@m3ui-vue/m3ui-vue 0.1.10 → 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 +1866 -1161
  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 +286 -110
  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();
78
+ }
79
+
80
+ function onChildToggle(item: DrawerItem) {
81
+ openItems[item.value] = !openItems[item.value];
82
+ }
83
+
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
56
101
  }
57
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 })
58
110
 
59
- watch(() => props.modelValue, (open) => {
60
- if (props.modal) {
61
- document.body.style.overflow = open ? 'hidden' : ''
62
- }
63
- })
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,11 +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">
74
- <div v-if="title || $slots.header" class="shrink-0 px-5 pt-6 pb-2">
75
- <slot name="header">
76
- <h2 class="text-title-small font-medium text-on-surface-variant">{{ title }}</h2>
77
- </slot>
132
+ <nav
133
+ class="nd-panel relative flex h-full w-72 max-w-[85vw] flex-col bg-surface-container shadow-elevation-3"
134
+ >
135
+ <div v-if="$slots.header" class="shrink-0">
136
+ <slot name="header" />
137
+ </div>
138
+ <div v-else-if="title" class="shrink-0 px-5 pt-6 pb-2">
139
+ <h2 class="text-title-small font-medium text-on-surface-variant">
140
+ {{ title }}
141
+ </h2>
78
142
  </div>
79
143
 
80
144
  <div class="flex-1 overflow-y-auto px-3 py-2">
@@ -87,48 +151,88 @@ watch(() => props.modelValue, (open) => {
87
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"
88
152
  @click="toggleSection(section, si)"
89
153
  >
90
- <MIcon v-if="section.icon" :name="section.icon" :size="24" class="shrink-0" />
91
- <span class="flex-1 text-left text-title-small font-medium">{{ section.title }}</span>
92
154
  <MIcon
93
- :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
+ "
94
167
  :size="18"
95
168
  class="shrink-0"
96
169
  />
97
170
  </button>
98
- <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
+ >
99
175
  {{ section.title }}
100
176
  </p>
101
177
 
102
178
  <Transition name="nd-section">
103
- <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
+ >
104
183
  <div class="nd-section-body">
105
- <component
106
- :is="itemTag(item)"
107
- v-for="item in section.items"
108
- :key="item.value"
109
- :to="item.to && !item.disabled ? item.to : undefined"
110
- :type="item.to ? undefined : 'button'"
111
- class="flex w-full items-center gap-3 rounded-full py-2.5 text-left transition-colors focus-visible:outline-none"
112
- :class="[
113
- section.collapsible ? 'pl-8 pr-3' : 'px-4',
114
- item.disabled
115
- ? 'cursor-not-allowed opacity-[0.38]'
116
- : item.value === selected
117
- ? 'bg-secondary-container text-on-secondary-container'
118
- : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
119
- ]"
120
- :disabled="item.disabled && !item.to"
121
- @click="select(item)"
122
- >
123
- <MIcon v-if="item.icon" :name="item.icon" :size="24" />
124
- <span class="flex-1 text-label-large font-medium">{{ item.label }}</span>
125
- <span
126
- v-if="item.badge != null"
127
- 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"
128
225
  >
129
- {{ item.badge }}
130
- </span>
131
- </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>
132
236
  </div>
133
237
  </div>
134
238
  </Transition>
@@ -144,7 +248,11 @@ watch(() => props.modelValue, (open) => {
144
248
  <nav
145
249
  class="nd-inline flex h-full shrink-0 flex-col border-r border-outline-variant bg-surface"
146
250
  :class="[
147
- !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',
148
256
  ]"
149
257
  >
150
258
  <div v-if="$slots.header" class="shrink-0">
@@ -152,11 +260,17 @@ watch(() => props.modelValue, (open) => {
152
260
  </div>
153
261
  <div v-else-if="title" class="nd-collapse-h shrink-0 overflow-hidden">
154
262
  <div class="px-5 pt-6 pb-2">
155
- <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>
156
268
  </div>
157
269
  </div>
158
270
 
159
- <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
+ >
160
274
  <template v-for="(section, si) in sections" :key="si">
161
275
  <div v-if="si > 0" class="my-1 border-t border-outline-variant" />
162
276
 
@@ -168,8 +282,16 @@ watch(() => props.modelValue, (open) => {
168
282
  :title="collapsed ? section.title : undefined"
169
283
  @click="toggleSection(section, si)"
170
284
  >
171
- <MIcon v-if="section.icon" :name="section.icon" :size="24" class="shrink-0" />
172
- <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
+ >
173
295
  <MIcon
174
296
  :name="isSectionOpen(section, si) ? 'expand_less' : 'expand_more'"
175
297
  :size="18"
@@ -179,46 +301,84 @@ watch(() => props.modelValue, (open) => {
179
301
 
180
302
  <!-- Static section title -->
181
303
  <div v-else-if="section.title" class="nd-collapse-h overflow-hidden">
182
- <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
+ >
183
307
  {{ section.title }}
184
308
  </p>
185
309
  </div>
186
310
 
187
311
  <Transition name="nd-section">
188
- <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
+ >
189
316
  <div class="nd-section-body">
190
- <component
191
- :is="itemTag(item)"
192
- v-for="item in section.items"
193
- :key="item.value"
194
- :to="item.to && !item.disabled ? item.to : undefined"
195
- :type="item.to ? undefined : 'button'"
196
- :title="collapsed ? item.label : undefined"
197
- 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"
198
- :class="[
199
- section.collapsible && !collapsed ? 'pl-8 pr-3' : 'px-3',
200
- item.disabled
201
- ? 'cursor-not-allowed opacity-[0.38]'
202
- : item.value === selected
203
- ? 'bg-secondary-container text-on-secondary-container'
204
- : 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
205
- ]"
206
- :disabled="item.disabled && !item.to"
207
- @click="select(item)"
208
- >
209
- <MIcon v-if="item.icon" :name="item.icon" :size="24" class="shrink-0" />
210
- <span class="nd-label min-w-0 flex-1 text-label-large font-medium">{{ item.label }}</span>
211
- <span v-if="item.badge != null" class="nd-label text-label-medium text-on-surface-variant">
212
- {{ item.badge }}
213
- </span>
214
- </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>
215
376
  </div>
216
377
  </div>
217
378
  </Transition>
218
379
  </template>
219
380
  </div>
220
381
  </nav>
221
-
222
382
  </template>
223
383
  </template>
224
384
 
@@ -295,7 +455,8 @@ watch(() => props.modelValue, (open) => {
295
455
  .nd-label {
296
456
  transition: opacity 200ms 80ms cubic-bezier(0.2, 0, 0, 1);
297
457
  }
298
- .nd-inline.nd-collapsed .nd-label {
458
+ .nd-inline.nd-collapsed .nd-label,
459
+ .nd-inline.nd-collapsed :deep(.nd-label) {
299
460
  opacity: 0;
300
461
  transition: opacity 100ms cubic-bezier(0.2, 0, 0, 1);
301
462
  }
@@ -309,7 +470,22 @@ watch(() => props.modelValue, (open) => {
309
470
  max-height: 0;
310
471
  }
311
472
 
312
- .nd-inline .nd-section-body > * {
473
+ .nd-inline .nd-section-body > *,
474
+ .nd-inline :deep(.nd-section-body) > * {
313
475
  transition: padding 300ms cubic-bezier(0.2, 0, 0, 1);
314
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
+
315
491
  </style>