@m3ui-vue/m3ui-vue 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/{MMenuItem-_n5OG5MT.js → MMenuItem-CIDblhtb.js} +45 -33
  2. package/dist/MMenuItem-CIDblhtb.js.map +1 -0
  3. package/dist/components/MCarousel.vue.d.ts +36 -0
  4. package/dist/components/MDataTable.vue.d.ts +17 -0
  5. package/dist/components/MFab.vue.d.ts +1 -1
  6. package/dist/components/MFlex.vue.d.ts +1 -1
  7. package/dist/components/MMaskField.vue.d.ts +24 -0
  8. package/dist/components/MMultiSelect.vue.d.ts +7 -5
  9. package/dist/components/MNavigationDrawer.vue.d.ts +9 -3
  10. package/dist/components/MNumberField.vue.d.ts +27 -0
  11. package/dist/components/MSelect.vue.d.ts +12 -9
  12. package/dist/components/MSplitter.vue.d.ts +1 -1
  13. package/dist/components/MStack.vue.d.ts +1 -1
  14. package/dist/components/MTable.vue.d.ts +1 -1
  15. package/dist/components/MTextField.vue.d.ts +2 -5
  16. package/dist/components/_MDrawerItemList.vue.d.ts +57 -0
  17. package/dist/index.d.ts +8 -1
  18. package/dist/m3ui-vue.css +1 -1
  19. package/dist/m3ui.js +1864 -1162
  20. package/dist/m3ui.js.map +1 -1
  21. package/dist/rich-text-editor.js +1 -1
  22. package/dist/styles.css +1 -1
  23. package/package.json +1 -1
  24. package/src/components/MCarousel.vue +203 -0
  25. package/src/components/MDataTable.vue +60 -5
  26. package/src/components/MMaskField.vue +198 -0
  27. package/src/components/MMultiSelect.vue +42 -17
  28. package/src/components/MNavigationDrawer.vue +282 -107
  29. package/src/components/MNumberField.vue +176 -0
  30. package/src/components/MSelect.vue +41 -14
  31. package/src/components/MTextField.vue +19 -10
  32. package/src/components/_MDrawerItemList.vue +105 -0
  33. package/src/index.ts +8 -1
  34. package/dist/MMenuItem-_n5OG5MT.js.map +0 -1
@@ -6,13 +6,13 @@ import { useFieldBg } from '../composables/useFieldBg'
6
6
 
7
7
  export interface MultiSelectOption {
8
8
  label: string
9
- value: string | number
9
+ value: unknown
10
10
  disabled?: boolean
11
11
  }
12
12
 
