Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
51 changes: 34 additions & 17 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',
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
29 changes: 4 additions & 25 deletions packages/vue-vuetify/src/controls/StringMaskControlRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,19 @@
</template>

<script lang="ts">
import {
type ControlElement,
type Tester,
type UISchemaElement,
} from '@jsonforms/core';
import { type ControlElement } from '@jsonforms/core';
import {
rendererProps,
type RendererProps,
useJsonFormsControl,
} from '@jsonforms/vue';
import isEmpty from 'lodash/isEmpty';
import { defineComponent, computed } from 'vue';
import cloneDeep from 'lodash/cloneDeep';
import { Mask, type MaskTokens, vMaska } from 'maska';
import { computed, defineComponent } from 'vue';
import { VTextField } from 'vuetify/components';
import { determineClearValue, useVuetifyControl } from '../util';
import { default as ControlWrapper } from './ControlWrapper.vue';
import { DisabledIconFocus } from './directives';
import { type MaskTokens, vMaska, Mask } from 'maska';
import cloneDeep from 'lodash/cloneDeep';

const defaultTokens: MaskTokens = {
'#': { pattern: /[0-9]/ },
Expand Down Expand Up @@ -187,20 +182,4 @@ const controlRenderer = defineComponent({
});

export default controlRenderer;

const hasOption =
(optionName: string): Tester =>
(uischema: UISchemaElement): boolean => {
if (isEmpty(uischema)) {
return false;
}

const options = uischema.options;
return (
(options &&
!isEmpty(options) &&
typeof options[optionName] === 'string') ||
false
);
};
</script>
Loading