diff --git a/packages/examples/src/examples/prepend-append-slots.ts b/packages/examples/src/examples/prepend-append-slots.ts new file mode 100644 index 000000000..820f0bf81 --- /dev/null +++ b/packages/examples/src/examples/prepend-append-slots.ts @@ -0,0 +1,148 @@ +/* + The MIT License + + Copyright (c) 2017-2025 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import { registerExamples } from '../register'; + +/** + * Prepend and Append Slots + * + * Prepend Only: + * - Display Name: Static decorative icon + * - Password: Dynamic strength meter + * + * Append Only: + * - Username: Async availability checker + * - Email: Copy to clipboard button + * + * Both: + * - Temperature: Dynamic icon + unit indicator + */ + +export const schema = { + type: 'object', + properties: { + // Prepend slot only + displayName: { + type: 'string', + description: 'Decorative icon', + }, + password: { + type: 'string', + description: 'Dynamic strength meter', + }, + + // Append slot only + username: { + type: 'string', + maxLength: 20, + description: 'Availability checker (try "admin" or "user")', + }, + email: { + type: 'string', + format: 'email', + description: 'Copy to clipboard', + }, + + // Both prepend AND append slots + temperature: { + type: 'integer', + minimum: -50, + maximum: 50, + description: 'Temperature with dynamic icon and unit (°C)', + }, + }, +}; + +export const uischema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Label', + text: 'Prepend Slot Only', + }, + { + type: 'Control', + scope: '#/properties/displayName', + options: { + clearable: false, + }, + }, + { + type: 'Control', + scope: '#/properties/password', + options: { + clearable: false, + }, + }, + + { + type: 'Label', + text: 'Append Slot Only', + }, + { + type: 'Control', + scope: '#/properties/username', + options: { + clearable: false, + }, + }, + { + type: 'Control', + scope: '#/properties/email', + options: { + clearable: false, + }, + }, + + { + type: 'Label', + text: 'Both Prepend and Append', + }, + { + type: 'Control', + scope: '#/properties/temperature', + options: { + clearable: false, + }, + }, + ], +}; + +export const data = { + displayName: 'John Doe', + password: '', + username: '', // Start empty so checker can be tested + email: 'user@example.com', + temperature: 22, +}; + +registerExamples([ + { + name: 'prepend-append-slots', + label: 'Prepend/Append Slots (Basic)', + data, + schema, + uischema, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index cb8684ed4..e200dc8e3 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -78,6 +78,7 @@ import * as login from './examples/login'; import * as mixed from './examples/mixed'; import * as mixedObject from './examples/mixed-object'; import * as string from './examples/string'; +import * as prependAppendSlots from './examples/prepend-append-slots'; export * from './register'; export * from './example'; @@ -145,4 +146,5 @@ export { issue_1884, arrayWithDefaults, string, + prependAppendSlots, }; diff --git a/packages/vue-vuetify/README.md b/packages/vue-vuetify/README.md index 8ae9c6d56..5e8e171ee 100644 --- a/packages/vue-vuetify/README.md +++ b/packages/vue-vuetify/README.md @@ -126,6 +126,26 @@ If note done yet, please [install Vuetify for Vue](https://vuetifyjs.com/en/gett For more information on how JSON Forms can be configured, please see the [README of `@jsonforms/vue`](https://github.com/eclipsesource/jsonforms/blob/master/packages/vue/README.md). +## Customization + +### Prepend and Append Slots + +All control renderers now support `prepend` and `append` slots, allowing you to add custom content before or after input fields without creating entirely custom renderers. + +**Example:** + +```vue + +``` + +See the "Prepend/Append Slots (Basic)" example in the dev app. + ## Override the ControlWrapper component All control renderers wrap their components with a **`ControlWrapper`** component, which by default uses **`DefaultControlWrapper`** to render the wrapper element around each control. diff --git a/packages/vue-vuetify/dev/renderers/DisplayNameWithIconRenderer.vue b/packages/vue-vuetify/dev/renderers/DisplayNameWithIconRenderer.vue new file mode 100644 index 000000000..0e7c2a0e5 --- /dev/null +++ b/packages/vue-vuetify/dev/renderers/DisplayNameWithIconRenderer.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/vue-vuetify/dev/renderers/PasswordStrengthRenderer.vue b/packages/vue-vuetify/dev/renderers/PasswordStrengthRenderer.vue new file mode 100644 index 000000000..c8f3e0a95 --- /dev/null +++ b/packages/vue-vuetify/dev/renderers/PasswordStrengthRenderer.vue @@ -0,0 +1,84 @@ + + + + diff --git a/packages/vue-vuetify/dev/renderers/StringWithCopyRenderer.vue b/packages/vue-vuetify/dev/renderers/StringWithCopyRenderer.vue new file mode 100644 index 000000000..79bac1780 --- /dev/null +++ b/packages/vue-vuetify/dev/renderers/StringWithCopyRenderer.vue @@ -0,0 +1,55 @@ + + + + diff --git a/packages/vue-vuetify/dev/renderers/TemperatureRenderer.vue b/packages/vue-vuetify/dev/renderers/TemperatureRenderer.vue new file mode 100644 index 000000000..72f99ce89 --- /dev/null +++ b/packages/vue-vuetify/dev/renderers/TemperatureRenderer.vue @@ -0,0 +1,77 @@ + + + diff --git a/packages/vue-vuetify/dev/renderers/UsernameCheckerRenderer.vue b/packages/vue-vuetify/dev/renderers/UsernameCheckerRenderer.vue new file mode 100644 index 000000000..cdde8caf8 --- /dev/null +++ b/packages/vue-vuetify/dev/renderers/UsernameCheckerRenderer.vue @@ -0,0 +1,132 @@ + + + + diff --git a/packages/vue-vuetify/dev/renderers/index.ts b/packages/vue-vuetify/dev/renderers/index.ts new file mode 100644 index 000000000..b2dc06aca --- /dev/null +++ b/packages/vue-vuetify/dev/renderers/index.ts @@ -0,0 +1,34 @@ +import { rankWith, scopeEndsWith } from '@jsonforms/core'; +import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core'; +import DisplayNameWithIconRenderer from './DisplayNameWithIconRenderer.vue'; +import PasswordStrengthRenderer from './PasswordStrengthRenderer.vue'; +import UsernameCheckerRenderer from './UsernameCheckerRenderer.vue'; +import StringWithCopyRenderer from './StringWithCopyRenderer.vue'; +import TemperatureRenderer from './TemperatureRenderer.vue'; + +export const prependAppendExampleRenderers: JsonFormsRendererRegistryEntry[] = [ + { + renderer: DisplayNameWithIconRenderer, + tester: rankWith(10, scopeEndsWith('displayName')), + }, + { + renderer: PasswordStrengthRenderer, + tester: rankWith(10, scopeEndsWith('password')), + }, + { + renderer: UsernameCheckerRenderer, + tester: rankWith(10, scopeEndsWith('username')), + }, + { + renderer: StringWithCopyRenderer, + tester: rankWith(10, scopeEndsWith('email')), + }, + { + renderer: TemperatureRenderer, + tester: rankWith(10, scopeEndsWith('temperature')), + }, +]; + +export function getCustomRenderersForExample(exampleName: string): JsonFormsRendererRegistryEntry[] { + return exampleName === 'prepend-append-slots' ? prependAppendExampleRenderers : []; +} diff --git a/packages/vue-vuetify/dev/views/ExampleView.vue b/packages/vue-vuetify/dev/views/ExampleView.vue index d995454f5..dfc1e2547 100644 --- a/packages/vue-vuetify/dev/views/ExampleView.vue +++ b/packages/vue-vuetify/dev/views/ExampleView.vue @@ -31,6 +31,7 @@ import { createAjv } from '../validate'; import { Pane, Splitpanes } from 'splitpanes'; import 'splitpanes/dist/splitpanes.css'; +import { getCustomRenderersForExample } from '../renderers'; const { extendedVuetifyRenderers } = await import('../../src'); @@ -54,8 +55,6 @@ const snackbar = ref(false); const snackbarText = ref(''); const snackbarTimeout = ref(3000); -const renderers = markRaw(extendedVuetifyRenderers); - const schemaModel = shallowRef(undefined); const uischemaModel = shallowRef( undefined, @@ -65,6 +64,10 @@ const dataModel = shallowRef(undefined); const initialState = (exampleProp: ExampleDescription) => { const example = cloneDeep(exampleProp); + // Get custom renderers for this example (if any) + const customRenderers = getCustomRenderersForExample(example.name); + const renderers = markRaw([...customRenderers, ...extendedVuetifyRenderers]); + return { data: example.data, schema: example.schema, diff --git a/packages/vue-vuetify/src/controls/AnyOfStringOrEnumControlRenderer.vue b/packages/vue-vuetify/src/controls/AnyOfStringOrEnumControlRenderer.vue index eb45c8400..8826edae2 100644 --- a/packages/vue-vuetify/src/controls/AnyOfStringOrEnumControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/AnyOfStringOrEnumControlRenderer.vue @@ -27,12 +27,19 @@ : undefined " :items="items" - :clearable="control.enabled" + :clearable="clearable" v-bind="vuetifyProps('v-combobox')" @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/BooleanControlRenderer.vue b/packages/vue-vuetify/src/controls/BooleanControlRenderer.vue index 8dafe3874..2ebf4fe47 100644 --- a/packages/vue-vuetify/src/controls/BooleanControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/BooleanControlRenderer.vue @@ -22,7 +22,14 @@ @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/BooleanToggleControlRenderer.vue b/packages/vue-vuetify/src/controls/BooleanToggleControlRenderer.vue index 307f36b8e..c64f70769 100644 --- a/packages/vue-vuetify/src/controls/BooleanToggleControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/BooleanToggleControlRenderer.vue @@ -25,7 +25,14 @@ @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/DateControlRenderer.vue b/packages/vue-vuetify/src/controls/DateControlRenderer.vue index af5613b87..75ac6ae62 100644 --- a/packages/vue-vuetify/src/controls/DateControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/DateControlRenderer.vue @@ -19,11 +19,14 @@ :error-messages="control.errors" v-bind="vuetifyProps('v-text-field')" v-model="inputModel" - :clearable="control.enabled" + :clearable="clearable" @focus="handleFocus" @blur="handleBlur" v-maska:[options]="maska" > + + diff --git a/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue b/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue index 8cf5be9a8..eda57ba99 100644 --- a/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue @@ -19,11 +19,14 @@ :error-messages="control.errors" v-bind="vuetifyProps('v-text-field')" v-model="inputModel" - :clearable="control.enabled" + :clearable="clearable" @focus="handleFocus" @blur="handleBlur" v-maska:[options]="maska" > + + diff --git a/packages/vue-vuetify/src/controls/EnumControlRenderer.vue b/packages/vue-vuetify/src/controls/EnumControlRenderer.vue index 709e9b4fe..7fcd95d1f 100644 --- a/packages/vue-vuetify/src/controls/EnumControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/EnumControlRenderer.vue @@ -17,7 +17,7 @@ :persistent-hint="persistentHint()" :required="control.required" :error-messages="control.errors" - :clearable="control.enabled" + :clearable="clearable" :model-value="control.data" :items="control.options" item-title="label" @@ -26,7 +26,14 @@ @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/IntegerControlRenderer.vue b/packages/vue-vuetify/src/controls/IntegerControlRenderer.vue index 21c96e751..42b9c380e 100644 --- a/packages/vue-vuetify/src/controls/IntegerControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/IntegerControlRenderer.vue @@ -19,12 +19,19 @@ :required="control.required" :error-messages="control.errors" :model-value="value" - :clearable="control.enabled" + :clearable="clearable" v-bind="vuetifyProps('v-text-field')" @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - > + > + + + diff --git a/packages/vue-vuetify/src/controls/MultiStringControlRenderer.vue b/packages/vue-vuetify/src/controls/MultiStringControlRenderer.vue index 3616cb59d..7d57c69f9 100644 --- a/packages/vue-vuetify/src/controls/MultiStringControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/MultiStringControlRenderer.vue @@ -26,13 +26,20 @@ ? control.schema.maxLength : undefined " - :clearable="control.enabled" + :clearable="clearable" multi-line v-bind="vuetifyProps('v-textarea')" @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/NumberControlRenderer.vue b/packages/vue-vuetify/src/controls/NumberControlRenderer.vue index 4d463bd26..796f066d3 100644 --- a/packages/vue-vuetify/src/controls/NumberControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/NumberControlRenderer.vue @@ -20,12 +20,19 @@ :required="control.required" :error-messages="control.errors" :model-value="value" - :clearable="control.enabled" + :clearable="clearable" v-bind="vuetifyProps('v-number-input')" @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - > + > + + + diff --git a/packages/vue-vuetify/src/controls/OneOfEnumControlRenderer.vue b/packages/vue-vuetify/src/controls/OneOfEnumControlRenderer.vue index e7fcd8eb3..2dfe9d132 100644 --- a/packages/vue-vuetify/src/controls/OneOfEnumControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/OneOfEnumControlRenderer.vue @@ -17,7 +17,7 @@ :persistent-hint="persistentHint()" :required="control.required" :error-messages="control.errors" - :clearable="control.enabled" + :clearable="clearable" :model-value="control.data" :items="control.options" item-title="label" @@ -26,7 +26,14 @@ @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/OneOfRadioGroupControlRenderer.vue b/packages/vue-vuetify/src/controls/OneOfRadioGroupControlRenderer.vue index 0f91f2a83..02fe10608 100644 --- a/packages/vue-vuetify/src/controls/OneOfRadioGroupControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/OneOfRadioGroupControlRenderer.vue @@ -24,6 +24,9 @@ @focus="handleFocus" @blur="handleBlur" > + + diff --git a/packages/vue-vuetify/src/controls/PasswordControlRenderer.vue b/packages/vue-vuetify/src/controls/PasswordControlRenderer.vue index 3069d51e3..593d2a87c 100644 --- a/packages/vue-vuetify/src/controls/PasswordControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/PasswordControlRenderer.vue @@ -36,7 +36,12 @@ @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/RadioGroupControlRenderer.vue b/packages/vue-vuetify/src/controls/RadioGroupControlRenderer.vue index 85147661c..b9f03f228 100644 --- a/packages/vue-vuetify/src/controls/RadioGroupControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/RadioGroupControlRenderer.vue @@ -24,6 +24,9 @@ @focus="handleFocus" @blur="handleBlur" > + + diff --git a/packages/vue-vuetify/src/controls/SliderControlRenderer.vue b/packages/vue-vuetify/src/controls/SliderControlRenderer.vue index 0d641cf37..12be9e3de 100644 --- a/packages/vue-vuetify/src/controls/SliderControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/SliderControlRenderer.vue @@ -25,7 +25,14 @@ @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/StringControlRenderer.vue b/packages/vue-vuetify/src/controls/StringControlRenderer.vue index d2b692fd0..3e7263d6e 100644 --- a/packages/vue-vuetify/src/controls/StringControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/StringControlRenderer.vue @@ -26,7 +26,7 @@ ? control.schema.maxLength : undefined " - :clearable="control.enabled" + :clearable="clearable" :model-value="control.data" :items="suggestions" hide-no-data @@ -34,7 +34,14 @@ @update:model-value="onChange" @focus="handleFocus" @blur="handleBlur" - /> + > + + + + > + + + diff --git a/packages/vue-vuetify/src/controls/StringMaskControlRenderer.vue b/packages/vue-vuetify/src/controls/StringMaskControlRenderer.vue index e23bd6f2c..934629fad 100644 --- a/packages/vue-vuetify/src/controls/StringMaskControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/StringMaskControlRenderer.vue @@ -25,14 +25,21 @@ ? control.schema.maxLength : undefined " - :clearable="control.enabled" + :clearable="clearable" @click:clear="clear" v-bind="vuetifyProps('v-text-field')" @focus="handleFocus" @blur="handleBlur" v-model="maskModel" v-maska:[options] - /> + > + + + diff --git a/packages/vue-vuetify/src/controls/TimeControlRenderer.vue b/packages/vue-vuetify/src/controls/TimeControlRenderer.vue index 9dc55993c..d266f51b1 100644 --- a/packages/vue-vuetify/src/controls/TimeControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/TimeControlRenderer.vue @@ -19,11 +19,14 @@ :error-messages="control.errors" v-bind="vuetifyProps('v-text-field')" v-model="inputModel" - :clearable="control.enabled" + :clearable="clearable" @focus="handleFocus" @blur="handleBlur" v-maska:[options]="maska" > + + diff --git a/packages/vue-vuetify/src/util/composition.ts b/packages/vue-vuetify/src/util/composition.ts index 876c8bf31..f4d42d4ff 100644 --- a/packages/vue-vuetify/src/util/composition.ts +++ b/packages/vue-vuetify/src/util/composition.ts @@ -140,6 +140,7 @@ export const useVuetifyControl = < errors: string; id: string; visible: boolean; + enabled: boolean; }, I extends { control: ComputedRef; @@ -215,6 +216,12 @@ export const useVuetifyControl = < const rawErrors = computed(() => input.control.value.errors); + const clearable = computed(() => { + return appliedOptions.value.clearable !== undefined + ? appliedOptions.value.clearable + : input.control.value.enabled; + }); + return { ...input, control: overwrittenControl, @@ -226,6 +233,7 @@ export const useVuetifyControl = < vuetifyProps, persistentHint, computedLabel, + clearable, touched, handleBlur, handleFocus,