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
+
+
+
+ mdi-help-circle
+
+
+
+```
+
+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 @@
+
+
+
+
+ mdi-badge-account-horizontal-outline
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ strengthIcon }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ justCopied ? 'mdi-check' : 'mdi-content-copy' }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ tempIcon }}
+
+
+
+
+
+
+ mdi-temperature-celsius
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-check-circle
+
+
+
+
+
+
+
+
+ mdi-close-circle
+
+
+
+
+
+
+ mdi-check-circle
+
+
+
+
+
+
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,