Skip to content

Commit 04c6006

Browse files
authored
vue-vuetify: add prepend and append slots to control renderers (#2504)
- add prepend and append slots to control renderers - add slot usage examples with custom renderers Further improvement: refactor centralize clearable logic in useVuetifyControl
1 parent 9fabff8 commit 04c6006

28 files changed

+732
-27
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
The MIT License
3+
4+
Copyright (c) 2017-2025 EclipseSource Munich
5+
https://github.com/eclipsesource/jsonforms
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in
15+
all copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23+
THE SOFTWARE.
24+
*/
25+
import { registerExamples } from '../register';
26+
27+
/**
28+
* Prepend and Append Slots
29+
*
30+
* Prepend Only:
31+
* - Display Name: Static decorative icon
32+
* - Password: Dynamic strength meter
33+
*
34+
* Append Only:
35+
* - Username: Async availability checker
36+
* - Email: Copy to clipboard button
37+
*
38+
* Both:
39+
* - Temperature: Dynamic icon + unit indicator
40+
*/
41+
42+
export const schema = {
43+
type: 'object',
44+
properties: {
45+
// Prepend slot only
46+
displayName: {
47+
type: 'string',
48+
description: 'Decorative icon',
49+
},
50+
password: {
51+
type: 'string',
52+
description: 'Dynamic strength meter',
53+
},
54+
55+
// Append slot only
56+
username: {
57+
type: 'string',
58+
maxLength: 20,
59+
description: 'Availability checker (try "admin" or "user")',
60+
},
61+
email: {
62+
type: 'string',
63+
format: 'email',
64+
description: 'Copy to clipboard',
65+
},
66+
67+
// Both prepend AND append slots
68+
temperature: {
69+
type: 'integer',
70+
minimum: -50,
71+
maximum: 50,
72+
description: 'Temperature with dynamic icon and unit (°C)',
73+
},
74+
},
75+
};
76+
77+
export const uischema = {
78+
type: 'VerticalLayout',
79+
elements: [
80+
{
81+
type: 'Label',
82+
text: 'Prepend Slot Only',
83+
},
84+
{
85+
type: 'Control',
86+
scope: '#/properties/displayName',
87+
options: {
88+
clearable: false,
89+
},
90+
},
91+
{
92+
type: 'Control',
93+
scope: '#/properties/password',
94+
options: {
95+
clearable: false,
96+
},
97+
},
98+
99+
{
100+
type: 'Label',
101+
text: 'Append Slot Only',
102+
},
103+
{
104+
type: 'Control',
105+
scope: '#/properties/username',
106+
options: {
107+
clearable: false,
108+
},
109+
},
110+
{
111+
type: 'Control',
112+
scope: '#/properties/email',
113+
options: {
114+
clearable: false,
115+
},
116+
},
117+
118+
{
119+
type: 'Label',
120+
text: 'Both Prepend and Append',
121+
},
122+
{
123+
type: 'Control',
124+
scope: '#/properties/temperature',
125+
options: {
126+
clearable: false,
127+
},
128+
},
129+
],
130+
};
131+
132+
export const data = {
133+
displayName: 'John Doe',
134+
password: '',
135+
username: '', // Start empty so checker can be tested
136+
email: 'user@example.com',
137+
temperature: 22,
138+
};
139+
140+
registerExamples([
141+
{
142+
name: 'prepend-append-slots',
143+
label: 'Prepend/Append Slots (Basic)',
144+
data,
145+
schema,
146+
uischema,
147+
},
148+
]);

packages/examples/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import * as login from './examples/login';
7878
import * as mixed from './examples/mixed';
7979
import * as mixedObject from './examples/mixed-object';
8080
import * as string from './examples/string';
81+
import * as prependAppendSlots from './examples/prepend-append-slots';
8182
export * from './register';
8283
export * from './example';
8384

@@ -145,4 +146,5 @@ export {
145146
issue_1884,
146147
arrayWithDefaults,
147148
string,
149+
prependAppendSlots,
148150
};

packages/vue-vuetify/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,26 @@ If note done yet, please [install Vuetify for Vue](https://vuetifyjs.com/en/gett
126126

127127
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).
128128

129+
## Customization
130+
131+
### Prepend and Append Slots
132+
133+
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.
134+
135+
**Example:**
136+
137+
```vue
138+
<template>
139+
<string-control-renderer v-bind="$props">
140+
<template #prepend>
141+
<v-icon>mdi-help-circle</v-icon>
142+
</template>
143+
</string-control-renderer>
144+
</template>
145+
```
146+
147+
See the "Prepend/Append Slots (Basic)" example in the dev app.
148+
129149
## Override the ControlWrapper component
130150

131151
All control renderers wrap their components with a **`ControlWrapper`** component, which by default uses **`DefaultControlWrapper`** to render the wrapper element around each control.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<template>
2+
<string-control-renderer v-bind="$props">
3+
<template #prepend>
4+
<v-icon
5+
color="primary"
6+
class="mr-1"
7+
aria-hidden="true"
8+
tabindex="-1"
9+
>
10+
mdi-badge-account-horizontal-outline
11+
</v-icon>
12+
</template>
13+
</string-control-renderer>
14+
</template>
15+
16+
<script setup lang="ts">
17+
import { rendererProps } from '@jsonforms/vue';
18+
import type { ControlElement } from '@jsonforms/core';
19+
import StringControlRenderer from '../../src/controls/StringControlRenderer.vue';
20+
import { VIcon } from 'vuetify/components';
21+
22+
defineProps(rendererProps<ControlElement>());
23+
</script>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<!-- Password strength indicator in prepend slot -->
2+
<template>
3+
<password-control-renderer v-bind="$props">
4+
<!-- PREPEND: Password Strength Indicator -->
5+
<template #prepend>
6+
<v-tooltip :text="strengthTooltip" location="bottom">
7+
<template #activator="{ props: tooltipProps }">
8+
<v-icon
9+
v-bind="tooltipProps"
10+
:color="strengthColor"
11+
class="mr-1"
12+
aria-hidden="true"
13+
tabindex="-1"
14+
>
15+
{{ strengthIcon }}
16+
</v-icon>
17+
</template>
18+
</v-tooltip>
19+
</template>
20+
<!-- Note: append slot used by PasswordControlRenderer for show/hide toggle -->
21+
</password-control-renderer>
22+
</template>
23+
24+
<script setup lang="ts">
25+
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue';
26+
import type { ControlElement } from '@jsonforms/core';
27+
import PasswordControlRenderer from '../../src/controls/PasswordControlRenderer.vue';
28+
import { VIcon, VTooltip } from 'vuetify/components';
29+
import { computed } from 'vue';
30+
31+
const props = defineProps(rendererProps<ControlElement>());
32+
33+
// Use JSONForms composable to access control state
34+
const { control } = useJsonFormsControl(props);
35+
36+
// PREPEND: Calculate "strength" based on input complexity
37+
// (In real app: password strength, username uniqueness check, etc.)
38+
const strength = computed(() => {
39+
const value = control.value.data || '';
40+
const length = value.length;
41+
42+
if (length === 0) return 0;
43+
if (length < 5) return 1;
44+
if (length < 10) return 2;
45+
46+
// Bonus for complexity
47+
const hasUpper = /[A-Z]/.test(value);
48+
const hasNumber = /[0-9]/.test(value);
49+
const hasSpecial = /[^A-Za-z0-9]/.test(value);
50+
const complexity = [hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
51+
52+
if (complexity >= 2) return 4;
53+
if (complexity >= 1) return 3;
54+
return 2;
55+
});
56+
57+
const strengthColor = computed(() => {
58+
const colors = ['grey-lighten-1', 'error', 'warning', 'info', 'success'];
59+
return colors[strength.value];
60+
});
61+
62+
const strengthIcon = computed(() => {
63+
const icons = [
64+
'mdi-shield-outline', // Empty - grey outline
65+
'mdi-shield-alert', // Weak - red shield with alert
66+
'mdi-shield-half-full', // Fair - orange half shield
67+
'mdi-shield-check', // Good - blue shield with check
68+
'mdi-shield-star', // Strong - green shield with star
69+
];
70+
return icons[strength.value];
71+
});
72+
73+
const strengthTooltip = computed(() => {
74+
const labels = ['Empty', 'Weak', 'Fair', 'Good', 'Strong'];
75+
const tips = [
76+
'Start typing to see complexity indicator',
77+
'Weak: Too short (< 5 chars)',
78+
'Fair: Medium length (5-10 chars)',
79+
'Good: Good length with some complexity',
80+
'Strong: Long with uppercase, numbers, or special chars',
81+
];
82+
return `${labels[strength.value]}: ${tips[strength.value]}`;
83+
});
84+
</script>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!-- Copy to clipboard button in append slot -->
2+
<template>
3+
<string-control-renderer v-bind="$props">
4+
<template #append>
5+
<!-- Replace the default clear button with our custom copy icon -->
6+
<v-tooltip :text="tooltipText" location="top">
7+
<template #activator="{ props: tooltipProps }">
8+
<v-icon
9+
v-bind="tooltipProps"
10+
@click="copyToClipboard"
11+
style="cursor: pointer"
12+
:aria-label="`Copy ${control.label} to clipboard`"
13+
role="button"
14+
>
15+
{{ justCopied ? 'mdi-check' : 'mdi-content-copy' }}
16+
</v-icon>
17+
</template>
18+
</v-tooltip>
19+
</template>
20+
</string-control-renderer>
21+
</template>
22+
23+
<script setup lang="ts">
24+
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue';
25+
import type { ControlElement } from '@jsonforms/core';
26+
import StringControlRenderer from '../../src/controls/StringControlRenderer.vue';
27+
import { VIcon, VTooltip } from 'vuetify/components';
28+
import { ref, computed } from 'vue';
29+
30+
const props = defineProps(rendererProps<ControlElement>());
31+
32+
const { control } = useJsonFormsControl(props);
33+
34+
const justCopied = ref(false);
35+
36+
const tooltipText = computed(() =>
37+
justCopied.value ? 'Copied!' : 'Copy to clipboard',
38+
);
39+
40+
const copyToClipboard = async () => {
41+
if (control.value.data) {
42+
try {
43+
await navigator.clipboard.writeText(control.value.data);
44+
justCopied.value = true;
45+
46+
// Reset after 1 second
47+
setTimeout(() => {
48+
justCopied.value = false;
49+
}, 1000);
50+
} catch (err) {
51+
console.error('Failed to copy:', err);
52+
}
53+
}
54+
};
55+
</script>

0 commit comments

Comments
 (0)