Skip to content

Commit b1c72e8

Browse files
kchobantonovsdirix
andauthored
vue-vuetify: Enable overriding control-wrapper with custom component (#2482)
* allow control-wrapper to be overridden by provided component * fix list-with-detail example and use computed field to make sure that the selectedIndex is in range. * Improve handling of invalid values in number and integer controls * enhance example to enable toggling between the default and a custom control wrapper. * remove not needed method from StringMaskControlRenderer.vue --------- Co-authored-by: Stefan Dirix <sdirix@eclipsesource.com>
1 parent 18060e1 commit b1c72e8

23 files changed

+276
-66
lines changed

packages/vue-vuetify/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,34 @@ 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+
## Override the ControlWrapper component
130+
131+
All control renderers wrap their components with a **`ControlWrapper`** component, which by default uses **`DefaultControlWrapper`** to render the wrapper element around each control.
132+
133+
If you want to:
134+
135+
- Replace the **`DefaultControlWrapper`** with your own implementation, or
136+
- Provide custom renderers that render their child controls differently,
137+
138+
you can use Vue’s **`provide` / `inject` mechanism** to supply your own wrapper under the **`ControlWrapperSymbol`**.
139+
140+
For example, the demo application includes a custom wrapper that can be enabled from the **Example App Settings**. It is registered like this:
141+
142+
```ts
143+
import { provide, type DefineComponent } from 'vue';
144+
import {
145+
ControlWrapperSymbol,
146+
type ControlWrapperProps,
147+
} from '@jsonforms/vue-vuetify';
148+
149+
import ControlWrapper from './components/ControlWrapper.vue';
150+
151+
provide(
152+
ControlWrapperSymbol,
153+
ControlWrapper as DefineComponent<ControlWrapperProps>,
154+
);
155+
```
156+
129157
## License
130158

131159
The JSONForms project is licensed under the MIT License. See the [LICENSE file](https://github.com/eclipsesource/jsonforms/blob/master/LICENSE) for more information.

packages/vue-vuetify/dev/App.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
<script setup lang="ts">
2-
import { computed } from 'vue';
2+
import { computed, provide, type DefineComponent } from 'vue';
3+
import ControlWrapper from './components/ControlWrapper.vue';
34
import ExampleAppBar from './components/ExampleAppBar.vue';
45
import ExampleDrawer from './components/ExampleDrawer.vue';
56
import ExampleSettings from './components/ExampleSettings.vue';
67
78
import ExampleView from './views/ExampleView.vue';
89
import HomeView from './views/HomeView.vue';
910
11+
import { ControlWrapperSymbol, type ControlWrapperProps } from '@/util';
1012
import examples from './examples';
1113
import { getCustomThemes } from './plugins/vuetify';
1214
import { useAppStore } from './store';
@@ -27,6 +29,12 @@ const theme = computed(() => {
2729
2830
return appStore.dark ? 'dark' : 'light';
2931
});
32+
33+
// override the default ControlWrapper
34+
provide(
35+
ControlWrapperSymbol,
36+
ControlWrapper as DefineComponent<ControlWrapperProps>,
37+
);
3038
</script>
3139

3240
<template>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<template>
2+
<div
3+
class="control-wrapper"
4+
v-if="appStore.overrideControlTemplate && visible"
5+
:class="[styles?.control.root, { 'focused-wrapper': isFocused }]"
6+
:id="id"
7+
>
8+
<label :for="id">{{ label }} {{ required ? '(required)' : '' }}</label>
9+
<template v-for="vnode in processedSlot">
10+
<component :is="vnode" />
11+
</template>
12+
</div>
13+
14+
<default-control-wrapper v-else v-bind="props">
15+
<slot></slot>
16+
</default-control-wrapper>
17+
</template>
18+
19+
<script setup lang="ts">
20+
import DefaultControlWrapper from '@/controls/components/DefaultControlWrapper.vue';
21+
import type { ControlWrapperProps } from '@/util';
22+
import { cloneVNode, computed, defineProps, useSlots } from 'vue';
23+
import { useAppStore } from '../store';
24+
const appStore = useAppStore();
25+
26+
const props = defineProps<ControlWrapperProps>();
27+
const slots = useSlots();
28+
29+
/**
30+
* Recursively clones a VNode and removes 'label' prop from Vuetify input components.
31+
*/
32+
function stripLabel(vnode: any) {
33+
if (!vnode) return vnode;
34+
35+
const hasLabel = vnode.props && 'label' in vnode.props;
36+
if (hasLabel) {
37+
vnode = cloneVNode(vnode, { label: undefined });
38+
}
39+
40+
if (vnode.children && Array.isArray(vnode.children)) {
41+
vnode.children = vnode.children.map(stripLabel);
42+
}
43+
44+
return vnode;
45+
}
46+
47+
const processedSlot = computed(() => {
48+
if (!slots.default) return [];
49+
return slots.default().map(stripLabel);
50+
});
51+
</script>
52+
53+
<style scoped>
54+
.control-wrapper {
55+
position: relative;
56+
padding: 8px;
57+
transition:
58+
background-color 0.2s ease,
59+
box-shadow 0.2s ease;
60+
border-radius: 6px;
61+
}
62+
63+
/* Subtle focus effect */
64+
.focused-wrapper {
65+
background-color: rgba(25, 118, 210, 0.05); /* soft glow */
66+
box-shadow: 0 0 8px rgba(25, 118, 210, 0.3);
67+
}
68+
</style>

packages/vue-vuetify/dev/components/ExampleSettings.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,24 @@ const layouts = appstoreLayouts.map((value: AppstoreLayouts) => ({
258258
</v-row>
259259
</v-container>
260260

261+
<v-divider />
262+
<v-container>
263+
<v-row>
264+
<v-col>
265+
<v-tooltip bottom>
266+
<template v-slot:activator="{ props }">
267+
<v-switch
268+
v-model="appStore.overrideControlTemplate"
269+
label="Use custom ControlWrapper"
270+
v-bind="props"
271+
></v-switch>
272+
</template>
273+
This shows how ControlWrapper can be overriden, uses Example app
274+
custom ControlWrapper. Visible when control is on focus.
275+
</v-tooltip>
276+
</v-col>
277+
</v-row>
278+
</v-container>
261279
<v-divider />
262280

263281
<v-container>

packages/vue-vuetify/dev/store/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const appstore = reactive({
1717
variant: useLocalStorage('vuetify-example-variant', ''),
1818
iconset: useLocalStorage('vuetify-example-iconset', 'mdi'),
1919
blueprint: useLocalStorage('vuetify-example-blueprint', 'md1'),
20+
overrideControlTemplate: false,
2021
jsonforms: {
2122
readonly: useHistoryHashQuery('read-only', false as boolean),
2223
validationMode: 'ValidateAndShow' as ValidationMode,

packages/vue-vuetify/src/additional/ListWithDetailRenderer.vue

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ import {
203203
type RendererProps,
204204
} from '@jsonforms/vue';
205205
import type { ErrorObject } from 'ajv';
206-
import { defineComponent, ref } from 'vue';
206+
import { computed, defineComponent, ref } from 'vue';
207207
import {
208208
VAvatar,
209209
VBtn,
@@ -248,11 +248,28 @@ const controlRenderer = defineComponent({
248248
...rendererProps<ControlElement>(),
249249
},
250250
setup(props: RendererProps<ControlElement>) {
251-
const selectedIndex = ref<number | undefined>(undefined);
251+
const input = useVuetifyArrayControl(useJsonFormsArrayControl(props));
252+
253+
const _selectedIndex = ref<number | undefined>(undefined);
254+
const selectedIndex = computed<number | undefined>({
255+
get: () => {
256+
const len = input.control.value?.data?.length ?? 0;
257+
258+
// If no index or out of bounds → undefined
259+
if (_selectedIndex.value === undefined || _selectedIndex.value >= len) {
260+
return undefined;
261+
}
262+
263+
return _selectedIndex.value;
264+
},
265+
set: (val) => {
266+
_selectedIndex.value = val;
267+
},
268+
});
252269
const icons = useIcons();
253270
254271
return {
255-
...useVuetifyArrayControl(useJsonFormsArrayControl(props)),
272+
...input,
256273
selectedIndex,
257274
icons,
258275
};
Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,46 @@
11
<template>
2-
<div v-if="visible" :class="styles.control.root" :id="id">
3-
<slot></slot>
4-
</div>
2+
<component :is="WrapperComponent" v-bind="props">
3+
<slot />
4+
</component>
55
</template>
66

77
<script lang="ts">
8-
import { defineComponent, type PropType } from 'vue';
9-
import type { Styles } from '../styles';
8+
import type { Styles } from '@/styles';
9+
import type {
10+
AppliedOptions,
11+
ControlWrapperProps,
12+
ControlWrapperType,
13+
} from '@/util';
14+
import { ControlWrapperSymbol } from '@/util';
15+
import { defineComponent, inject, type PropType } from 'vue';
16+
import DefaultControlWrapper from './components/DefaultControlWrapper.vue';
1017
1118
export default defineComponent({
1219
name: 'control-wrapper',
1320
props: {
14-
id: {
15-
required: true as const,
16-
type: String,
17-
},
18-
visible: {
19-
required: false as const,
20-
type: Boolean,
21-
default: true,
22-
},
23-
styles: {
24-
required: true,
25-
type: Object as PropType<Styles>,
21+
id: { type: String },
22+
description: { type: String },
23+
errors: { type: String },
24+
label: { type: String },
25+
visible: { type: Boolean },
26+
required: { type: Boolean },
27+
isFocused: { type: Boolean },
28+
styles: { type: Object as PropType<Styles> },
29+
appliedOptions: {
30+
type: Object as PropType<AppliedOptions>,
2631
},
2732
},
33+
setup(props: ControlWrapperProps) {
34+
// Inject a custom wrapper if provided
35+
const WrapperComponent = inject<ControlWrapperType>(
36+
ControlWrapperSymbol,
37+
DefaultControlWrapper,
38+
) as ControlWrapperType;
39+
40+
return {
41+
WrapperComponent,
42+
props,
43+
};
44+
},
2845
});
2946
</script>

packages/vue-vuetify/src/controls/IntegerControlRenderer.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
:persistent-hint="persistentHint()"
1919
:required="control.required"
2020
:error-messages="control.errors"
21-
:model-value="control.data"
21+
:model-value="value"
2222
:clearable="control.enabled"
2323
v-bind="vuetifyProps('v-text-field')"
2424
@update:model-value="onChange"
@@ -65,6 +65,16 @@ const controlRenderer = defineComponent({
6565
const options: any = this.appliedOptions;
6666
return options.step ?? 1;
6767
},
68+
value(): number | null | undefined {
69+
if (
70+
typeof this.control.data === 'number' ||
71+
this.control.data === null ||
72+
this.control.data === undefined
73+
) {
74+
return this.control.data;
75+
}
76+
return Number(this.control.data);
77+
},
6878
},
6979
});
7080

packages/vue-vuetify/src/controls/NumberControlRenderer.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
:persistent-hint="persistentHint()"
2020
:required="control.required"
2121
:error-messages="control.errors"
22-
:model-value="control.data"
22+
:model-value="value"
2323
:clearable="control.enabled"
2424
v-bind="vuetifyProps('v-number-input')"
2525
@update:model-value="onChange"
@@ -78,6 +78,16 @@ const controlRenderer = defineComponent({
7878
const fraction = stepStr.split('.')[1];
7979
return fraction ? fraction.length : undefined;
8080
},
81+
value(): number | null | undefined {
82+
if (
83+
typeof this.control.data === 'number' ||
84+
this.control.data === null ||
85+
this.control.data === undefined
86+
) {
87+
return this.control.data;
88+
}
89+
return Number(this.control.data);
90+
},
8191
},
8292
});
8393

packages/vue-vuetify/src/controls/StringMaskControlRenderer.vue

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,19 @@
3737
</template>
3838

3939
<script lang="ts">
40-
import {
41-
type ControlElement,
42-
type Tester,
43-
type UISchemaElement,
44-
} from '@jsonforms/core';
40+
import { type ControlElement } from '@jsonforms/core';
4541
import {
4642
rendererProps,
4743
type RendererProps,
4844
useJsonFormsControl,
4945
} from '@jsonforms/vue';
50-
import isEmpty from 'lodash/isEmpty';
51-
import { defineComponent, computed } from 'vue';
46+
import cloneDeep from 'lodash/cloneDeep';
47+
import { Mask, type MaskTokens, vMaska } from 'maska';
48+
import { computed, defineComponent } from 'vue';
5249
import { VTextField } from 'vuetify/components';
5350
import { determineClearValue, useVuetifyControl } from '../util';
5451
import { default as ControlWrapper } from './ControlWrapper.vue';
5552
import { DisabledIconFocus } from './directives';
56-
import { type MaskTokens, vMaska, Mask } from 'maska';
57-
import cloneDeep from 'lodash/cloneDeep';
5853
5954
const defaultTokens: MaskTokens = {
6055
'#': { pattern: /[0-9]/ },
@@ -187,20 +182,4 @@ const controlRenderer = defineComponent({
187182
});
188183
189184
export default controlRenderer;
190-
191-
const hasOption =
192-
(optionName: string): Tester =>
193-
(uischema: UISchemaElement): boolean => {
194-
if (isEmpty(uischema)) {
195-
return false;
196-
}
197-
198-
const options = uischema.options;
199-
return (
200-
(options &&
201-
!isEmpty(options) &&
202-
typeof options[optionName] === 'string') ||
203-
false
204-
);
205-
};
206185
</script>

0 commit comments

Comments
 (0)