13
13
  const props = withDefaults(
14
14
  defineProps<{
15
- modelValue: (string | number)[]
15
+ modelValue: unknown[]
16
16
  options: MultiSelectOption[]
17
17
  label?: string
18
18
  placeholder?: string
@@ -25,6 +25,7 @@ const props = withDefaults(
25
25
  fieldBg?: string
26
26
  searchable?: boolean
27
27
  maxChips?: number
28
+ clearable?: boolean
28
29
  }>(),
29
30
  {
30
31
  modelValue: () => [],
@@ -33,10 +34,11 @@ const props = withDefaults(
33
34
  required: false,
34
35
  searchable: true,
35
36
  maxChips: 3,
37
+ clearable: false,
36
38
  },
37
39
  )
38
40
 
39
- const emit = defineEmits<{ 'update:modelValue': [(string | number)[]] }>()
41
+ const emit = defineEmits<{ 'update:modelValue': [unknown[]] }>()
40
42
 
41
43
  const id = useId()
42
44
  const open = ref(false)
@@ -47,6 +49,16 @@ const dropdownEl = ref<HTMLElement | null>(null)
47
49
  const searchInput = ref<HTMLInputElement | null>(null)
48
50
  const dropPos = ref({ top: '0px', left: '0px', width: '0px' })
49
51
 
52
+ function eq(a: unknown, b: unknown): boolean {
53
+ if (a === b) return true
54
+ if (typeof a !== 'object' || typeof b !== 'object' || a == null || b == null) return false
55
+ return JSON.stringify(a) === JSON.stringify(b)
56
+ }
57
+
58
+ function includes(arr: unknown[], val: unknown): boolean {
59
+ return arr.some((v) => eq(v, val))
60
+ }
61
+
50
62
  const hasValue = computed(() => props.modelValue.length > 0)
51
63
 
52
64
  const filteredOptions = computed(() => {
@@ -58,24 +70,24 @@ const filteredOptions = computed(() => {
58
70
  const visibleChips = computed(() =>
59
71
  props.modelValue.slice(0, props.maxChips).map((v) => ({
60
72
  value: v,
61
- label: props.options.find((o) => o.value === v)?.label ?? String(v),
73
+ label: props.options.find((o) => eq(o.value, v))?.label ?? String(v),
62
74
  })),
63
75
  )
64
76
 
65
77
  const overflowCount = computed(() => Math.max(0, props.modelValue.length - props.maxChips))
66
78
 
67
- function toggle(value: string | number) {
79
+ function toggle(value: unknown) {
68
80
  const current = props.modelValue
69
- if (current.includes(value)) {
70
- emit('update:modelValue', current.filter((v) => v !== value))
81
+ if (includes(current, value)) {
82
+ emit('update:modelValue', current.filter((v) => !eq(v, value)))
71
83
  } else {
72
84
  emit('update:modelValue', [...current, value])
73
85
  }
74
86
  }
75
87
 
76
- function removeChip(value: string | number, e: Event) {
88
+ function removeChip(value: unknown, e: Event) {
77
89
  e.stopPropagation()
78
- emit('update:modelValue', props.modelValue.filter((v) => v !== value))
90
+ emit('update:modelValue', props.modelValue.filter((v) => !eq(v, value)))
79
91
  }
80
92
 
81
93
  function computeDropPos() {
@@ -164,7 +176,9 @@ const labelClasses = computed(() => {
164
176
  ? '-top-2.5 translate-y-0 text-label-small bg-[var(--field-bg)] px-1 right-auto max-w-[calc(100%-1.5rem)]'
165
177
  : 'top-2 translate-y-0 text-label-small'
166
178
 
167
- const unFloated = 'top-1/2 -translate-y-1/2 text-body-large'
179
+ const unFloated = props.variant === 'filled'
180
+ ? 'top-[53%] -translate-y-1/2 text-body-large'
181
+ : 'top-1/2 -translate-y-1/2 text-body-large'
168
182
  const active = open.value || hasValue.value
169
183
 
170
184
  return [
@@ -188,7 +202,8 @@ const labelClasses = computed(() => {
188
202
  >
189
203
  <div
190
204
  v-if="leadingIcon"
191
- class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-on-surface-variant"
205
+ class="pointer-events-none absolute left-3.5 text-on-surface-variant"
206
+ :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
192
207
  >
193
208
  <MIcon :name="leadingIcon" :size="20" />
194
209
  </div>
@@ -208,8 +223,8 @@ const labelClasses = computed(() => {
208
223
  >
209
224
  <template v-if="hasValue">
210
225
  <span
211
- v-for="chip in visibleChips"
212
- :key="chip.value"
226
+ v-for="(chip, i) in visibleChips"
227
+ :key="i"
213
228
  class="inline-flex items-center gap-1 rounded-full bg-secondary-container px-2 py-0.5 text-label-small text-on-secondary-container"
214
229
  >
215
230
  {{ chip.label }}
@@ -237,7 +252,17 @@ const labelClasses = computed(() => {
237
252
  {{ label }}<span v-if="required" class="text-error">&nbsp;*</span>
238
253
  </label>
239
254
 
240
- <div class="pointer-events-none absolute right-2 top-7 -translate-y-1/2">
255
+ <button
256
+ v-if="clearable && hasValue && !disabled"
257
+ type="button"
258
+ class="absolute right-9 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
259
+ :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
260
+ @click.stop="emit('update:modelValue', []); close()"
261
+ >
262
+ <MIcon name="close" :size="18" />
263
+ </button>
264
+
265
+ <div class="pointer-events-none absolute right-2" :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'">
241
266
  <MIcon
242
267
  :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
243
268
  :size="24"
@@ -282,13 +307,13 @@ const labelClasses = computed(() => {
282
307
 
283
308
  <div class="flex flex-col py-1">
284
309
  <label
285
- v-for="opt in filteredOptions"
286
- :key="opt.value"
310
+ v-for="(opt, i) in filteredOptions"
311
+ :key="i"
287
312
  class="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-on-surface/8"
288
313
  :class="opt.disabled ? 'cursor-not-allowed opacity-38' : ''"
289
314
  >
290
315
  <MCheckbox
291
- :model-value="modelValue.includes(opt.value)"
316
+ :model-value="includes(modelValue, opt.value)"
292
317
  :disabled="opt.disabled"
293
318
  @update:model-value="!opt.disabled && toggle(opt.value)"
294
319
  />
@@ -1,66 +1,125 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch } from 'vue'
3
- import MIcon from './MIcon.vue'
2
+ import { ref, reactive, watch, provide, computed } from "vue";
3
+ import MIcon from "./MIcon.vue";
4
+ import MDrawerItemList from "./_MDrawerItemList.vue";
4
5
 
5
6
  export interface DrawerItem {
6
- value: string | number
7
- label: string
8
- icon?: string
9
- badge?: string | number
10
- disabled?: boolean
11
- to?: string | Record<string, any>
7
+ value: string | number;
8
+ label: string;
9
+ icon?: string;
10
+ badge?: string | number;
11
+ disabled?: boolean;
12
+ to?: string | Record<string, any>;
13
+ children?: DrawerItem[];
14
+ iconSize?: number;
15
+ labelClass?: string;
16
+ py?: string;
12
17
  }
13
18
 
14
19
  export interface DrawerSection {
15
- title?: string
16
- icon?: string
17
- items: DrawerItem[]
18
- collapsible?: boolean
20
+ title?: string;
21
+ icon?: string;
22
+ items: DrawerItem[];
23
+ collapsible?: boolean;
19
24
  }
20
25
 
21
- const props = withDefaults(defineProps<{
22
- modelValue: boolean
23
- selected?: string | number
24
- sections: DrawerSection[]
25
- title?: string
26
- modal?: boolean
27
- collapsed?: boolean
28
- }>(), { modal: true })
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ modelValue: boolean;
29
+ selected?: string | number;
30
+ sections: DrawerSection[];
31
+ title?: string;
32
+ modal?: boolean;
33
+ collapsed?: boolean;
34
+ }>(),
35
+ { modal: true },
36
+ );
29
37
 
30
38
  const emit = defineEmits<{
31
- 'update:modelValue': [boolean]
32
- select: [string | number]
33
- }>()
39
+ "update:modelValue": [boolean];
40
+ select: [string | number];
41
+ }>();
34
42
 
35
- const openSections = ref<Record<string, boolean>>({})
43
+ const openSections = ref<Record<string, boolean>>({});
44
+ const openItems = reactive<Record<string | number, boolean>>({});
36
45
 
37
46
  function isSectionOpen(section: DrawerSection, index: number) {
38
- const key = section.title ?? `__${index}`
39
- return openSections.value[key] !== false
47
+ const key = section.title ?? `__${index}`;
48
+ return openSections.value[key] !== false;
40
49
  }
41
50
 
42
51
  function toggleSection(section: DrawerSection, index: number) {
43
- const key = section.title ?? `__${index}`
44
- openSections.value[key] = !isSectionOpen(section, index)
52
+ const key = section.title ?? `__${index}`;
53
+ openSections.value[key] = !isSectionOpen(section, index);
45
54
  }
46
55
 
47
- function close() { emit('update:modelValue', false) }
56
+ function isItemOpen(item: DrawerItem) {
57
+ return openItems[item.value] === true;
58
+ }
59
+
60
+ function toggleItem(item: DrawerItem) {
61
+ openItems[item.value] = !openItems[item.value];
62
+ }
63
+
64
+ function close() {
65
+ emit("update:modelValue", false);
66
+ }
48
67
  function select(item: DrawerItem) {
49
- if (item.disabled) return
50
- emit('select', item.value)
51
- if (props.modal) close()
68
+ if (item.disabled) return;
69
+ if (item.children?.length) toggleItem(item);
70
+ emit("select", item.value);
71
+ if (props.modal && !item.children?.length) close();
52
72
  }
53
73
 
54
- function itemTag(item: DrawerItem) {
55
- return item.to && !item.disabled ? 'RouterLink' : 'button'
74
+ function onChildSelect(item: DrawerItem) {
75
+ if (item.disabled) return;
76
+ emit("select", item.value);
77
+ if (props.modal && !item.children?.length) close();
56
78
  }
57
79
 
80
+ function onChildToggle(item: DrawerItem) {
81
+ openItems[item.value] = !openItems[item.value];
82
+ }
58
83
 
59
- watch(() => props.modelValue, (open) => {
60
- if (props.modal) {
61
- document.body.style.overflow = open ? 'hidden' : ''
62
- }
63
- })
84
+ function subEnter(el: Element, done: () => void) {
85
+ const e = el as HTMLElement
86
+ e.style.overflow = 'hidden'
87
+ const h = e.scrollHeight
88
+ e.animate(
89
+ [{ height: '0px' }, { height: h + 'px' }],
90
+ { duration: 250, easing: 'cubic-bezier(0.2, 0, 0, 1)' },
91
+ ).onfinish = () => { e.style.overflow = ''; done() }
92
+ }
93
+ function subLeave(el: Element, done: () => void) {
94
+ const e = el as HTMLElement
95
+ e.style.overflow = 'hidden'
96
+ const h = e.scrollHeight
97
+ e.animate(
98
+ [{ height: h + 'px' }, { height: '0px' }],
99
+ { duration: 200, easing: 'cubic-bezier(0.4, 0, 1, 1)' },
100
+ ).onfinish = done
101
+ }
102
+
103
+ const collapsedRef = computed(() => props.collapsed ?? false)
104
+ provide("nd-open-items", openItems);
105
+ provide("nd-toggle-item", onChildToggle);
106
+ provide("nd-select-item", onChildSelect);
107
+ provide("nd-collapsed", collapsedRef);
108
+
109
+ defineExpose({ openItems })
110
+
111
+ function itemTag(item: DrawerItem) {
112
+ return item.to && !item.disabled ? "RouterLink" : "button";
113
+ }
114
+
115
+ watch(
116
+ () => props.modelValue,
117
+ (open) => {
118
+ if (props.modal) {
119
+ document.body.style.overflow = open ? "hidden" : "";
120
+ }
121
+ },
122
+ );
64
123
  </script>
65
124
 
66
125
  <template>
@@ -70,12 +129,16 @@ watch(() => props.modelValue, (open) => {
70
129
  <div v-if="modelValue" class="fixed inset-0 z-100 flex">
71
130
  <div class="nd-scrim absolute inset-0 bg-black/40" @click="close" />
72
131
 
73
- <nav class="nd-panel relative flex h-full w-72 max-w-[85vw] flex-col bg-surface-container shadow-elevation-3">
132
+ <nav
133
+ class="nd-panel relative flex h-full w-72 max-w-[85vw] flex-col bg-surface-container shadow-elevation-3"
134
+ >
74
135
  <div v-if="$slots.header" class="shrink-0">
75
136
  <slot name="header" />
76
137
  </div>
77
138
  <div v-else-if="title" class="shrink-0 px-5 pt-6 pb-2">
78
- <h2 class="text-title-small font-medium text-on-surface-variant">{{ title }}</h2>
139
+ <h2 class="text-title-small font-medium text-on-surface-variant">
140
+ {{ title }}
141
+ </h2>
79
142
  </div>
80
143
 
81
144
  <div class="flex-1 overflow-y-auto px-3 py-2">
@@ -88,48 +151,88 @@ watch(() => props.modelValue, (open) => {
88
151
  class="mt-1 flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-on-surface-variant transition-colors hover:bg-on-surface/8"
89
152
  @click="toggleSection(section, si)"
90
153
  >
91
- <MIcon v-if="section.icon" :name="section.icon" :size="24" class="shrink-0" />
92
- <span class="flex-1 text-left text-title-small font-medium">{{ section.title }}</span>
93
154
  <MIcon
94
- :name="isSectionOpen(section, si) ? 'expand_less' : 'expand_more'"
155
+ v-if="section.icon"
156
+ :name="section.icon"
157
+ :size="24"
158
+ class="shrink-0"
159
+ />
160
+ <span class="flex-1 text-left text-title-small font-medium">{{
161
+ section.title
162
+ }}</span>
163
+ <MIcon
164
+ :name="
165
+ isSectionOpen(section, si) ? 'expand_less' : 'expand_more'
166
+ "
95
167
  :size="18"
96
168
  class="shrink-0"
97
169
  />
98
170
  </button>
99
- <p v-else-if="section.title" class="px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant">
171
+ <p
172
+ v-else-if="section.title"
173
+ class="px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant"
174
+ >
100
175
  {{ section.title }}
101
176
  </p>
102
177
 
103
178
  <Transition name="nd-section">
104
- <div v-if="!section.collapsible || isSectionOpen(section, si)" class="nd-section-grid">
179
+ <div
180
+ v-if="!section.collapsible || isSectionOpen(section, si)"
181
+ class="nd-section-grid"
182
+ >
105
183
  <div class="nd-section-body">
106
- <component
107
- :is="itemTag(item)"
108
- v-for="item in section.items"
109
- :key="item.value"
110
- :to="item.to && !item.disabled ? item.to : undefined"
111
- :type="item.to ? undefined : 'button'"
112
- class="flex w-full items-center gap-3 rounded-full py-2.5 text-left transition-colors focus-visible:outline-none"
113
- :class="[
114
- section.collapsible ? 'pl-8 pr-3' : 'px-4',
115
- item.disabled
116
- ? 'cursor-not-allowed opacity-[0.38]'
117
- : item.value === selected
118
- ? 'bg-secondary-container text-on-secondary-container'
119
- : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
120
- ]"
121
- :disabled="item.disabled && !item.to"
122
- @click="select(item)"
123
- >
124
- <MIcon v-if="item.icon" :name="item.icon" :size="24" />
125
- <span class="flex-1 text-label-large font-medium">{{ item.label }}</span>
126
- <span
127
- v-if="item.badge != null"
128
- class="text-label-medium text-on-surface-variant"
184
+ <template v-for="item in section.items" :key="item.value">
185
+ <component
186
+ :is="itemTag(item)"
187
+ :to="item.to && !item.disabled ? item.to : undefined"
188
+ :type="item.to ? undefined : 'button'"
189
+ class="flex w-full items-center gap-3 rounded-full py-2.5 text-left transition-colors focus-visible:outline-none"
190
+ :class="[
191
+ section.collapsible ? 'pl-8 pr-3' : 'px-4',
192
+ item.disabled
193
+ ? 'cursor-not-allowed opacity-[0.38]'
194
+ : item.value === selected
195
+ ? 'bg-secondary-container text-on-secondary-container'
196
+ : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
197
+ ]"
198
+ :disabled="item.disabled && !item.to"
199
+ @click="select(item)"
200
+ >
201
+ <MIcon v-if="item.icon" :name="item.icon" :size="item.iconSize ?? 24" />
202
+ <span class="flex-1 font-medium" :class="item.labelClass ?? 'text-label-large'">{{
203
+ item.label
204
+ }}</span>
205
+ <MIcon
206
+ v-if="item.children?.length"
207
+ :name="
208
+ isItemOpen(item) ? 'expand_less' : 'expand_more'
209
+ "
210
+ :size="18"
211
+ class="shrink-0 text-on-surface-variant"
212
+ />
213
+ <span
214
+ v-else-if="item.badge != null"
215
+ class="text-label-medium text-on-surface-variant"
216
+ >
217
+ {{ item.badge }}
218
+ </span>
219
+ </component>
220
+ <Transition
221
+ name="nd-sub"
222
+ :css="false"
223
+ @enter="subEnter"
224
+ @leave="subLeave"
129
225
  >
130
- {{ item.badge }}
131
- </span>
132
- </component>
226
+ <div
227
+ v-if="item.children?.length && isItemOpen(item)"
228
+ >
229
+ <MDrawerItemList
230
+ :items="item.children"
231
+ :selected="selected"
232
+ />
233
+ </div>
234
+ </Transition>
235
+ </template>
133
236
  </div>
134
237
  </div>
135
238
  </Transition>
@@ -145,7 +248,11 @@ watch(() => props.modelValue, (open) => {
145
248
  <nav
146
249
  class="nd-inline flex h-full shrink-0 flex-col border-r border-outline-variant bg-surface"
147
250
  :class="[
148
- !modelValue ? 'nd-hidden w-0 border-r-0' : collapsed ? 'nd-collapsed w-[72px]' : 'w-72',
251
+ !modelValue
252
+ ? 'nd-hidden w-0 border-r-0'
253
+ : collapsed
254
+ ? 'nd-collapsed w-[72px]'
255
+ : 'w-72',
149
256
  ]"
150
257
  >
151
258
  <div v-if="$slots.header" class="shrink-0">
@@ -153,11 +260,17 @@ watch(() => props.modelValue, (open) => {
153
260
  </div>
154
261
  <div v-else-if="title" class="nd-collapse-h shrink-0 overflow-hidden">
155
262
  <div class="px-5 pt-6 pb-2">
156
- <h2 class="whitespace-nowrap text-title-small font-medium text-on-surface-variant">{{ title }}</h2>
263
+ <h2
264
+ class="whitespace-nowrap text-title-small font-medium text-on-surface-variant"
265
+ >
266
+ {{ title }}
267
+ </h2>
157
268
  </div>
158
269
  </div>
159
270
 
160
- <div class="flex flex-col gap-1 overflow-y-auto overflow-x-hidden px-3 py-2">
271
+ <div
272
+ class="flex flex-col gap-1 overflow-y-auto overflow-x-hidden px-3 py-2"
273
+ >
161
274
  <template v-for="(section, si) in sections" :key="si">
162
275
  <div v-if="si > 0" class="my-1 border-t border-outline-variant" />
163
276
 
@@ -169,8 +282,16 @@ watch(() => props.modelValue, (open) => {
169
282
  :title="collapsed ? section.title : undefined"
170
283
  @click="toggleSection(section, si)"
171
284
  >
172
- <MIcon v-if="section.icon" :name="section.icon" :size="24" class="shrink-0" />
173
- <span class="nd-label min-w-0 flex-1 text-left text-title-small font-medium">{{ section.title }}</span>
285
+ <MIcon
286
+ v-if="section.icon"
287
+ :name="section.icon"
288
+ :size="24"
289
+ class="shrink-0"
290
+ />
291
+ <span
292
+ class="nd-label min-w-0 flex-1 text-left text-title-small font-medium"
293
+ >{{ section.title }}</span
294
+ >
174
295
  <MIcon
175
296
  :name="isSectionOpen(section, si) ? 'expand_less' : 'expand_more'"
176
297
  :size="18"
@@ -180,46 +301,84 @@ watch(() => props.modelValue, (open) => {
180
301
 
181
302
  <!-- Static section title -->
182
303
  <div v-else-if="section.title" class="nd-collapse-h overflow-hidden">
183
- <p class="whitespace-nowrap px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant">
304
+ <p
305
+ class="whitespace-nowrap px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant"
306
+ >
184
307
  {{ section.title }}
185
308
  </p>
186
309
  </div>
187
310
 
188
311
  <Transition name="nd-section">
189
- <div v-if="!section.collapsible || isSectionOpen(section, si)" class="nd-section-grid">
312
+ <div
313
+ v-if="!section.collapsible || isSectionOpen(section, si)"
314
+ class="nd-section-grid"
315
+ >
190
316
  <div class="nd-section-body">
191
- <component
192
- :is="itemTag(item)"
193
- v-for="item in section.items"
194
- :key="item.value"
195
- :to="item.to && !item.disabled ? item.to : undefined"
196
- :type="item.to ? undefined : 'button'"
197
- :title="collapsed ? item.label : undefined"
198
- class="flex w-full shrink-0 items-center gap-3 overflow-hidden whitespace-nowrap rounded-full py-2.5 text-left focus-visible:outline-none"
199
- :class="[
200
- section.collapsible && !collapsed ? 'pl-8 pr-3' : 'px-3',
201
- item.disabled
202
- ? 'cursor-not-allowed opacity-[0.38]'
203
- : item.value === selected
204
- ? 'bg-secondary-container text-on-secondary-container'
205
- : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
206
- ]"
207
- :disabled="item.disabled && !item.to"
208
- @click="select(item)"
209
- >
210
- <MIcon v-if="item.icon" :name="item.icon" :size="24" class="shrink-0" />
211
- <span class="nd-label min-w-0 flex-1 text-label-large font-medium">{{ item.label }}</span>
212
- <span v-if="item.badge != null" class="nd-label text-label-medium text-on-surface-variant">
213
- {{ item.badge }}
214
- </span>
215
- </component>
317
+ <template v-for="item in section.items" :key="item.value">
318
+ <component
319
+ :is="itemTag(item)"
320
+ :to="item.to && !item.disabled ? item.to : undefined"
321
+ :type="item.to ? undefined : 'button'"
322
+ :title="collapsed ? item.label : undefined"
323
+ class="flex w-full shrink-0 items-center gap-3 overflow-hidden whitespace-nowrap rounded-full py-2.5 text-left focus-visible:outline-none"
324
+ :class="[
325
+ section.collapsible && !collapsed ? 'pl-8 pr-3' : 'px-3',
326
+ item.disabled
327
+ ? 'cursor-not-allowed opacity-[0.38]'
328
+ : item.value === selected
329
+ ? 'bg-secondary-container text-on-secondary-container'
330
+ : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
331
+ ]"
332
+ :disabled="item.disabled && !item.to"
333
+ @click="select(item)"
334
+ >
335
+ <MIcon
336
+ v-if="item.icon"
337
+ :name="item.icon"
338
+ :size="item.iconSize ?? 24"
339
+ class="shrink-0"
340
+ />
341
+ <span
342
+ class="nd-label min-w-0 flex-1 font-medium"
343
+ :class="item.labelClass ?? 'text-label-large'"
344
+ >{{ item.label }}</span
345
+ >
346
+ <MIcon
347
+ v-if="item.children?.length"
348
+ :name="isItemOpen(item) ? 'expand_less' : 'expand_more'"
349
+ :size="18"
350
+ class="nd-label shrink-0 text-on-surface-variant"
351
+ />
352
+ <span
353
+ v-else-if="item.badge != null"
354
+ class="nd-label text-label-medium text-on-surface-variant"
355
+ >
356
+ {{ item.badge }}
357
+ </span>
358
+ </component>
359
+ <div v-if="item.children?.length && isItemOpen(item)" class="nd-children-divider" />
360
+ <Transition
361
+ :css="false"
362
+ @enter="subEnter"
363
+ @leave="subLeave"
364
+ >
365
+ <div
366
+ v-if="item.children?.length && isItemOpen(item)"
367
+ >
368
+ <MDrawerItemList
369
+ :items="item.children"
370
+ :selected="selected"
371
+ />
372
+ </div>
373
+ </Transition>
374
+ <div v-if="item.children?.length && isItemOpen(item)" class="nd-children-divider" />
375
+ </template>
216
376
  </div>
217
377
  </div>
218
378
  </Transition>
219
379
  </template>
220
380
  </div>
221
381
  </nav>
222
-
223
382
  </template>
224
383
  </template>
225
384
 
@@ -296,7 +455,8 @@ watch(() => props.modelValue, (open) => {
296
455
  .nd-label {
297
456
  transition: opacity 200ms 80ms cubic-bezier(0.2, 0, 0, 1);
298
457
  }
299
- .nd-inline.nd-collapsed .nd-label {
458
+ .nd-inline.nd-collapsed .nd-label,
459
+ .nd-inline.nd-collapsed :deep(.nd-label) {
300
460
  opacity: 0;
301
461
  transition: opacity 100ms cubic-bezier(0.2, 0, 0, 1);
302
462
  }
@@ -310,7 +470,22 @@ watch(() => props.modelValue, (open) => {
310
470
  max-height: 0;
311
471
  }
312
472
 
313
- .nd-inline .nd-section-body > * {
473
+ .nd-inline .nd-section-body > *,
474
+ .nd-inline :deep(.nd-section-body) > * {
314
475
  transition: padding 300ms cubic-bezier(0.2, 0, 0, 1);
315
476
  }
477
+
478
+ /* Dividers between children groups — only visible in collapsed mode */
479
+ .nd-children-divider {
480
+ height: 0;
481
+ margin: 0 0.75rem;
482
+ border-top: 1px solid transparent;
483
+ transition: border-color 100ms, margin 100ms;
484
+ }
485
+ .nd-inline.nd-collapsed .nd-children-divider {
486
+ border-top-color: var(--color-outline-variant);
487
+ margin: 0.25rem 0.75rem;
488
+ transition: border-color 200ms 300ms, margin 200ms 300ms;
489
+ }
490
+
316
491
  </style>