diff --git a/CHANGELOG.md b/CHANGELOG.md
index 735c003..41feb01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial release
+- `TranslatableInput` field: multi-language text/textarea whose value is an array keyed by locale, with a globe button that opens a dialog to edit every locale, per-locale validation via a `TranslationsRule` (errors reported as `name.en`, `name.ar`, `name.ckb`, ...) and locale metadata (native name and text direction) read from the panel's `config('app.available_locales')` list by default, so languages are defined once in `config/app.php`. An optional `laravilt-forms.locales` config key, shipped empty, overrides that list with plain codes or per-locale `name`/`direction`/`label` metadata when content languages differ from UI languages. Shipped for Blade, Vue and React.
+- `required()` is now mirrored as a `required` validation rule on every field, so it is enforced even when other rules such as `maxLength()` are set (previously the schema only added it for fields with no other rules).
### Changed
diff --git a/README.md b/README.md
index 23f12b9..bb17741 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ php artisan make:component CustomField
## Field Types
-**Basic:** TextInput, Textarea, NumberField, Select, Checkbox, CheckboxList, Radio, Toggle, ToggleButtons, Hidden
+**Basic:** TextInput, Textarea, TranslatableInput, NumberField, Select, Checkbox, CheckboxList, Radio, Toggle, ToggleButtons, Hidden
**Date & Time:** DatePicker, DateTimePicker, TimePicker, DateRangePicker
diff --git a/config/laravilt-forms.php b/config/laravilt-forms.php
index 9957ce0..ebe815b 100644
--- a/config/laravilt-forms.php
+++ b/config/laravilt-forms.php
@@ -29,5 +29,40 @@
'middleware' => ['web', 'auth', 'throttle:120,1'],
],
+ /*
+ |--------------------------------------------------------------------------
+ | Locales
+ |--------------------------------------------------------------------------
+ |
+ | The locales offered by TranslatableInput when a field does not call
+ | ->locales() itself. Leave this empty to reuse the languages the panel
+ | already knows about from config('app.available_locales'), the same list
+ | the Locale & Timezone settings page shows, so a project defines its
+ | languages once in config/app.php:
+ |
+ | 'available_locales' => [
+ | ['value' => 'en', 'label' => 'English', 'dir' => 'ltr'],
+ | ['value' => 'ar', 'label' => 'العربية', 'dir' => 'rtl'],
+ | ],
+ |
+ | Set it only when the languages content is written in differ from the
+ | languages the UI is shown in. Each entry may be a plain code or a
+ | code => metadata pair with a native "name" and a "direction" (ltr or
+ | rtl); an optional "label" overrides the short badge on the globe button.
+ |
+ | 'locales' => ['en', 'ar', 'ckb'],
+ | 'locales' => [
+ | 'en' => ['name' => 'English', 'direction' => 'ltr'],
+ | 'ar' => ['name' => 'العربية', 'direction' => 'rtl'],
+ | 'ckb' => ['name' => 'کوردی', 'direction' => 'rtl'],
+ | ],
+ |
+ | A plain code gets the code as its name and "ltr" as its direction. When
+ | both lists are empty the field falls back to the application locale.
+ |
+ */
+
+ 'locales' => [],
+
// Add your configuration options here
];
diff --git a/docs/index.md b/docs/index.md
index c48fe61..26e9a71 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -45,6 +45,7 @@ php artisan make:form UserForm --resource
### 📝 Basic Fields
- **TextInput**: Single-line text input with validation
- **Textarea**: Multi-line text input
+- **TranslatableInput**: Multi-language text input with a per-locale dialog
- **NumberField**: Numeric input with min/max
- **Select**: Dropdown select with search
- **Checkbox**: Single checkbox
@@ -261,6 +262,116 @@ TextInput::make('name')
->helperText('Enter your full name');
```
+### TranslatableInput
+
+A multi-language text field. Its value is an array keyed by locale code
+(`['en' => 'Title', 'ar' => 'العنوان', 'ckb' => 'ناونیشان']`). The main input edits the active
+locale; a globe button inside the input opens a dialog with one input per
+locale (RTL locales render with `dir="rtl"`), so long titles and content stay
+readable. The globe is hidden when only one
+locale is allowed.
+
+```php
+TranslatableInput::make('name')
+ ->label('Name')
+ ->locales(['en', 'ar', 'ckb']) // defaults to the configured locales, see below
+ ->activeLocale('en') // defaults to the app locale when allowed
+ ->required() // every locale is required...
+ ->requiredLocales(['en']) // ...unless you narrow it down
+ ->maxLength(255) // applied per locale
+ ->multiline() // textarea per locale
+ ->rows(4);
+```
+
+Locales resolve in this order: explicit `locales()`, then
+`config('laravilt-forms.locales')` (published to `config/laravilt-forms.php`),
+then `config('app.available_locales')`, then the application locale.
+
+By default the field reuses the languages the panel already knows about. That
+is the same list the [Locale & Timezone](../auth/profile/preferences.md)
+settings page shows, so a project defines its languages once in
+`config/app.php`; `label` becomes the native name shown in the dialog and
+`dir` sets `dir` on each input:
+
+```php
+// config/app.php
+'available_locales' => [
+ ['value' => 'en', 'label' => 'English', 'dir' => 'ltr'],
+ ['value' => 'ar', 'label' => 'العربية', 'dir' => 'rtl'],
+ ['value' => 'ckb', 'label' => 'کوردی', 'dir' => 'rtl'],
+],
+```
+
+Set `laravilt-forms.locales` only when the languages content is written in
+differ from the languages the UI is shown in, for example an English-only
+admin panel that manages content in three languages. It accepts plain codes or
+per-locale metadata, and an optional `label` overrides the short badge on the
+globe button:
+
+```php
+// config/laravilt-forms.php
+'locales' => [
+ 'en' => ['name' => 'English', 'direction' => 'ltr'],
+ 'ar' => ['name' => 'العربية', 'direction' => 'rtl'],
+ 'ckb' => ['name' => 'کوردی', 'direction' => 'rtl'],
+],
+// or simply: 'locales' => ['en', 'ar', 'ckb'],
+```
+
+Names and directions are looked up in `laravilt-forms.locales` first and
+`app.available_locales` second, so plain codes in the forms config still pick
+up the names the panel defines. A code found in neither is shown as its own
+code, labelled with its uppercased base code, and rendered LTR.
+
+Every locale is validated on its own and errors are reported per locale key
+(`name.en`, `name.ar`, ...), which the field shows next to the matching input.
+`getValidationRules()` returns one rule list for the field, as the schema
+expects, with a `TranslationsRule` carrying the per-locale rules; use
+`getLocaleValidationRules()` for flat keys when validating by hand:
+
+```php
+$field = TranslatableInput::make('name')->locales(['en', 'ar', 'ckb'])->required()->maxLength(120);
+
+$field->getValidationRules();
+// ['required', 'array', TranslationsRule(en|ar|ckb => ['required', 'string', 'max:120'])]
+
+$field->getLocaleValidationRules();
+// [
+// 'name' => ['required', 'array'],
+// 'name.en' => ['required', 'string', 'max:120'],
+// 'name.ar' => ['required', 'string', 'max:120'],
+// 'name.ckb' => ['required', 'string', 'max:120'],
+// ]
+```
+
+Messages use the field label plus the locale, e.g. "The Name (EN) field is
+required.", and `validationMessages(['required' => '...'])` applies to every
+locale (`['en.required' => '...']` targets one).
+
+`hydrateState()` / `dehydrateState()` accept a JSON string, an array or `null`
+and always return an array with every allowed locale present, so the field works
+with both storage styles:
+
+```php
+// spatie/laravel-translatable
+class Product extends Model
+{
+ use \Spatie\Translatable\HasTranslations;
+
+ public $translatable = ['name'];
+}
+
+// or a plain JSON column
+class Category extends Model
+{
+ protected $casts = ['name' => 'array'];
+}
+
+// Both accept the field's value as-is:
+$product->setTranslations('name', $data['name']); // or $product->name = $data['name'];
+$category->name = $data['name'];
+```
+
### Select
```php
@@ -592,6 +703,7 @@ class ProductForm extends Form
### Basic
- TextInput
- Textarea
+- TranslatableInput
- NumberField
- Select
- Checkbox
diff --git a/docs/mcp-server.md b/docs/mcp-server.md
index 16e077a..e33557f 100644
--- a/docs/mcp-server.md
+++ b/docs/mcp-server.md
@@ -119,6 +119,7 @@ For MCP tools to provide field type information:
### Basic Fields
- TextInput: Single-line text input
- Textarea: Multi-line text input
+- TranslatableInput: Multi-language text input (value keyed by locale)
- NumberField: Numeric input
- Select: Dropdown select
- Checkbox: Single checkbox
diff --git a/lang/ar/forms.php b/lang/ar/forms.php
index f36913c..4df11f3 100644
--- a/lang/ar/forms.php
+++ b/lang/ar/forms.php
@@ -93,4 +93,11 @@
'search_placeholder' => 'بحث في الصلاحيات...',
'no_results' => 'لم يتم العثور على صلاحيات',
],
+
+ // TranslatableInput Component
+ 'translatable_input' => [
+ 'translations' => 'الترجمات',
+ 'description' => 'حرّر هذا الحقل بكل اللغات.',
+ 'done' => 'تم',
+ ],
];
diff --git a/lang/ckb/forms.php b/lang/ckb/forms.php
index 0e7e055..a7eaa7f 100644
--- a/lang/ckb/forms.php
+++ b/lang/ckb/forms.php
@@ -93,4 +93,11 @@
'search_placeholder' => 'گەڕان بۆ دەسەڵاتەکان...',
'no_results' => 'هیچ دەسەڵاتێک نەدۆزرایەوە',
],
+
+ // TranslatableInput Component
+ 'translatable_input' => [
+ 'translations' => 'وەرگێڕانەکان',
+ 'description' => 'ئەم خانەیە بە هەموو زمانەکان دەستکاری بکە.',
+ 'done' => 'تەواو',
+ ],
];
diff --git a/lang/en/forms.php b/lang/en/forms.php
index 26632d3..00ba5ee 100644
--- a/lang/en/forms.php
+++ b/lang/en/forms.php
@@ -93,4 +93,11 @@
'search_placeholder' => 'Search permissions...',
'no_results' => 'No permissions found',
],
+
+ // TranslatableInput Component
+ 'translatable_input' => [
+ 'translations' => 'Translations',
+ 'description' => 'Edit this field in every language.',
+ 'done' => 'Done',
+ ],
];
diff --git a/resources/js/app.ts b/resources/js/app.ts
index 00def27..9aed132 100644
--- a/resources/js/app.ts
+++ b/resources/js/app.ts
@@ -3,6 +3,7 @@ import Form from './components/Form.vue'
import FieldWrapper from './components/FieldWrapper.vue'
import TextInput from './components/fields/TextInput.vue'
import Textarea from './components/fields/Textarea.vue'
+import TranslatableInput from './components/fields/TranslatableInput.vue'
import Select from './components/fields/Select.vue'
import Checkbox from './components/fields/Checkbox.vue'
import CheckboxList from './components/fields/CheckboxList.vue'
@@ -43,6 +44,7 @@ export default {
// Field components
app.component('laravilt-text-input', TextInput)
app.component('laravilt-textarea', Textarea)
+ app.component('laravilt-translatable-input', TranslatableInput)
app.component('laravilt-select', Select)
app.component('laravilt-checkbox', Checkbox)
app.component('laravilt-checkbox-list', CheckboxList)
diff --git a/resources/js/components-only.js b/resources/js/components-only.js
index 948849a..8f841f2 100644
--- a/resources/js/components-only.js
+++ b/resources/js/components-only.js
@@ -6,6 +6,7 @@ import LaraviltForm from './components/Form.vue';
import LaraviltFieldWrapper from './components/FieldWrapper.vue';
import LaraviltTextInput from './components/fields/TextInput.vue';
import LaraviltTextarea from './components/fields/Textarea.vue';
+import LaraviltTranslatableInput from './components/fields/TranslatableInput.vue';
import LaraviltSelect from './components/fields/SelectWrapper.vue';
import LaraviltCheckbox from './components/fields/Checkbox.vue';
import LaraviltCheckboxList from './components/fields/CheckboxList.vue';
@@ -45,6 +46,7 @@ export default {
// Field components
app.component('laravilt-text-input', LaraviltTextInput);
app.component('laravilt-textarea', LaraviltTextarea);
+ app.component('laravilt-translatable-input', LaraviltTranslatableInput);
app.component('laravilt-select', LaraviltSelect);
app.component('laravilt-checkbox', LaraviltCheckbox);
app.component('laravilt-checkbox-list', LaraviltCheckboxList);
diff --git a/resources/js/components/fields/TranslatableInput.vue b/resources/js/components/fields/TranslatableInput.vue
new file mode 100644
index 0000000..03cc026
--- /dev/null
+++ b/resources/js/components/fields/TranslatableInput.vue
@@ -0,0 +1,379 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ localeErrorMessage }}
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/schema/Section.vue b/resources/js/components/schema/Section.vue
index 4df5cae..3f0a031 100644
--- a/resources/js/components/schema/Section.vue
+++ b/resources/js/components/schema/Section.vue
@@ -109,6 +109,7 @@ const componentMap: Record = {
// Form field components
text_input: defineAsyncComponent(() => import('../fields/TextInput.vue')),
textarea: defineAsyncComponent(() => import('../fields/Textarea.vue')),
+ translatable_input: defineAsyncComponent(() => import('../fields/TranslatableInput.vue')),
select: defineAsyncComponent(() => import('../fields/Select.vue')),
checkbox: defineAsyncComponent(() => import('../fields/Checkbox.vue')),
checkbox_list: CheckboxList,
diff --git a/resources/js/index.js b/resources/js/index.js
index 4f6c61c..36e9fec 100644
--- a/resources/js/index.js
+++ b/resources/js/index.js
@@ -10,6 +10,7 @@ export { default as FieldWrapper } from './components/FieldWrapper.vue';
// Export field components
export { default as TextInput } from './components/fields/TextInput.vue';
export { default as Textarea } from './components/fields/Textarea.vue';
+export { default as TranslatableInput } from './components/fields/TranslatableInput.vue';
export { default as Select } from './components/fields/Select.vue';
export { default as Checkbox } from './components/fields/Checkbox.vue';
export { default as CheckboxList } from './components/fields/CheckboxList.vue';
@@ -46,6 +47,7 @@ import LaraviltForm from './components/Form.vue';
import LaraviltFieldWrapper from './components/FieldWrapper.vue';
import LaraviltTextInput from './components/fields/TextInput.vue';
import LaraviltTextarea from './components/fields/Textarea.vue';
+import LaraviltTranslatableInput from './components/fields/TranslatableInput.vue';
import LaraviltSelect from './components/fields/Select.vue';
import LaraviltCheckbox from './components/fields/Checkbox.vue';
import LaraviltCheckboxList from './components/fields/CheckboxList.vue';
@@ -82,6 +84,7 @@ if (typeof window !== 'undefined' && window.Vue) {
FieldWrapper: LaraviltFieldWrapper,
TextInput: LaraviltTextInput,
Textarea: LaraviltTextarea,
+ TranslatableInput: LaraviltTranslatableInput,
Select: LaraviltSelect,
Checkbox: LaraviltCheckbox,
CheckboxList: LaraviltCheckboxList,
@@ -128,6 +131,7 @@ export default {
// Field components
app.component('laravilt-text-input', LaraviltTextInput);
app.component('laravilt-textarea', LaraviltTextarea);
+ app.component('laravilt-translatable-input', LaraviltTranslatableInput);
app.component('laravilt-select', LaraviltSelect);
app.component('laravilt-checkbox', LaraviltCheckbox);
app.component('laravilt-checkbox-list', LaraviltCheckboxList);
diff --git a/resources/react/app.ts b/resources/react/app.ts
index 737068a..dd11782 100644
--- a/resources/react/app.ts
+++ b/resources/react/app.ts
@@ -26,6 +26,7 @@ import Slider from './components/fields/Slider';
import TagsInput from './components/fields/TagsInput';
import TextInput from './components/fields/TextInput';
import Textarea from './components/fields/Textarea';
+import TranslatableInput from './components/fields/TranslatableInput';
import TimePicker from './components/fields/TimePicker';
import Toggle from './components/fields/Toggle';
import ToggleButtons from './components/fields/ToggleButtons';
@@ -45,6 +46,7 @@ export default {
// Field components
'laravilt-text-input': TextInput,
'laravilt-textarea': Textarea,
+ 'laravilt-translatable-input': TranslatableInput,
'laravilt-select': Select,
'laravilt-checkbox': Checkbox,
'laravilt-checkbox-list': CheckboxList,
diff --git a/resources/react/components-only.ts b/resources/react/components-only.ts
index 3a04bd4..45d8d0f 100644
--- a/resources/react/components-only.ts
+++ b/resources/react/components-only.ts
@@ -28,6 +28,7 @@ import LaraviltSlider from './components/fields/Slider';
import LaraviltTagsInput from './components/fields/TagsInput';
import LaraviltTextInput from './components/fields/TextInput';
import LaraviltTextarea from './components/fields/Textarea';
+import LaraviltTranslatableInput from './components/fields/TranslatableInput';
import LaraviltTimePicker from './components/fields/TimePicker';
import LaraviltToggle from './components/fields/Toggle';
import LaraviltToggleButtons from './components/fields/ToggleButtons';
@@ -46,6 +47,7 @@ export default {
// Field components
'laravilt-text-input': LaraviltTextInput,
'laravilt-textarea': LaraviltTextarea,
+ 'laravilt-translatable-input': LaraviltTranslatableInput,
'laravilt-select': LaraviltSelect,
'laravilt-checkbox': LaraviltCheckbox,
'laravilt-checkbox-list': LaraviltCheckboxList,
diff --git a/resources/react/components/fields/TranslatableInput.tsx b/resources/react/components/fields/TranslatableInput.tsx
new file mode 100644
index 0000000..7fe8683
--- /dev/null
+++ b/resources/react/components/fields/TranslatableInput.tsx
@@ -0,0 +1,435 @@
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea as UiTextarea } from '@/components/ui/textarea';
+import { cn } from '@/lib/utils';
+import ActionButton from '@laravilt/actions/components/ActionButton';
+import { useErrors, useSchemaContext } from '@laravilt/support/composables/contexts';
+import { useLocalization } from '@laravilt/support/composables/useLocalization';
+import { Globe } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import { useReactiveField } from '../../composables/useReactiveField';
+import FieldWrapper from '../FieldWrapper';
+
+/** Locale metadata as serialized by TranslatableInput::toLaraviltProps(). */
+export interface TranslatableLocale {
+ code: string;
+ name?: string;
+ label?: string;
+ direction?: 'ltr' | 'rtl' | string;
+}
+
+export type Translations = Record;
+
+export interface TranslatableInputSlotScope {
+ id: string;
+ name: string;
+ translations: Translations;
+ activeLocale: string;
+ locales: TranslatableLocale[];
+ multiline?: boolean;
+ rows?: number;
+ placeholder?: string;
+ readonly?: boolean;
+ maxLength?: number;
+ hasError: boolean;
+ setLocale: (code: string, value: string) => void;
+ extraAttributes?: Record;
+}
+
+export interface TranslatableInputProps {
+ id?: string;
+ name: string;
+ label?: string;
+ placeholder?: string;
+ hint?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readonly?: boolean;
+ helperText?: string;
+ value?: Translations | string | null;
+ modelValue?: Translations | string | null;
+ /** Allowed locales: metadata objects from PHP, or plain locale codes. */
+ locales?: (TranslatableLocale | string)[];
+ /** Locale edited by the main (collapsed) input. */
+ activeLocale?: string;
+ requiredLocales?: string[];
+ multiline?: boolean;
+ rows?: number;
+ maxLength?: number;
+ extraAttributes?: Record;
+ hidden?: boolean;
+ columnSpan?: number | string;
+ hintActions?: any[];
+ prefixActions?: any[];
+ suffixActions?: any[];
+ isLive?: boolean;
+ isLazy?: boolean;
+ liveDebounce?: number;
+ onUpdateModelValue?: (value: Translations) => void;
+ onUpdateValue?: (value: Translations) => void;
+ /** Scoped default slot (Blade mode). */
+ children?: (scope: TranslatableInputSlotScope) => ReactNode;
+ [key: string]: any;
+}
+
+// Split `class` out of server-provided extra attributes so React receives `className`
+const splitExtraAttributes = (extraAttributes?: Record) => {
+ if (!extraAttributes) {
+ return { attributes: {}, className: undefined as string | undefined };
+ }
+ const { class: klass, className, ...attributes } = extraAttributes;
+ return { attributes, className: cn(klass, className) || undefined };
+};
+
+// Normalize the locale list: plain codes become minimal metadata objects
+const normalizeLocales = (locales?: (TranslatableLocale | string)[], activeLocale?: string): TranslatableLocale[] => {
+ const list = (locales || [])
+ .map((locale) => (typeof locale === 'string' ? { code: locale } : locale))
+ .filter((locale): locale is TranslatableLocale => !!locale && !!locale.code);
+
+ if (list.length) {
+ return list;
+ }
+
+ // No metadata from the server: fall back to the active locale so the field still works
+ return [{ code: activeLocale || 'en' }];
+};
+
+// Pick the main input's locale: the requested one if allowed, else the first allowed locale
+const preferredActive = (codes: string[], activeLocale?: string): string => {
+ if (activeLocale && codes.includes(activeLocale)) return activeLocale;
+ return codes[0] ?? activeLocale ?? 'en';
+};
+
+// Parse whatever the server/parent hands us into a dict with every allowed locale present
+const parseTranslations = (value: unknown, codes: string[], fallbackLocale: string): Translations => {
+ let dict: Record = {};
+
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ dict = value as Record;
+ } else if (typeof value === 'string' && value !== '') {
+ try {
+ const parsed = value.trim().startsWith('{') ? JSON.parse(value) : null;
+ dict = parsed && typeof parsed === 'object' ? parsed : { [fallbackLocale]: value };
+ } catch {
+ dict = { [fallbackLocale]: value };
+ }
+ }
+
+ return Object.fromEntries(codes.map((code) => [code, dict[code] == null ? '' : String(dict[code])]));
+};
+
+export default function TranslatableInput(props: TranslatableInputProps) {
+ const {
+ id,
+ name,
+ label,
+ placeholder,
+ hint,
+ required,
+ disabled,
+ readonly,
+ helperText,
+ value,
+ modelValue,
+ locales,
+ activeLocale,
+ requiredLocales,
+ multiline,
+ rows,
+ maxLength,
+ extraAttributes,
+ hidden,
+ columnSpan,
+ hintActions,
+ prefixActions,
+ suffixActions,
+ isLive,
+ isLazy,
+ liveDebounce,
+ onUpdateModelValue,
+ onUpdateValue,
+ children,
+ } = props;
+
+ const { trans } = useLocalization();
+
+ const availableLocales = useMemo(() => normalizeLocales(locales, activeLocale), [locales, activeLocale]);
+ const localeCodes = useMemo(() => availableLocales.map((locale) => locale.code), [availableLocales]);
+ const hasLocaleSwitcher = availableLocales.length > 1;
+
+ const findLocale = (code: string) => availableLocales.find((locale) => locale.code === code);
+ const labelOf = (code: string) => findLocale(code)?.label || code.split(/[-_]/)[0].toUpperCase();
+ const directionOf = (code: string) => findLocale(code)?.direction || 'ltr';
+ const isLocaleRequired = (code: string) => (requiredLocales ? requiredLocales.includes(code) : !!required);
+
+ const incoming = modelValue ?? value;
+
+ const [active, setActive] = useState(() => preferredActive(localeCodes, activeLocale));
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [localValue, setLocalValueState] = useState(() =>
+ parseTranslations(incoming, localeCodes, preferredActive(localeCodes, activeLocale)),
+ );
+ const localValueRef = useRef(localValue);
+ const setLocalValue = (next: Translations) => {
+ localValueRef.current = next;
+ setLocalValueState(next);
+ };
+
+ // Sync local value when the prop changes from an external source (Vue: watch on modelValue/value)
+ const incomingKey = typeof incoming === 'string' ? incoming : JSON.stringify(incoming ?? null);
+ useEffect(() => {
+ const next = parseTranslations(incoming, localeCodes, preferredActive(localeCodes, activeLocale));
+ if (JSON.stringify(next) !== JSON.stringify(localValueRef.current)) {
+ setLocalValue(next);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [incomingKey, localeCodes]);
+
+ // Keep the active locale valid when the allowed set or preference changes
+ useEffect(() => {
+ if (!localeCodes.includes(active)) {
+ setActive(preferredActive(localeCodes, activeLocale));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [localeCodes, activeLocale]);
+
+ const setLocale = (code: string, next: string) => {
+ const translations = { ...localValueRef.current, [code]: next };
+ setLocalValue(translations);
+ onUpdateModelValue?.({ ...translations });
+ onUpdateValue?.({ ...translations });
+ };
+
+ // Reactive field support (live/lazy): re-evaluate the schema when the value changes
+ useReactiveField(name, localValue, { isLive, isLazy, liveDebounce });
+
+ // Dependencies for suffix actions (Vue: inject('getFormData'))
+ const { getFormData } = useSchemaContext();
+
+ // Errors from parent (Vue: inject('errors')), including per-locale keys (name.en, name.ar, ...)
+ const errors = useErrors() || {};
+ const firstError = (key: string): string | null => {
+ const error = (errors as Record)[key];
+ if (!error) return null;
+ return Array.isArray(error) ? error[0] : error;
+ };
+ const localeError = (code: string) => firstError(`${name}.${code}`);
+ const localeHasError = (code: string) => !!localeError(code);
+ const localeErrorMessage = (() => {
+ for (const code of localeCodes) {
+ const error = localeError(code);
+ if (error) return `${labelOf(code)}: ${error}`;
+ }
+ return null;
+ })();
+ const errorMessage = firstError(name) || localeErrorMessage;
+ const hasError = !!errorMessage;
+
+ // Use slot if provided (Blade mode), otherwise use internal template (Direct usage)
+ if (children) {
+ return (
+ <>
+ {children({
+ id: id || name,
+ name,
+ translations: localValue,
+ activeLocale: active,
+ locales: availableLocales,
+ multiline,
+ rows,
+ placeholder,
+ readonly,
+ maxLength,
+ hasError,
+ setLocale,
+ extraAttributes,
+ })}
+ >
+ );
+ }
+
+ const extra = splitExtraAttributes(extraAttributes);
+ const fieldId = id || name;
+
+ const mainClassName = cn(
+ 'w-full',
+ extra.className,
+ hasError ? 'border-destructive focus-visible:ring-destructive' : '',
+ hasLocaleSwitcher ? 'pe-12' : '',
+ );
+
+ return (
+
+