diff --git a/packages/vue-vuetify/dev/views/ExampleView.vue b/packages/vue-vuetify/dev/views/ExampleView.vue
index 653993197b..17269eadca 100644
--- a/packages/vue-vuetify/dev/views/ExampleView.vue
+++ b/packages/vue-vuetify/dev/views/ExampleView.vue
@@ -29,8 +29,7 @@ import examples from '../examples';
import { useAppStore } from '../store';
import { createAjv } from '../validate';
-import { Pane, Splitpanes } from 'splitpanes';
-import 'splitpanes/dist/splitpanes.css';
+import { VPane, VSplitpanes } from '../../src/components';
import { getCustomRenderersForExample } from '../renderers';
const { extendedVuetifyRenderers } = await import('../../src');
@@ -332,12 +331,11 @@ const handleAction = (action: Action) => {
@@ -509,28 +507,3 @@ const handleAction = (action: Action) => {
-
diff --git a/packages/vue-vuetify/package.json b/packages/vue-vuetify/package.json
index 7c92412a2f..a26fc50f1a 100644
--- a/packages/vue-vuetify/package.json
+++ b/packages/vue-vuetify/package.json
@@ -68,6 +68,7 @@
"dayjs": "^1.10.6",
"lodash": "^4.17.21",
"maska": "^2.1.11",
+ "splitpanes": "^3.1.5",
"vue": "^3.5.0",
"vuetify": "^3.11.0"
},
diff --git a/packages/vue-vuetify/src/complex/MixedRenderer.vue b/packages/vue-vuetify/src/complex/MixedRenderer.vue
index 5f572a7f17..1626327d35 100644
--- a/packages/vue-vuetify/src/complex/MixedRenderer.vue
+++ b/packages/vue-vuetify/src/complex/MixedRenderer.vue
@@ -1,13 +1,13 @@
-
-
-
+
-
+
-
+ />
+
+
+
+ open ? startRename(element.propertyName) : cancelRename()
+ "
+ >
+
+
+ {{
+ icons.current.value.itemEdit
+ }}
+
+
+
+
{{
@@ -83,9 +156,9 @@
{{ translations.removeTooltip }}
-
-
+
+
+
@@ -93,11 +166,12 @@
+
+
diff --git a/packages/vue-vuetify/src/components/VPane.vue b/packages/vue-vuetify/src/components/VPane.vue
new file mode 100644
index 0000000000..780fae4d0e
--- /dev/null
+++ b/packages/vue-vuetify/src/components/VPane.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/packages/vue-vuetify/src/components/VSplitpanes.sass b/packages/vue-vuetify/src/components/VSplitpanes.sass
new file mode 100644
index 0000000000..d82932599a
--- /dev/null
+++ b/packages/vue-vuetify/src/components/VSplitpanes.sass
@@ -0,0 +1,218 @@
+@use 'sass:math'
+@use 'vuetify/tools'
+
+$splitter-thickness: 1px
+$splitter-hover-thickness: 4px
+$splitter-hit-area: 8px
+$splitter-transition: background-color 0.15s ease, width 0.15s ease, height 0.15s ease
+$pane-transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1)
+
+@mixin splitter-base
+ box-sizing: border-box
+ flex-shrink: 0
+ background-color: transparent
+ transition: none
+ position: relative
+
+@mixin splitter-line($width: $splitter-thickness, $height: 100%, $top: 0, $left: 0)
+ content: ''
+ position: absolute
+ top: $top
+ left: $left
+ width: $width
+ height: $height
+ background-color: rgba(var(--v-border-color), var(--v-border-opacity))
+ transition: $splitter-transition
+ z-index: 1
+
+@mixin splitter-hover-state($is-horizontal: false)
+ @if $is-horizontal
+ height: $splitter-hover-thickness
+ width: 100%
+ top: -1.5px
+ left: 0
+ @else
+ width: $splitter-hover-thickness
+ height: 100%
+ left: -1.5px
+ top: 0
+ background-color: rgb(var(--v-theme-primary))
+
+@mixin hit-area($is-horizontal: false)
+ content: ''
+ position: absolute
+ z-index: 0
+ background-color: transparent
+
+ @if $is-horizontal
+ top: -(math.div($splitter-hit-area, 2))
+ bottom: -(math.div($splitter-hit-area, 2))
+ left: 0
+ right: 0
+ cursor: row-resize
+ @else
+ left: -(math.div($splitter-hit-area, 2))
+ right: -(math.div($splitter-hit-area, 2))
+ top: 0
+ bottom: 0
+ cursor: col-resize
+
+@mixin pane-transition($property)
+ transition: none
+
+ .splitpanes:not(.splitpanes--dragging) &
+ transition: #{$property} $pane-transition
+
+@include tools.layer('components')
+ .splitpanes
+ display: flex
+ width: 100%
+ height: 100%
+
+ &--vertical
+ flex-direction: row
+
+ &--horizontal
+ flex-direction: column
+
+ &--dragging
+ -webkit-user-select: none
+ user-select: none
+ pointer-events: auto
+ transform: translateZ(0)
+ will-change: transform
+
+ *, *:before, *:after
+ transition: none !important
+
+ .splitpanes__splitter
+ pointer-events: auto
+
+ &[dir='rtl']
+ &.splitpanes--vertical
+ flex-direction: row-reverse
+
+ > .splitpanes__splitter
+ border-left: none
+ border-right: $splitter-thickness solid rgba(var(--v-border-color), var(--v-border-opacity))
+
+ .splitpanes__pane
+ width: 100%
+ height: 100%
+ overflow: hidden
+ transform: translateZ(0)
+ backface-visibility: hidden
+
+ .splitpanes--vertical &
+ @include pane-transition(width)
+
+ .splitpanes--horizontal &
+ @include pane-transition(height)
+
+ .splitpanes__splitter
+ .splitpanes--vertical > &
+ min-width: $splitter-thickness
+ cursor: col-resize
+
+ .splitpanes--horizontal > &
+ min-height: $splitter-thickness
+ cursor: row-resize
+
+ &:first-child
+ cursor: auto
+
+ &:focus-visible
+ outline: 2px solid rgb(var(--v-theme-primary))
+ outline-offset: -2px
+
+ .splitpanes.v-splitpanes
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))
+
+ .splitpanes__pane
+ background-color: rgb(var(--v-theme-surface))
+
+ .splitpanes__splitter
+ @include splitter-base
+ transform: translateZ(0)
+ will-change: auto
+
+ &:before
+ @include splitter-line
+
+ &:hover:before
+ @include splitter-hover-state
+
+ .splitpanes--dragging &:before
+ @include splitter-hover-state
+ transition: none !important
+
+ .splitpanes &
+ z-index: 1
+
+ &.splitpanes--vertical > .splitpanes__splitter:after
+ @include hit-area
+
+ &.splitpanes--horizontal > .splitpanes__splitter
+ &:before
+ @include splitter-line(100%, $splitter-thickness)
+
+ &:hover:before
+ @include splitter-hover-state(true)
+
+ .splitpanes--dragging &:before
+ @include splitter-hover-state(true)
+ transition: none !important
+
+ &:after
+ @include hit-area(true)
+
+ .splitpanes.v-splitpanes.splitpanes--dragging
+ .splitpanes__pane
+ transition: none
+
+ .splitpanes__splitter:hover:before,
+ .splitpanes__splitter:focus:before,
+ .splitpanes__splitter:active:before
+ @include splitter-hover-state
+ transition: none
+
+ .splitpanes__pane .splitpanes__splitter:before
+ background-color: rgba(var(--v-border-color), var(--v-border-opacity))
+ width: $splitter-thickness
+ height: 100%
+ top: 0
+ left: 0
+ transition: none
+
+ .splitpanes__pane .splitpanes__splitter:hover:before,
+ .splitpanes__pane .splitpanes__splitter:focus:before,
+ .splitpanes__pane .splitpanes__splitter:active:before
+ @include splitter-hover-state
+ transition: none
+
+ &.splitpanes--horizontal .splitpanes__splitter:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:before
+ background-color: rgba(var(--v-border-color), var(--v-border-opacity))
+ width: 100%
+ height: $splitter-thickness
+ top: 0
+ left: 0
+ transition: none
+
+ &.splitpanes--horizontal .splitpanes__splitter:hover:before,
+ &.splitpanes--horizontal .splitpanes__splitter:focus:before,
+ &.splitpanes--horizontal .splitpanes__splitter:active:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:hover:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:focus:before,
+ .splitpanes__pane .splitpanes--horizontal .splitpanes__splitter:active:before
+ @include splitter-hover-state(true)
+ transition: none
+
+ @media (prefers-contrast: high)
+ .splitpanes.v-splitpanes .splitpanes__splitter
+ opacity: 1 !important
+ background-color: rgba(var(--v-border-color), 0.87) !important
+
+ @media (prefers-reduced-motion: reduce)
+ .splitpanes__pane
+ transition: none
diff --git a/packages/vue-vuetify/src/components/VSplitpanes.vue b/packages/vue-vuetify/src/components/VSplitpanes.vue
new file mode 100644
index 0000000000..79192cf2a8
--- /dev/null
+++ b/packages/vue-vuetify/src/components/VSplitpanes.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/packages/vue-vuetify/src/components/index.ts b/packages/vue-vuetify/src/components/index.ts
new file mode 100644
index 0000000000..83c7dd2e8e
--- /dev/null
+++ b/packages/vue-vuetify/src/components/index.ts
@@ -0,0 +1,2 @@
+export { default as VPane } from './VPane.vue';
+export { default as VSplitpanes } from './VSplitpanes.vue';
diff --git a/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts b/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts
index 632415d9b7..6002efa424 100644
--- a/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts
+++ b/packages/vue-vuetify/src/i18n/additionalPropertiesTranslations.ts
@@ -10,6 +10,10 @@ export enum AdditionalPropertiesTranslationEnum {
addAriaLabel = 'addAriaLabel',
removeTooltip = 'removeTooltip',
removeAriaLabel = 'removeAriaLabel',
+ renameTooltip = 'renameTooltip',
+ renameAriaLabel = 'renameAriaLabel',
+ renameConfirm = 'renameConfirm',
+ cancel = 'cancel',
propertyNameLabel = 'propertyNameLabel',
propertyNameInvalid = 'propertyNameInvalid',
propertyAlreadyDefined = 'propertyAlreadyDefined',
@@ -39,6 +43,23 @@ export const additionalPropertiesDefaultTranslations: AdditionalPropertiesDefaul
key: AdditionalPropertiesTranslationEnum.removeAriaLabel,
default: () => 'Delete button',
},
+ {
+ key: AdditionalPropertiesTranslationEnum.renameTooltip,
+ default: () => 'Rename',
+ },
+ {
+ key: AdditionalPropertiesTranslationEnum.renameAriaLabel,
+ default: (input) =>
+ input ? `Rename property '${input}' button` : 'Rename property button',
+ },
+ {
+ key: AdditionalPropertiesTranslationEnum.renameConfirm,
+ default: () => 'Rename',
+ },
+ {
+ key: AdditionalPropertiesTranslationEnum.cancel,
+ default: () => 'Cancel',
+ },
{
key: AdditionalPropertiesTranslationEnum.propertyNameLabel,
default: () => 'Property Name',
diff --git a/packages/vue-vuetify/src/i18n/i18nUtil.ts b/packages/vue-vuetify/src/i18n/i18nUtil.ts
index 560c5c2255..4c4da9297c 100644
--- a/packages/vue-vuetify/src/i18n/i18nUtil.ts
+++ b/packages/vue-vuetify/src/i18n/i18nUtil.ts
@@ -5,6 +5,22 @@ import {
type AdditionalPropertiesTranslations,
} from './additionalPropertiesTranslations';
import { addI18nKeyToPrefix, type Translator } from '@jsonforms/core';
+import {
+ mixedRendererDefaultTranslations,
+ MixedRendererTranslationEnum,
+} from './mixedRendererTranslations';
+
+export const getAdditionalPropertyTranslation = (
+ t: Translator,
+ defaultTranslations: AdditionalPropertiesDefaultTranslation[],
+ i18nKeyPrefix: string,
+ key: AdditionalPropertiesTranslationEnum,
+ variable?: string | null,
+): string => {
+ const translation = defaultTranslations.find((entry) => entry.key === key);
+ const defaultMessage = translation?.default(variable) ?? key;
+ return t(addI18nKeyToPrefix(i18nKeyPrefix, key), defaultMessage, variable);
+};
export const getAdditionalPropertiesTranslations = (
t: Translator,
@@ -41,3 +57,50 @@ export const getAdditionalPropertiesTranslations = (
});
return translations;
};
+
+export interface MixedRendererTranslations {
+ searchLabel: string;
+ showPrimitives: string;
+ hidePrimitives: string;
+ renameTooltip: string;
+ renameAriaLabel: (propertyName: string) => string;
+ deleteTooltip: string;
+ deleteAriaLabel: (propertyName: string) => string;
+ viewTooltip: (label: string) => string;
+ viewAriaLabel: (label: string) => string;
+ itemLabel: (index: number) => string;
+}
+
+export const getMixedRendererTranslations = (
+ t: Translator,
+ i18nKeyPrefix: string,
+): MixedRendererTranslations => {
+ const translate = (
+ key: MixedRendererTranslationEnum,
+ variable?: string | number | null,
+ ): string => {
+ const translation = mixedRendererDefaultTranslations.find(
+ (entry) => entry.key === key,
+ );
+ const defaultMessage = translation?.default(variable) ?? key;
+ return t(addI18nKeyToPrefix(i18nKeyPrefix, key), defaultMessage, variable);
+ };
+
+ return {
+ searchLabel: translate(MixedRendererTranslationEnum.searchLabel),
+ showPrimitives: translate(MixedRendererTranslationEnum.showPrimitives),
+ hidePrimitives: translate(MixedRendererTranslationEnum.hidePrimitives),
+ renameTooltip: translate(MixedRendererTranslationEnum.renameTooltip),
+ renameAriaLabel: (propertyName) =>
+ translate(MixedRendererTranslationEnum.renameAriaLabel, propertyName),
+ deleteTooltip: translate(MixedRendererTranslationEnum.deleteTooltip),
+ deleteAriaLabel: (propertyName) =>
+ translate(MixedRendererTranslationEnum.deleteAriaLabel, propertyName),
+ viewTooltip: (label) =>
+ translate(MixedRendererTranslationEnum.viewTooltip, label),
+ viewAriaLabel: (label) =>
+ translate(MixedRendererTranslationEnum.viewAriaLabel, label),
+ itemLabel: (index) =>
+ translate(MixedRendererTranslationEnum.itemLabel, index),
+ };
+};
diff --git a/packages/vue-vuetify/src/i18n/index.ts b/packages/vue-vuetify/src/i18n/index.ts
index 42bda164bc..78b0632c34 100644
--- a/packages/vue-vuetify/src/i18n/index.ts
+++ b/packages/vue-vuetify/src/i18n/index.ts
@@ -1,2 +1,3 @@
export * from './additionalPropertiesTranslations';
export * from './i18nUtil';
+export * from './mixedRendererTranslations';
diff --git a/packages/vue-vuetify/src/i18n/mixedRendererTranslations.ts b/packages/vue-vuetify/src/i18n/mixedRendererTranslations.ts
new file mode 100644
index 0000000000..d0e3a3855a
--- /dev/null
+++ b/packages/vue-vuetify/src/i18n/mixedRendererTranslations.ts
@@ -0,0 +1,63 @@
+export interface MixedRendererDefaultTranslation {
+ key: MixedRendererTranslationEnum;
+ default: (variable?: string | number | null) => string;
+}
+
+export enum MixedRendererTranslationEnum {
+ searchLabel = 'searchLabel',
+ showPrimitives = 'showPrimitives',
+ hidePrimitives = 'hidePrimitives',
+ renameTooltip = 'renameTooltip',
+ renameAriaLabel = 'renameAriaLabel',
+ deleteTooltip = 'deleteTooltip',
+ deleteAriaLabel = 'deleteAriaLabel',
+ viewTooltip = 'viewTooltip',
+ viewAriaLabel = 'viewAriaLabel',
+ itemLabel = 'itemLabel',
+}
+
+export const mixedRendererDefaultTranslations: MixedRendererDefaultTranslation[] =
+ [
+ {
+ key: MixedRendererTranslationEnum.searchLabel,
+ default: () => 'Search',
+ },
+ {
+ key: MixedRendererTranslationEnum.showPrimitives,
+ default: () => 'Show primitives',
+ },
+ {
+ key: MixedRendererTranslationEnum.hidePrimitives,
+ default: () => 'Hide primitives',
+ },
+ {
+ key: MixedRendererTranslationEnum.renameTooltip,
+ default: () => 'Rename',
+ },
+ {
+ key: MixedRendererTranslationEnum.renameAriaLabel,
+ default: (input) =>
+ input ? `Rename property '${input}' button` : 'Rename property button',
+ },
+ {
+ key: MixedRendererTranslationEnum.deleteTooltip,
+ default: () => 'Delete',
+ },
+ {
+ key: MixedRendererTranslationEnum.deleteAriaLabel,
+ default: (input) =>
+ input ? `Delete property '${input}' button` : 'Delete property button',
+ },
+ {
+ key: MixedRendererTranslationEnum.viewTooltip,
+ default: (input) => (input ? `View ${input}` : 'View'),
+ },
+ {
+ key: MixedRendererTranslationEnum.viewAriaLabel,
+ default: (input) => (input ? `View ${input} button` : 'View button'),
+ },
+ {
+ key: MixedRendererTranslationEnum.itemLabel,
+ default: (input) => `Item ${input ?? ''}`.trim(),
+ },
+ ];
diff --git a/packages/vue-vuetify/src/icons/fa.ts b/packages/vue-vuetify/src/icons/fa.ts
index 4b914a7287..a9470c2297 100644
--- a/packages/vue-vuetify/src/icons/fa.ts
+++ b/packages/vue-vuetify/src/icons/fa.ts
@@ -1,14 +1,29 @@
import type { IconAliases } from './icons';
-const aliases: IconAliases = {
+const aliases: Required = {
itemAdd: 'fas fa-plus',
+ itemCancel: 'fas fa-xmark',
+ itemConfirm: 'fas fa-check',
+ itemEdit: 'far fa-pen-to-square',
itemMoveUp: 'fas fa-arrow-up',
itemMoveDown: 'fas fa-arrow-down',
itemDelete: 'fas fa-trash',
+ search: 'fas fa-magnifying-glass',
+ treeExpand: 'fas fa-chevron-right',
+ treeCollapse: 'fas fa-chevron-down',
+ visibilityOff: 'far fa-eye-slash',
+ visibilityOn: 'far fa-eye',
calendarClock: 'fas fa-calendar',
clock: 'far fa-clock',
passwordHide: 'far fa-eye-slash',
passwordShow: 'far fa-eye',
+ typeArray: 'fas fa-list',
+ typeBoolean: 'fas fa-toggle-on',
+ typeNull: 'fas fa-ban',
+ typeNumber: 'fas fa-hashtag',
+ typeObject: 'far fa-folder',
+ typeString: 'fas fa-font',
+ typeUnknown: 'fas fa-circle',
validationError: 'fas fa-circle-exclamation',
};
diff --git a/packages/vue-vuetify/src/icons/icons.ts b/packages/vue-vuetify/src/icons/icons.ts
index 12985eef09..dd05a3eb69 100644
--- a/packages/vue-vuetify/src/icons/icons.ts
+++ b/packages/vue-vuetify/src/icons/icons.ts
@@ -10,12 +10,27 @@ export type IconValue =
export interface IconAliases {
itemAdd: IconValue;
+ itemCancel?: IconValue;
+ itemConfirm?: IconValue;
+ itemEdit?: IconValue;
itemMoveUp: IconValue;
itemMoveDown: IconValue;
itemDelete: IconValue;
+ search?: IconValue;
+ treeExpand?: IconValue;
+ treeCollapse?: IconValue;
+ visibilityOff?: IconValue;
+ visibilityOn?: IconValue;
calendarClock: IconValue;
clock: IconValue;
passwordHide: IconValue;
passwordShow: IconValue;
+ typeArray?: IconValue;
+ typeBoolean?: IconValue;
+ typeNull?: IconValue;
+ typeNumber?: IconValue;
+ typeObject?: IconValue;
+ typeString?: IconValue;
+ typeUnknown?: IconValue;
validationError: IconValue;
}
diff --git a/packages/vue-vuetify/src/icons/mdi.ts b/packages/vue-vuetify/src/icons/mdi.ts
index d63940b2b4..4de6565a1c 100644
--- a/packages/vue-vuetify/src/icons/mdi.ts
+++ b/packages/vue-vuetify/src/icons/mdi.ts
@@ -1,14 +1,29 @@
import type { IconAliases } from './icons';
-const aliases: IconAliases = {
+const aliases: Required = {
itemAdd: 'mdi-plus',
+ itemCancel: 'mdi-close',
+ itemConfirm: 'mdi-check',
+ itemEdit: 'mdi-pencil-outline',
itemMoveUp: 'mdi-arrow-up',
itemMoveDown: 'mdi-arrow-down',
itemDelete: 'mdi-delete',
+ search: 'mdi-magnify',
+ treeExpand: 'mdi-chevron-right',
+ treeCollapse: 'mdi-chevron-down',
+ visibilityOff: 'mdi-eye-off',
+ visibilityOn: 'mdi-eye',
calendarClock: 'mdi-calendar-clock',
clock: 'mdi-clock-outline',
passwordHide: 'mdi-eye-off',
passwordShow: 'mdi-eye',
+ typeArray: 'mdi-format-list-bulleted',
+ typeBoolean: 'mdi-toggle-switch-outline',
+ typeNull: 'mdi-null',
+ typeNumber: 'mdi-numeric',
+ typeObject: 'mdi-folder-outline',
+ typeString: 'mdi-format-text',
+ typeUnknown: 'mdi-circle-small',
validationError: 'mdi-alert-circle-outline',
};
diff --git a/packages/vue-vuetify/src/util/composition.ts b/packages/vue-vuetify/src/util/composition.ts
index e50e799ad7..0be5533be9 100644
--- a/packages/vue-vuetify/src/util/composition.ts
+++ b/packages/vue-vuetify/src/util/composition.ts
@@ -507,7 +507,7 @@ export const useNested = (element: false | 'array' | 'object'): NestedInfo => {
};
export const useIcons = () => {
- const iconSet = computed(() => {
+ const iconSet = computed>(() => {
const icons = inject(IconSymbol);
if (!icons) throw new Error('Missing Vuetify Icons provide!');
diff --git a/packages/vue-vuetify/src/util/dynamicProperties.ts b/packages/vue-vuetify/src/util/dynamicProperties.ts
new file mode 100644
index 0000000000..b8c298eab3
--- /dev/null
+++ b/packages/vue-vuetify/src/util/dynamicProperties.ts
@@ -0,0 +1,174 @@
+import { Resolve, type JsonSchema, type JsonSchema7 } from '@jsonforms/core';
+import type Ajv from 'ajv';
+import type { ErrorObject } from 'ajv';
+
+export type DynamicPropertyNameValidationError =
+ | { reason: 'alreadyDefined' }
+ | { reason: 'invalid' }
+ | { reason: 'schema'; errors: ErrorObject[] };
+
+export interface DynamicPropertyNameValidationOptions {
+ propertyName: string;
+ currentPropertyName?: string;
+ data: unknown;
+ propertyNameSchema?: JsonSchema7;
+ ajv?: Ajv;
+}
+
+export const composePropertyPath = (
+ parentPath: string,
+ propertyName: string,
+): string => (parentPath ? `${parentPath}.${propertyName}` : propertyName);
+
+export const getAdditionalPropertyNames = (
+ data: Record | undefined,
+ reservedPropertyNames: string[],
+): string[] =>
+ Object.keys(data ?? {}).filter(
+ (propertyName) => !reservedPropertyNames.includes(propertyName),
+ );
+
+export const haveAdditionalPropertyNamesChanged = (
+ newData: Record | undefined,
+ oldData: Record | undefined,
+ reservedPropertyNames: string[],
+): boolean => {
+ const newNames = getAdditionalPropertyNames(newData, reservedPropertyNames);
+ const oldNames = getAdditionalPropertyNames(oldData, reservedPropertyNames);
+ return (
+ newNames.length !== oldNames.length ||
+ newNames.some((propertyName, index) => propertyName !== oldNames[index])
+ );
+};
+
+export const findPropertySchema = (
+ parentSchema: JsonSchema,
+ propertyName: string,
+ rootSchema: JsonSchema,
+): JsonSchema | undefined => {
+ const declaredSchema = parentSchema.properties?.[propertyName];
+ if (declaredSchema) {
+ return typeof declaredSchema.$ref === 'string'
+ ? (Resolve.schema(rootSchema, declaredSchema.$ref, rootSchema) ??
+ declaredSchema)
+ : declaredSchema;
+ }
+
+ const pattern = Object.keys(parentSchema.patternProperties ?? {}).find(
+ (candidate) => new RegExp(candidate).test(propertyName),
+ );
+ if (pattern) {
+ const patternSchema = parentSchema.patternProperties?.[pattern];
+ return typeof patternSchema?.$ref === 'string'
+ ? (Resolve.schema(rootSchema, patternSchema.$ref, rootSchema) ??
+ patternSchema)
+ : patternSchema;
+ }
+
+ const additionalProperties = parentSchema.additionalProperties;
+ if (typeof additionalProperties === 'object') {
+ return typeof additionalProperties.$ref === 'string'
+ ? (Resolve.schema(rootSchema, additionalProperties.$ref, rootSchema) ??
+ additionalProperties)
+ : additionalProperties;
+ }
+
+ return additionalProperties === true
+ ? { additionalProperties: true }
+ : undefined;
+};
+
+export const getPropertyNameSchema = (
+ schema: JsonSchema,
+ rootSchema: JsonSchema,
+): JsonSchema7 => {
+ const result: JsonSchema7 = { type: 'string' };
+ const propertyNames = (schema as JsonSchema & { propertyNames?: JsonSchema })
+ .propertyNames;
+
+ if (propertyNames && typeof propertyNames === 'object') {
+ const resolved =
+ typeof propertyNames.$ref === 'string'
+ ? (Resolve.schema(rootSchema, propertyNames.$ref, rootSchema) ??
+ propertyNames)
+ : propertyNames;
+ return { ...resolved, ...result } as JsonSchema7;
+ }
+
+ if (
+ schema.additionalProperties === false &&
+ schema.patternProperties &&
+ typeof schema.patternProperties === 'object'
+ ) {
+ const patterns = Object.keys(schema.patternProperties);
+ if (patterns.length > 0) {
+ return { pattern: patterns.join('|'), ...result };
+ }
+ }
+
+ return result;
+};
+
+export const validateDynamicPropertyName = ({
+ propertyName,
+ currentPropertyName,
+ data,
+ propertyNameSchema,
+ ajv,
+}: DynamicPropertyNameValidationOptions): DynamicPropertyNameValidationError | null => {
+ if (!propertyName) {
+ return null;
+ }
+
+ if (
+ typeof data === 'object' &&
+ data !== null &&
+ Object.prototype.hasOwnProperty.call(data, propertyName) &&
+ propertyName !== currentPropertyName
+ ) {
+ return { reason: 'alreadyDefined' };
+ }
+
+ // JSON Forms data paths use dots as segment separators. Brackets are
+ // treated literally by core and therefore are valid in property names.
+ if (propertyName.includes('.')) {
+ return { reason: 'invalid' };
+ }
+
+ if (
+ propertyNameSchema &&
+ ajv &&
+ !ajv.validate(propertyNameSchema, propertyName)
+ ) {
+ return { reason: 'schema', errors: [...(ajv.errors ?? [])] };
+ }
+
+ return null;
+};
+
+export const getDynamicPropertyNameErrorMessage = (
+ error: DynamicPropertyNameValidationError | null,
+ messages: {
+ alreadyDefined: string;
+ invalid: string;
+ schema?: (error: ErrorObject) => string;
+ },
+): string | null => {
+ if (!error) {
+ return null;
+ }
+ if (error.reason === 'alreadyDefined') {
+ return messages.alreadyDefined;
+ }
+ if (error.reason === 'invalid') {
+ return messages.invalid;
+ }
+ const translatedErrors = error.errors
+ .map((schemaError) =>
+ messages.schema
+ ? messages.schema(schemaError)
+ : (schemaError.message ?? ''),
+ )
+ .filter(Boolean);
+ return translatedErrors.join(', ') || messages.invalid;
+};
diff --git a/packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts b/packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts
new file mode 100644
index 0000000000..845d49f337
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/additional/AdditionalPropertiesMixed.spec.ts
@@ -0,0 +1,33 @@
+import { clearAllIds } from '@jsonforms/core';
+import { nextTick } from 'vue';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { extendedVuetifyRenderers } from '../../../src';
+import { mountJsonForms } from '../util';
+
+describe('AdditionalProperties mixed values', () => {
+ const schema = {
+ type: 'object' as const,
+ additionalProperties: {},
+ };
+ const uischema = { type: 'Control' as const, scope: '#' };
+
+ beforeEach(() => {
+ clearAllIds();
+ });
+
+ it('updates the mixed renderer when an existing property changes type', async () => {
+ const wrapper = mountJsonForms(
+ { dynamic: 'text' },
+ schema,
+ extendedVuetifyRenderers,
+ uischema,
+ );
+
+ expect(wrapper.find('.mixed-primitive').exists()).toBe(true);
+
+ await wrapper.setProps({ data: { dynamic: { nested: true } } });
+ await nextTick();
+
+ expect(wrapper.find('.mixed-tree-container').exists()).toBe(true);
+ });
+});
diff --git a/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts b/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts
new file mode 100644
index 0000000000..ac20fbabc1
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/dynamicProperties.spec.ts
@@ -0,0 +1,199 @@
+import { createAjv, type JsonSchema } from '@jsonforms/core';
+import { describe, expect, it } from 'vitest';
+import {
+ composePropertyPath,
+ findPropertySchema,
+ getDynamicPropertyNameErrorMessage,
+ getPropertyNameSchema,
+ haveAdditionalPropertyNamesChanged,
+ validateDynamicPropertyName,
+} from '../../../src/util/dynamicProperties';
+
+describe('dynamic property utilities', () => {
+ const rootSchema: JsonSchema = {
+ type: 'object',
+ $defs: {
+ propertyName: {
+ type: 'string',
+ pattern: '^[A-Z][A-Za-z0-9]*$',
+ },
+ patternValue: {
+ type: 'number',
+ },
+ },
+ };
+
+ describe('findPropertySchema', () => {
+ it('prefers a matching pattern over additionalProperties: true', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ patternProperties: {
+ '^count': { $ref: '#/$defs/patternValue' },
+ },
+ additionalProperties: true,
+ };
+
+ expect(findPropertySchema(schema, 'countItems', rootSchema)).toEqual({
+ type: 'number',
+ });
+ });
+
+ it('falls back to the additional-properties schema', () => {
+ const schema: JsonSchema = {
+ type: 'object',
+ patternProperties: {
+ '^count': { type: 'number' },
+ },
+ additionalProperties: { type: 'string' },
+ };
+
+ expect(findPropertySchema(schema, 'description', rootSchema)).toEqual({
+ type: 'string',
+ });
+ });
+ });
+
+ describe('getPropertyNameSchema', () => {
+ it('resolves a propertyNames $ref against the root schema', () => {
+ const schema = {
+ type: 'object',
+ propertyNames: { $ref: '#/$defs/propertyName' },
+ } as JsonSchema;
+
+ expect(getPropertyNameSchema(schema, rootSchema)).toEqual({
+ type: 'string',
+ pattern: '^[A-Z][A-Za-z0-9]*$',
+ });
+ });
+
+ it('uses patternProperties as a name constraint only when additional properties are forbidden', () => {
+ const patternProperties = { '^allowed': { type: 'string' } };
+
+ expect(
+ getPropertyNameSchema(
+ {
+ type: 'object',
+ patternProperties,
+ additionalProperties: false,
+ },
+ rootSchema,
+ ),
+ ).toEqual({ type: 'string', pattern: '^allowed' });
+
+ expect(
+ getPropertyNameSchema(
+ {
+ type: 'object',
+ patternProperties,
+ additionalProperties: true,
+ },
+ rootSchema,
+ ),
+ ).toEqual({ type: 'string' });
+ });
+ });
+
+ describe('validateDynamicPropertyName', () => {
+ const ajv = createAjv();
+
+ it('rejects another existing property but allows the current name', () => {
+ const data = { first: 1, second: 2 };
+
+ expect(
+ validateDynamicPropertyName({ propertyName: 'second', data }),
+ ).toEqual({ reason: 'alreadyDefined' });
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'first',
+ currentPropertyName: 'first',
+ data,
+ }),
+ ).toBeNull();
+ });
+
+ it('rejects dots but permits bracket characters supported by core paths', () => {
+ expect(
+ validateDynamicPropertyName({ propertyName: 'nested.value', data: {} }),
+ ).toEqual({ reason: 'invalid' });
+ expect(
+ validateDynamicPropertyName({ propertyName: 'property[0]', data: {} }),
+ ).toBeNull();
+ expect(composePropertyPath('parent', '[property]')).toBe(
+ 'parent.[property]',
+ );
+ });
+
+ it('uses the same JSON Schema validation for both rename entry points', () => {
+ const propertyNameSchema = getPropertyNameSchema(
+ {
+ type: 'object',
+ propertyNames: { $ref: '#/$defs/propertyName' },
+ } as JsonSchema,
+ rootSchema,
+ );
+
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'invalid',
+ data: {},
+ propertyNameSchema,
+ ajv,
+ }),
+ ).toMatchObject({ reason: 'schema' });
+ expect(
+ validateDynamicPropertyName({
+ propertyName: 'ValidName',
+ data: {},
+ propertyNameSchema,
+ ajv,
+ }),
+ ).toBeNull();
+ });
+
+ it('allows JSON Schema errors to be translated by the caller', () => {
+ const validationError = validateDynamicPropertyName({
+ propertyName: 'invalid',
+ data: {},
+ propertyNameSchema: { type: 'string', pattern: '^[A-Z]' },
+ ajv,
+ });
+
+ expect(
+ getDynamicPropertyNameErrorMessage(validationError, {
+ alreadyDefined: 'Already defined',
+ invalid: 'Invalid',
+ schema: (error) => `Translated ${error.keyword} error`,
+ }),
+ ).toBe('Translated pattern error');
+ });
+ });
+
+ describe('haveAdditionalPropertyNamesChanged', () => {
+ it('does not rebuild property descriptors when only a value or its type changes', () => {
+ expect(
+ haveAdditionalPropertyNamesChanged(
+ { declared: 'same', dynamic: { nested: true } },
+ { declared: 'same', dynamic: 'text' },
+ ['declared'],
+ ),
+ ).toBe(false);
+ });
+
+ it('detects additional-property additions, removals, and order changes', () => {
+ expect(
+ haveAdditionalPropertyNamesChanged(
+ { first: 1, second: 2 },
+ { first: 1 },
+ [],
+ ),
+ ).toBe(true);
+ expect(
+ haveAdditionalPropertyNamesChanged(
+ { second: 2, first: 1 },
+ { first: 1, second: 2 },
+ [],
+ ),
+ ).toBe(true);
+ });
+ });
+});
diff --git a/packages/vue-vuetify/tests/unit/util/i18nUtil.spec.ts b/packages/vue-vuetify/tests/unit/util/i18nUtil.spec.ts
new file mode 100644
index 0000000000..9a07fee897
--- /dev/null
+++ b/packages/vue-vuetify/tests/unit/util/i18nUtil.spec.ts
@@ -0,0 +1,55 @@
+import { createTranslator } from '@jsonforms/core';
+import { describe, expect, it } from 'vitest';
+import { additionalPropertiesDefaultTranslations } from '../../../src/i18n/additionalPropertiesTranslations';
+import {
+ getAdditionalPropertyTranslation,
+ getMixedRendererTranslations,
+} from '../../../src/i18n/i18nUtil';
+import { AdditionalPropertiesTranslationEnum } from '../../../src/i18n';
+
+describe('Vuetify i18n utilities', () => {
+ it('uses translated Additional Properties action labels with their values', () => {
+ const translate = createTranslator((id, _defaultMessage, value) =>
+ id === 'control.additionalProperties.renameAriaLabel'
+ ? `Edit ${value}`
+ : undefined,
+ );
+
+ expect(
+ getAdditionalPropertyTranslation(
+ translate,
+ additionalPropertiesDefaultTranslations,
+ 'control.additionalProperties',
+ AdditionalPropertiesTranslationEnum.renameAriaLabel,
+ 'dynamicKey',
+ ),
+ ).toBe('Edit dynamicKey');
+ });
+
+ it('provides translated mixed tree strings and generated item labels', () => {
+ const translate = createTranslator((id, _defaultMessage, value) => {
+ if (id === 'control.mixedRenderer.searchLabel') {
+ return 'Find node';
+ }
+ if (id === 'control.mixedRenderer.itemLabel') {
+ return `Entry ${value}`;
+ }
+ if (id === 'control.mixedRenderer.renameAriaLabel') {
+ return `Edit ${value} button`;
+ }
+ return undefined;
+ });
+
+ const translations = getMixedRendererTranslations(
+ translate,
+ 'control.mixedRenderer',
+ );
+
+ expect(translations.searchLabel).toBe('Find node');
+ expect(translations.itemLabel(3)).toBe('Entry 3');
+ expect(translations.renameAriaLabel('dynamicKey')).toBe(
+ 'Edit dynamicKey button',
+ );
+ expect(translations.showPrimitives).toBe('Show primitives');
+ });
+});