Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/vue-vuetify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,34 @@ 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).

## 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.

If you want to:

- Replace the **`DefaultControlWrapper`** with your own implementation, or
- Provide custom renderers that render their child controls differently,

you can use Vue’s **`provide` / `inject` mechanism** to supply your own wrapper under the **`ControlWrapperSymbol`**.

For example, the demo application includes a custom wrapper that can be enabled from the **Example App Settings**. It is registered like this:

```ts
import { provide, type DefineComponent } from 'vue';
import {
ControlWrapperSymbol,
type ControlWrapperProps,
} from '@jsonforms/vue-vuetify';

import ControlWrapper from './components/ControlWrapper.vue';

provide(
ControlWrapperSymbol,
ControlWrapper as DefineComponent<ControlWrapperProps>,
);
```

## License

The JSONForms project is licensed under the MIT License. See the [LICENSE file](https://github.com/eclipsesource/jsonforms/blob/master/LICENSE) for more information.
10 changes: 9 additions & 1 deletion packages/vue-vuetify/dev/App.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, provide, type DefineComponent } from 'vue';
import ControlWrapper from './components/ControlWrapper.vue';
import ExampleAppBar from './components/ExampleAppBar.vue';
import ExampleDrawer from './components/ExampleDrawer.vue';
import ExampleSettings from './components/ExampleSettings.vue';

import ExampleView from './views/ExampleView.vue';
import HomeView from './views/HomeView.vue';

import { ControlWrapperSymbol, type ControlWrapperProps } from '@/util';
import examples from './examples';
import { getCustomThemes } from './plugins/vuetify';
import { useAppStore } from './store';
Expand All @@ -27,6 +29,12 @@ const theme = computed(() => {

return appStore.dark ? 'dark' : 'light';
});

// override the default ControlWrapper
provide(
ControlWrapperSymbol,
ControlWrapper as DefineComponent<ControlWrapperProps>,
);
</script>

<template>
Expand Down
68 changes: 68 additions & 0 deletions packages/vue-vuetify/dev/components/ControlWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<template>
<div
class="control-wrapper"
v-if="appStore.overrideControlTemplate && visible"
:class="[styles?.control.root, { 'focused-wrapper': isFocused }]"
:id="id"
>
<label :for="id">{{ label }} {{ required ? '(required)' : '' }}</label>
<template v-for="vnode in processedSlot">
<component :is="vnode" />
</template>
</div>

<default-control-wrapper v-else v-bind="props">
<slot></slot>
</default-control-wrapper>
</template>

<script setup lang="ts">
import DefaultControlWrapper from '@/controls/components/DefaultControlWrapper.vue';
import type { ControlWrapperProps } from '@/util';
import { cloneVNode, computed, defineProps, useSlots } from 'vue';
import { useAppStore } from '../store';
const appStore = useAppStore();

const props = defineProps<ControlWrapperProps>();
const slots = useSlots();

/**
* Recursively clones a VNode and removes 'label' prop from Vuetify input components.
*/
function stripLabel(vnode: any) {
if (!vnode) return vnode;

const hasLabel = vnode.props && 'label' in vnode.props;
if (hasLabel) {
vnode = cloneVNode(vnode, { label: undefined });
}

if (vnode.children && Array.isArray(vnode.children)) {
vnode.children = vnode.children.map(stripLabel);
}

return vnode;
}

const processedSlot = computed(() => {
if (!slots.default) return [];
return slots.default().map(stripLabel);
});
</script>

<style scoped>
.control-wrapper {
position: relative;
padding: 8px;
transition:
background-color 0.2s ease,
box-shadow 0.2s ease;
border-radius: 6px;
}

