From aa7d80458e21760ba98f2f674280293aff9d945e Mon Sep 17 00:00:00 2001 From: swara Date: Wed, 16 Sep 2026 19:36:40 +0200 Subject: [PATCH 1/9] feat(forms): Add TranslatableInput multi-language field for PHP, Vue and React --- CHANGELOG.md | 1 + README.md | 2 +- config/laravilt-forms.php | 26 ++ docs/index.md | 76 ++++ docs/mcp-server.md | 1 + lang/ar/forms.php | 5 + lang/ckb/forms.php | 5 + lang/en/forms.php | 5 + resources/js/app.ts | 2 + resources/js/components-only.js | 2 + .../components/fields/TranslatableInput.vue | 358 +++++++++++++++ resources/js/index.js | 4 + resources/react/app.ts | 2 + resources/react/components-only.ts | 2 + .../components/fields/TranslatableInput.tsx | 416 ++++++++++++++++++ resources/react/index.ts | 3 + .../fields/translatable-input.blade.php | 10 + src/Components/TranslatableInput.php | 294 +++++++++++++ src/FormsServiceProvider.php | 2 + src/Support/Locales.php | 124 ++++++ tests/Unit/TranslatableInputTest.php | 221 ++++++++++ 21 files changed, 1560 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/fields/TranslatableInput.vue create mode 100644 resources/react/components/fields/TranslatableInput.tsx create mode 100644 resources/views/components/fields/translatable-input.blade.php create mode 100644 src/Components/TranslatableInput.php create mode 100644 src/Support/Locales.php create mode 100644 tests/Unit/TranslatableInputTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 735c003..08fb432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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 popover to edit every locale, per-locale validation rules (`name.en`, `name.ar`, ...) and a new `laravilt-forms.locales` config key that accepts plain codes or per-locale `name`/`direction`/`label` metadata. Shipped for Blade, Vue and React. ### 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..eabd891 100644 --- a/config/laravilt-forms.php +++ b/config/laravilt-forms.php @@ -29,5 +29,31 @@ 'middleware' => ['web', 'auth', 'throttle:120,1'], ], + /* + |-------------------------------------------------------------------------- + | Locales + |-------------------------------------------------------------------------- + | + | The locales offered by TranslatableInput when a field does not call + | ->locales() itself. 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 shown 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. + | Leave empty to fall back to the application locale. + | + */ + + 'locales' => [ + 'en' => ['name' => 'English', 'direction' => 'ltr'], + ], + // Add your configuration options here ]; diff --git a/docs/index.md b/docs/index.md index c48fe61..bb5b9ca 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 popover - **NumberField**: Numeric input with min/max - **Select**: Dropdown select with search - **Checkbox**: Single checkbox @@ -261,6 +262,80 @@ 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' => 'العنوان']`). The main input edits the active +locale; a globe button inside the input opens a popover with one input per +locale (RTL locales render with `dir="rtl"`). The globe is hidden when only one +locale is allowed. + +```php +TranslatableInput::make('name') + ->label('Name') + ->locales(['en', 'ar', 'ckb']) // defaults to config('laravilt-forms.locales') + ->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 the application locale. The config accepts plain codes or per-locale +metadata; the native name is shown in the popover and the direction sets +`dir` on each input: + +```php +// config/laravilt-forms.php +'locales' => [ + 'en' => ['name' => 'English', 'direction' => 'ltr'], + 'ar' => ['name' => 'العربية', 'direction' => 'rtl'], + 'ckb' => ['name' => 'کوردی', 'direction' => 'rtl', 'label' => 'KU'], +], +// or simply: 'locales' => ['en', 'ar', 'ckb'], +``` + +The config is the only source of names and directions: a plain code is shown +as its own code, labelled with its uppercased base code, and rendered LTR. + +Validation rules are produced per locale key: + +```php +TranslatableInput::make('name')->locales(['en', 'ar'])->required()->getValidationRules(); +// [ +// 'name' => ['required', 'array'], +// 'name.en' => ['required', 'string'], +// 'name.ar' => ['required', 'string'], +// ] +``` + +`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 +667,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..f1e5665 100644 --- a/lang/ar/forms.php +++ b/lang/ar/forms.php @@ -93,4 +93,9 @@ 'search_placeholder' => 'بحث في الصلاحيات...', 'no_results' => 'لم يتم العثور على صلاحيات', ], + + // TranslatableInput Component + 'translatable_input' => [ + 'translations' => 'الترجمات', + ], ]; diff --git a/lang/ckb/forms.php b/lang/ckb/forms.php index 0e7e055..3fb0253 100644 --- a/lang/ckb/forms.php +++ b/lang/ckb/forms.php @@ -93,4 +93,9 @@ 'search_placeholder' => 'گەڕان بۆ دەسەڵاتەکان...', 'no_results' => 'هیچ دەسەڵاتێک نەدۆزرایەوە', ], + + // TranslatableInput Component + 'translatable_input' => [ + 'translations' => 'وەرگێڕانەکان', + ], ]; diff --git a/lang/en/forms.php b/lang/en/forms.php index 26632d3..ca4d42a 100644 --- a/lang/en/forms.php +++ b/lang/en/forms.php @@ -93,4 +93,9 @@ 'search_placeholder' => 'Search permissions...', 'no_results' => 'No permissions found', ], + + // TranslatableInput Component + 'translatable_input' => [ + 'translations' => 'Translations', + ], ]; 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..5723340 --- /dev/null +++ b/resources/js/components/fields/TranslatableInput.vue @@ -0,0 +1,358 @@ +