From cc8075fc7a673eb1344a33b001fa7215913f85f2 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Sat, 5 Jul 2025 21:36:51 +0200 Subject: [PATCH] feat(VSelect, VList): cascade instead of expanding --- .../vuetify/src/components/VList/VList.tsx | 2 + .../src/components/VList/VListChildren.tsx | 5 +- .../src/components/VList/VListItem.tsx | 48 ++++++++++++++++--- .../vuetify/src/components/VMenu/VMenu.tsx | 4 +- .../src/components/VSelect/VSelect.tsx | 4 +- .../vuetify/src/composables/list-items.ts | 8 +++- packages/vuetify/src/iconsets/fa.ts | 1 + packages/vuetify/src/iconsets/fa4.ts | 1 + packages/vuetify/src/iconsets/md.ts | 1 + packages/vuetify/src/iconsets/mdi-svg.ts | 1 + packages/vuetify/src/iconsets/mdi.ts | 1 + 11 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/vuetify/src/components/VList/VList.tsx b/packages/vuetify/src/components/VList/VList.tsx index 4535d4ad406..e302f3d934b 100644 --- a/packages/vuetify/src/components/VList/VList.tsx +++ b/packages/vuetify/src/components/VList/VList.tsx @@ -101,6 +101,7 @@ export const makeVListProps = propsFactory({ }, slim: Boolean, nav: Boolean, + cascade: Boolean, 'onClick:open': EventProp<[{ id: unknown, value: boolean, path: unknown[] }]>(), 'onClick:select': EventProp<[{ id: unknown, value: boolean, path: unknown[] }]>(), @@ -285,6 +286,7 @@ export const VList = genericComponent diff --git a/packages/vuetify/src/components/VList/VListChildren.tsx b/packages/vuetify/src/components/VList/VListChildren.tsx index 20ba13ccbc3..f36a9fce0ba 100644 --- a/packages/vuetify/src/components/VList/VListChildren.tsx +++ b/packages/vuetify/src/components/VList/VListChildren.tsx @@ -27,6 +27,7 @@ export type VListChildrenSlots = { export const makeVListChildrenProps = propsFactory({ items: Array as PropType, returnObject: Boolean, + cascade: Boolean, }, 'VListChildren') export const VListChildren = genericComponent( @@ -65,7 +66,7 @@ export const VListChildren = genericComponent( const listGroupProps = VListGroup.filterProps(itemProps) - return children ? ( + return children && !props.cascade ? ( ( ) diff --git a/packages/vuetify/src/components/VList/VListItem.tsx b/packages/vuetify/src/components/VList/VListItem.tsx index a1eeba25191..d34f9f89232 100644 --- a/packages/vuetify/src/components/VList/VListItem.tsx +++ b/packages/vuetify/src/components/VList/VListItem.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ // Styles import './VListItem.sass' @@ -27,11 +28,13 @@ import { genOverlays, makeVariantProps, useVariant } from '@/composables/variant import vRipple from '@/directives/ripple' // Utilities -import { computed, onBeforeMount, toDisplayString, toRef, watch } from 'vue' +import { computed, onBeforeMount, shallowRef, toDisplayString, toRef, watch } from 'vue' import { deprecate, EventProp, genericComponent, keyCodes, propsFactory, useRender } from '@/util' // Types import type { PropType } from 'vue' +import { VList } from './VList' +import { VMenu } from '../VMenu' import type { RippleDirectiveBinding } from '@/directives/ripple' export type ListItemSlot = { @@ -44,6 +47,7 @@ export type ListItemSlot = { isOpen: boolean isSelected: boolean isIndeterminate: boolean + cascade: boolean select: (value: boolean) => void } @@ -81,6 +85,9 @@ export const makeVListItemProps = propsFactory({ default: undefined, }, nav: Boolean, + cascade: Boolean, + children: Array as PropType, + prependAvatar: String, prependIcon: IconValue, ripple: { @@ -146,6 +153,9 @@ export const VListItem = genericComponent()({ (props.active || link.isActive?.value || (root.activatable.value ? isActivated.value : isSelected.value)) ) const isLink = toRef(() => props.link !== false && link.isLink.value) + const hasSubmenu = toRef(props.cascade && !!props.children) + const submenu = shallowRef(false) + const isSelectable = computed(() => (!!list && (root.selectable.value || root.activatable.value || props.value != null))) const isClickable = computed(() => !props.disabled && @@ -200,17 +210,23 @@ export const VListItem = genericComponent()({ isOpen: isOpen.value, isSelected: isSelected.value, isIndeterminate: isIndeterminate.value, + cascade: props.cascade, } satisfies ListItemSlot)) function onClick (e: MouseEvent) { - emit('click', e) + if (hasSubmenu.value) { + submenu.value = true + } else { + emit('click', e) + } + if (['INPUT', 'TEXTAREA'].includes((e.target as Element)?.tagName)) return if (!isClickable.value) return link.navigate?.(e) - if (isGroupActivator) return + if (isGroupActivator || hasSubmenu.value) return if (root.activatable.value) { activate(!isActivated.value, e) @@ -238,9 +254,10 @@ export const VListItem = genericComponent()({ const hasTitle = (slots.title || props.title != null) const hasSubtitle = (slots.subtitle || props.subtitle != null) const hasAppendMedia = !!(props.appendAvatar || props.appendIcon) - const hasAppend = !!(hasAppendMedia || slots.append) + const hasAppend = !!(hasAppendMedia || hasSubmenu.value || slots.append) const hasPrependMedia = !!(props.prependAvatar || props.prependIcon) const hasPrepend = !!(hasPrependMedia || slots.prepend) + const submenuIcon = !props.appendIcon && !props.appendAvatar && hasSubmenu.value ? '$submenuExpand' : undefined list?.updateHasPrepend(hasPrepend) @@ -353,15 +370,32 @@ export const VListItem = genericComponent()({ { slots.default?.(slotProps.value) } + { hasSubmenu.value && ( + + + + )} { hasAppend && (
{ !slots.append ? ( <> - { props.appendIcon && ( + { (props.appendIcon || submenuIcon) && ( )} @@ -384,7 +418,7 @@ export const VListItem = genericComponent()({ }, VIcon: { density: props.density, - icon: props.appendIcon, + icon: props.appendIcon || submenuIcon, }, VListItemAction: { end: true, diff --git a/packages/vuetify/src/components/VMenu/VMenu.tsx b/packages/vuetify/src/components/VMenu/VMenu.tsx index 7150113d79d..9cebc2e4ef8 100644 --- a/packages/vuetify/src/components/VMenu/VMenu.tsx +++ b/packages/vuetify/src/components/VMenu/VMenu.tsx @@ -146,7 +146,9 @@ export const VMenu = genericComponent()({ }, { immediate: true }) function onClickOutside (e: MouseEvent) { - parent?.closeParents(e) + if (!props.submenu) { + parent?.closeParents(e) + } } function onKeydown (e: KeyboardEvent) { diff --git a/packages/vuetify/src/components/VSelect/VSelect.tsx b/packages/vuetify/src/components/VSelect/VSelect.tsx index bf8fb5ed879..be12b59d517 100644 --- a/packages/vuetify/src/components/VSelect/VSelect.tsx +++ b/packages/vuetify/src/components/VSelect/VSelect.tsx @@ -110,6 +110,7 @@ export const VSelect = genericComponent, ReturnObject extends boolean = false, + Cascade extends boolean = false, Multiple extends boolean = false, V extends Value = Value >( @@ -119,6 +120,7 @@ export const VSelect = genericComponent> itemProps?: SelectItemKey> returnObject?: ReturnObject + cascade?: Cascade multiple?: Multiple modelValue?: V | null 'onUpdate:modelValue'?: (value: V) => void @@ -487,7 +489,7 @@ export const VSelect = genericComponent + {{ prepend: ({ isSelected }) => ( <> diff --git a/packages/vuetify/src/composables/list-items.ts b/packages/vuetify/src/composables/list-items.ts index f09534df269..34840349ff3 100644 --- a/packages/vuetify/src/composables/list-items.ts +++ b/packages/vuetify/src/composables/list-items.ts @@ -24,6 +24,7 @@ export interface ItemProps { itemChildren: SelectItemKey itemProps: SelectItemKey returnObject: boolean + cascade: boolean valueComparator: typeof deepEqual | undefined } @@ -50,6 +51,7 @@ export const makeItemsProps = propsFactory({ default: 'props', }, returnObject: Boolean, + cascade: Boolean, valueComparator: Function as PropType, }, 'list-items') @@ -59,7 +61,7 @@ export function transformItem (props: Omit, item: any): List const children = getPropertyFromItem(item, props.itemChildren) const itemProps = props.itemProps === true ? typeof item === 'object' && item != null && !Array.isArray(item) - ? 'children' in item + ? 'children' in item && !props.cascade ? omit(item, ['children']) : item : undefined @@ -68,6 +70,8 @@ export function transformItem (props: Omit, item: any): List const _props = { title, value, + cascade: props.cascade, + children: props.cascade ? children : [], ...itemProps, } @@ -87,6 +91,7 @@ export function transformItems (props: Omit, items: ItemProp 'itemChildren', 'itemProps', 'returnObject', + 'cascade', 'valueComparator', ]) @@ -141,6 +146,7 @@ export function useItems (props: ItemProps) { 'itemChildren', 'itemProps', 'returnObject', + 'cascade', 'valueComparator', ]) diff --git a/packages/vuetify/src/iconsets/fa.ts b/packages/vuetify/src/iconsets/fa.ts index 8b49a271039..5e2740ffe40 100644 --- a/packages/vuetify/src/iconsets/fa.ts +++ b/packages/vuetify/src/iconsets/fa.ts @@ -41,6 +41,7 @@ const aliases: IconAliases = { plus: 'fas fa-plus', minus: 'fas fa-minus', calendar: 'fas fa-calendar', + submenuExpand: 'fas fa-caret-right', treeviewCollapse: 'fas fa-caret-down', treeviewExpand: 'fas fa-caret-right', eyeDropper: 'fas fa-eye-dropper', diff --git a/packages/vuetify/src/iconsets/fa4.ts b/packages/vuetify/src/iconsets/fa4.ts index 5b8d62ba070..e51917349a8 100644 --- a/packages/vuetify/src/iconsets/fa4.ts +++ b/packages/vuetify/src/iconsets/fa4.ts @@ -44,6 +44,7 @@ const aliases: IconAliases = { plus: 'fa-plus', minus: 'fa-minus', calendar: 'fa-calendar', + submenuExpand: 'fa-caret-right', treeviewCollapse: 'fa-caret-down', treeviewExpand: 'fa-caret-right', eyeDropper: 'fa-eye-dropper', diff --git a/packages/vuetify/src/iconsets/md.ts b/packages/vuetify/src/iconsets/md.ts index 64ee6880dfd..ac43bb42d42 100644 --- a/packages/vuetify/src/iconsets/md.ts +++ b/packages/vuetify/src/iconsets/md.ts @@ -44,6 +44,7 @@ const aliases: IconAliases = { plus: 'add', minus: 'remove', calendar: 'event', + submenuExpand: 'arrow_right', treeviewCollapse: 'arrow_drop_down', treeviewExpand: 'arrow_right', eyeDropper: 'colorize', diff --git a/packages/vuetify/src/iconsets/mdi-svg.ts b/packages/vuetify/src/iconsets/mdi-svg.ts index 4d9281c4be3..e232048c359 100644 --- a/packages/vuetify/src/iconsets/mdi-svg.ts +++ b/packages/vuetify/src/iconsets/mdi-svg.ts @@ -43,6 +43,7 @@ const aliases: IconAliases = { plus: 'svg:M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z', minus: 'svg:M19,13H5V11H19V13Z', calendar: 'svg:M19,19H5V8H19M16,1V3H8V1H6V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3H18V1M17,12H12V17H17V12Z', + submenuExpand: 'svg:M10,17L15,12L10,7V17Z', treeviewCollapse: 'svg:M7,10L12,15L17,10H7Z', treeviewExpand: 'svg:M10,17L15,12L10,7V17Z', eyeDropper: 'svg:M19.35,11.72L17.22,13.85L15.81,12.43L8.1,20.14L3.5,22L2,20.5L3.86,15.9L11.57,8.19L10.15,6.78L12.28,4.65L19.35,11.72M16.76,3C17.93,1.83 19.83,1.83 21,3C22.17,4.17 22.17,6.07 21,7.24L19.08,9.16L14.84,4.92L16.76,3M5.56,17.03L4.5,19.5L6.97,18.44L14.4,11L13,9.6L5.56,17.03Z', diff --git a/packages/vuetify/src/iconsets/mdi.ts b/packages/vuetify/src/iconsets/mdi.ts index 0260ae3877c..c8fe73fb3ab 100644 --- a/packages/vuetify/src/iconsets/mdi.ts +++ b/packages/vuetify/src/iconsets/mdi.ts @@ -44,6 +44,7 @@ const aliases: IconAliases = { plus: 'mdi-plus', minus: 'mdi-minus', calendar: 'mdi-calendar', + submenuExpand: 'mdi-menu-right', treeviewCollapse: 'mdi-menu-down', treeviewExpand: 'mdi-menu-right', eyeDropper: 'mdi-eyedropper',