/* Subtle focus effect */
.focused-wrapper {
background-color: rgba(25, 118, 210, 0.05); /* soft glow */
box-shadow: 0 0 8px rgba(25, 118, 210, 0.3);
}
</style>
18 changes: 18 additions & 0 deletions packages/vue-vuetify/dev/components/ExampleSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,24 @@ const layouts = appstoreLayouts.map((value: AppstoreLayouts) => ({
</v-row>
</v-container>

<v-divider />
<v-container>
<v-row>
<v-col>
<v-tooltip bottom>
<template v-slot:activator="{ props }">
<v-switch
v-model="appStore.overrideControlTemplate"
label="Use custom ControlWrapper"
v-bind="props"
></v-switch>
</template>
This shows how ControlWrapper can be overriden, uses Example app
custom ControlWrapper. Visible when control is on focus.
</v-tooltip>
</v-col>
</v-row>
</v-container>
<v-divider />

<v-container>
Expand Down
1 change: 1 addition & 0 deletions packages/vue-vuetify/dev/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const appstore = reactive({
variant: useLocalStorage('vuetify-example-variant', ''),
iconset: useLocalStorage('vuetify-example-iconset', 'mdi'),
blueprint: useLocalStorage('vuetify-example-blueprint', 'md1'),
overrideControlTemplate: false,
jsonforms: {
readonly: useHistoryHashQuery('read-only', false as boolean),
validationMode: 'ValidateAndShow' as ValidationMode,
Expand Down
23 changes: 20 additions & 3 deletions packages/vue-vuetify/src/additional/ListWithDetailRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ import {
type RendererProps,
} from '@jsonforms/vue';
import type { ErrorObject } from 'ajv';
import { defineComponent, ref } from 'vue';
import { computed, defineComponent, ref } from 'vue';
import {
VAvatar,
VBtn,
Expand Down Expand Up @@ -248,11 +248,28 @@ const controlRenderer = defineComponent({
...rendererProps<ControlElement>(),
},
setup(props: RendererProps<ControlElement>) {
const selectedIndex = ref<number | undefined>(undefined);
const input = useVuetifyArrayControl(useJsonFormsArrayControl(props));

const _selectedIndex = ref<number | undefined>(undefined);
const selectedIndex = computed<number | undefined>({
get: () => {
const len = input.control.value?.data?.length ?? 0;

// If no index or out of bounds → undefined
if (_selectedIndex.value === undefined || _selectedIndex.value >= len) {
return undefined;
}

return _selectedIndex.value;
},
set: (val) => {
_selectedIndex.value = val;
},
});
const icons = useIcons();

return {
...useVuetifyArrayControl(useJsonFormsArrayControl(props)),
...input,
selectedIndex,
icons,
};
Expand Down
53 changes: 35 additions & 18 deletions packages/vue-vuetify/src/controls/ControlWrapper.vue
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
<template>
<div v-if="visible" :class="styles.control.root" :id="id">
<slot></slot>
</div>
<component :is="WrapperComponent" v-bind="props">
<slot />
</component>
</template>

<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { Styles } from '../styles';
import type { Styles } from '@/styles';
import type {
AppliedOptions,
ControlWrapperProps,
ControlWrapperType,
} from '@/util';
import { ControlWrapperSymbol } from '@/util';
import { defineComponent, inject, type PropType } from 'vue';
import DefaultControlWrapper from './components/DefaultControlWrapper.vue';
export default defineComponent({
name: 'control-wrapper',
name: 'ControlWrapper',
props: {
id: {
required: true as const,
type: String,
},
visible: {
required: false as const,
type: Boolean,
default: true,
},
styles: {
required: true,
type: Object as PropType<Styles>,
id: { type: String },
description: { type: String },
errors: { type: String },
label: { type: String },
visible: { type: Boolean },
required: { type: Boolean },
isFocused: { type: Boolean },
styles: { type: Object as PropType<Styles> },
appliedOptions: {
type: Object as PropType<AppliedOptions>,
},
},
setup(props: ControlWrapperProps) {
// Inject a custom wrapper if provided
const WrapperComponent = inject<ControlWrapperType>(
ControlWrapperSymbol,
DefaultControlWrapper,
) as ControlWrapperType;
return {
WrapperComponent,
props,
};
},
});
</script>
12 changes: 11 additions & 1 deletion packages/vue-vuetify/src/controls/IntegerControlRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
:persistent-hint="persistentHint()"
:required="control.required"
:error-messages="control.errors"
:model-value="control.data"
:model-value="value"
:clearable="control.enabled"
v-bind="vuetifyProps('v-text-field')"
@update:model-value="onChange"
Expand Down Expand Up @@ -65,6 +65,16 @@ const controlRenderer = defineComponent({
const options: any = this.appliedOptions;
return options.step ?? 1;
},
value(): number | null | undefined {
if (
typeof this.control.data === 'number' ||
this.control.data == null ||
this.control.data === undefined
) {
return this.control.data;
}
return Number(this.control.data);
},
},
});

Expand Down
12 changes: 11 additions & 1 deletion packages/vue-vuetify/src/controls/NumberControlRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
:persistent-hint="persistentHint()"
:required="control.required"
:error-messages="control.errors"
:model-value="control.data"
:model-value="value"
:clearable="control.enabled"
v-bind="vuetifyProps('v-number-input')"
@update:model-value="onChange"
Expand Down Expand Up @@ -78,6 +78,16 @@ const controlRenderer = defineComponent({
const fraction = stepStr.split('.')[1];
return fraction ? fraction.length : undefined;
},
value(): number | null | undefined {
if (
typeof this.control.data === 'number' ||
this.control.data == null ||
this.control.data === undefined
) {
return this.control.data;
}
return Number(this.control.data);
},
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<div v-if="visible" :class="styles?.control.root" :id="id">
<slot></slot>
</div>
</template>

<script lang="ts">
import type { Styles } from '@/styles';
import type { AppliedOptions, ControlWrapperProps } from '@/util';
import { defineComponent, type DefineComponent, type PropType } from 'vue';

export default defineComponent({
name: 'default-control-wrapper',
props: {
id: { type: String },
description: { type: String },
errors: { type: String },
label: { type: String },
visible: { type: Boolean },
required: { type: Boolean },
isFocused: { type: Boolean },
styles: { type: Object as PropType<Styles> },
appliedOptions: {
type: Object as PropType<AppliedOptions>,
},
},
}) as DefineComponent<ControlWrapperProps>;
</script>
1 change: 1 addition & 0 deletions packages/vue-vuetify/src/controls/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as DefaultControlWrapper } from './DefaultControlWrapper.vue';
export { default as ValidationBadge } from './ValidationBadge.vue';
export { default as ValidationIcon } from './ValidationIcon.vue';
22 changes: 21 additions & 1 deletion packages/vue-vuetify/src/util/inject.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import type { InjectionKey } from 'vue';
import type { DefineComponent, InjectionKey } from 'vue';
import type { Styles } from '../styles';
import type { useControlAppliedOptions } from './composition';

export const IsDynamicPropertyContext: InjectionKey<boolean> = Symbol.for(
'jsonforms-vue-vuetify:IsDynamicPropertyContext',
);

export type AppliedOptions = ReturnType<typeof useControlAppliedOptions>;
export interface ControlWrapperProps {
id?: string;
description?: string;
errors?: string;
label?: string;
visible?: boolean;
required?: boolean;
isFocused?: boolean;
styles?: Styles;
appliedOptions?: AppliedOptions;
}

export type ControlWrapperType = DefineComponent<ControlWrapperProps>;

export const ControlWrapperSymbol: InjectionKey<ControlWrapperType> =
Symbol.for('jsonforms-vue-vuetify:ControlWrapper');
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
exports[`BooleanControlRenderer.vue > should render component and match snapshot 1`] = `
"<div class="v-application v-theme--light v-layout v-layout--full-height v-locale--is-ltr">
<div class="v-application__wrap">
<div class="control" id="#6" errors="" label="My Boolean" required="false" isfocused="false" appliedoptions="[object Object]">
<div class="control" id="#6">
<div class="v-input v-input--horizontal v-input--center-affix v-input--density-default v-theme--light v-locale--is-ltr v-input--dirty v-checkbox input">
<!---->
<div class="v-input__control">
Expand Down
Loading
Loading