@imaginario27/air-ui-ds 1.10.2 → 1.11.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this package are documented in this file.
5
5
  Historical releases were reconstructed from git history (GitHub repository) and npm publish dates.
6
6
  Future releases will include detailed entries generated with Changesets.
7
7
 
8
+ ## 1.10.2 - 2026-04-14
9
+
10
+ Release type: patch.
11
+ Commits found in range: 3.
12
+
13
+ ### Fixed
14
+
15
+ 1. fix typecheck errors in selects ([eee12f8](https://github.com/imaginario27/air-ui/commit/eee12f87045fcb12556f3f38e80f87e5078a0d5c))
16
+ 2. remove duplicated imports ([a277959](https://github.com/imaginario27/air-ui/commit/a2779590034a360e1685193455e978391870a69f))
17
+ 3. fix duplicated imports ([5a324a3](https://github.com/imaginario27/air-ui/commit/5a324a3ea43ae5fdf794e345b54c57207860efbd))
18
+
19
+ - Package: @imaginario27/air-ui-ds.
20
+
21
+ ## 1.10.1 - 2026-04-09
22
+
23
+ Release type: patch.
24
+ Commits found in range: 1.
25
+
26
+ ### Fixed
27
+
28
+ 1. fix TypeScript errors in TabBar.vue ([273925b](https://github.com/imaginario27/air-ui/commit/273925bdca00ac9ad58c1c43de5668ed78a85da1))
29
+
30
+ - Package: @imaginario27/air-ui-ds.
31
+
8
32
  ## 1.10.0 - 2026-04-09
9
33
 
10
34
  Release type: minor.
@@ -32,6 +32,7 @@
32
32
  <div
33
33
  v-if="isOpen"
34
34
  ref="dropdown"
35
+ data-dropdown-menu-panel
35
36
  v-bind="$attrs"
36
37
  :class="[
37
38
  'bg-background-surface',
@@ -55,26 +56,70 @@
55
56
  :onClose="close"
56
57
  />
57
58
  <template v-else-if="items?.length">
58
- <DropdownMenuItem
59
+ <template
59
60
  v-for="(item, index) in items"
60
61
  :key="index"
61
- :actionType="item.actionType"
62
- :text="item.text"
63
- :icon="item.icon"
64
- :size="item.size"
65
- :type="item.type"
66
- :userDisplayName="item.userDisplayName"
67
- :userProfileImg="item.userProfileImg"
68
- :imgUrl="item.imgUrl"
69
- :alt="item.alt"
70
- :helpText="item.helpText"
71
- :to="item.to"
72
- :isExternal="item.isExternal"
73
- :hasSeparator="item.hasSeparator"
74
- :disabled="disabled || item.disabled"
75
- :prefetchOn
76
- @click="handleClick(item.callback)"
77
- />
62
+ >
63
+ <DropdownSectionItem
64
+ v-if="item.sectionTitle"
65
+ :text="item.text"
66
+ :icon="item.icon"
67
+ />
68
+
69
+ <DropdownMenu
70
+ v-else-if="canOpenNested(item)"
71
+ :items="item.children"
72
+ :position="DropdownPosition.RIGHT_TOP"
73
+ :positionXOffset="nestedMenuGap"
74
+ :positionYOffset="0"
75
+ :trigger="trigger"
76
+ :hasShadow
77
+ :hasBorder
78
+ :disabled="disabled || item.disabled"
79
+ :prefetchOn
80
+ :level="level + 1"
81
+ >
82
+ <template #activator>
83
+ <DropdownMenuItem
84
+ :actionType="DropdownActionType.ACTION"
85
+ :text="item.text"
86
+ :icon="item.icon"
87
+ :size="item.size"
88
+ :type="item.type"
89
+ :userDisplayName="item.userDisplayName"
90
+ :userProfileImg="item.userProfileImg"
91
+ :imgUrl="item.imgUrl"
92
+ :alt="item.alt"
93
+ :helpText="item.helpText"
94
+ :hasSeparator="item.hasSeparator"
95
+ :disabled="disabled || item.disabled"
96
+ :hasNestedLevels="true"
97
+ :prefetchOn
98
+ />
99
+ </template>
100
+ </DropdownMenu>
101
+
102
+ <DropdownMenuItem
103
+ v-else
104
+ :actionType="resolveItemActionType(item)"
105
+ :text="item.text"
106
+ :icon="item.icon"
107
+ :size="item.size"
108
+ :type="item.type"
109
+ :userDisplayName="item.userDisplayName"
110
+ :userProfileImg="item.userProfileImg"
111
+ :imgUrl="item.imgUrl"
112
+ :alt="item.alt"
113
+ :helpText="item.helpText"
114
+ :to="item.to"
115
+ :isExternal="item.isExternal"
116
+ :hasSeparator="item.hasSeparator"
117
+ :disabled="disabled || item.disabled"
118
+ :hasNestedLevels="hasNestedItems(item)"
119
+ :prefetchOn
120
+ @click="handleClick(item.callback)"
121
+ />
122
+ </template>
78
123
  </template>
79
124
  </div>
80
125
  </Transition>
@@ -91,6 +136,7 @@
91
136
  <div
92
137
  v-if="isOpen"
93
138
  ref="dropdown"
139
+ data-dropdown-menu-panel
94
140
  v-bind="$attrs"
95
141
  :class="[
96
142
  'bg-background-surface',
@@ -116,25 +162,70 @@
116
162
  :onClose="toggle"
117
163
  />
118
164
  <template v-else-if="items?.length && !$slots['items']">
119
- <DropdownMenuItem
120
- v-for="(item, index) in items" :key="index"
121
- :actionType="item.actionType"
122
- :text="item.text"
123
- :icon="item.icon"
124
- :size="item.size"
125
- :type="item.type"
126
- :userDisplayName="item.userDisplayName"
127
- :userProfileImg="item.userProfileImg"
128
- :imgUrl="item.imgUrl"
129
- :alt="item.alt"
130
- :helpText="item.helpText"
131
- :to="item.to"
132
- :isExternal="item.isExternal"
133
- :hasSeparator="item.hasSeparator"
134
- :disabled="disabled || item.disabled"
135
- :prefetchOn
136
- @click="handleClick(item.callback)"
137
- />
165
+ <template
166
+ v-for="(item, index) in items"
167
+ :key="index"
168
+ >
169
+ <DropdownSectionItem
170
+ v-if="item.sectionTitle"
171
+ :text="item.text"
172
+ :icon="item.icon"
173
+ />
174
+
175
+ <DropdownMenu
176
+ v-else-if="canOpenNested(item)"
177
+ :items="item.children"
178
+ :position="DropdownPosition.RIGHT_TOP"
179
+ :positionXOffset="nestedMenuGap"
180
+ :positionYOffset="0"
181
+ :trigger="trigger"
182
+ :hasShadow
183
+ :hasBorder
184
+ :disabled="disabled || item.disabled"
185
+ :prefetchOn
186
+ :level="level + 1"
187
+ >
188
+ <template #activator>
189
+ <DropdownMenuItem
190
+ :actionType="DropdownActionType.ACTION"
191
+ :text="item.text"
192
+ :icon="item.icon"
193
+ :size="item.size"
194
+ :type="item.type"
195
+ :userDisplayName="item.userDisplayName"
196
+ :userProfileImg="item.userProfileImg"
197
+ :imgUrl="item.imgUrl"
198
+ :alt="item.alt"
199
+ :helpText="item.helpText"
200
+ :hasSeparator="item.hasSeparator"
201
+ :disabled="disabled || item.disabled"
202
+ :hasNestedLevels="true"
203
+ :prefetchOn
204
+ />
205
+ </template>
206
+ </DropdownMenu>
207
+
208
+ <DropdownMenuItem
209
+ v-else
210
+ :actionType="resolveItemActionType(item)"
211
+ :text="item.text"
212
+ :icon="item.icon"
213
+ :size="item.size"
214
+ :type="item.type"
215
+ :userDisplayName="item.userDisplayName"
216
+ :userProfileImg="item.userProfileImg"
217
+ :imgUrl="item.imgUrl"
218
+ :alt="item.alt"
219
+ :helpText="item.helpText"
220
+ :to="item.to"
221
+ :isExternal="item.isExternal"
222
+ :hasSeparator="item.hasSeparator"
223
+ :disabled="disabled || item.disabled"
224
+ :hasNestedLevels="hasNestedItems(item)"
225
+ :prefetchOn
226
+ @click="handleClick(item.callback)"
227
+ />
228
+ </template>
138
229
  </template>
139
230
  </div>
140
231
  </Transition>
@@ -197,6 +288,14 @@ const props = defineProps({
197
288
  default: Trigger.CLICK,
198
289
  validator: (value: Trigger) => Object.values(Trigger).includes(value),
199
290
  },
291
+ level: {
292
+ type: Number as PropType<number>,
293
+ default: 1,
294
+ },
295
+ nestedMenuGap: {
296
+ type: Number as PropType<number>,
297
+ default: 8,
298
+ },
200
299
  disabled: {
201
300
  type: Boolean as PropType<boolean>,
202
301
  default: false,
@@ -224,6 +323,24 @@ const close = () => {
224
323
  isPositioned.value = false
225
324
  }
226
325
 
326
+ const MAX_NESTED_LEVELS = 3
327
+
328
+ const hasNestedItems = (item: DropdownMenuItem) => {
329
+ return Array.isArray(item.children) && item.children.length > 0
330
+ }
331
+
332
+ const canOpenNested = (item: DropdownMenuItem) => {
333
+ return props.level < MAX_NESTED_LEVELS && hasNestedItems(item)
334
+ }
335
+
336
+ const resolveItemActionType = (item: DropdownMenuItem) => {
337
+ // Items with children are always ACTION items (never links)
338
+ if (hasNestedItems(item)) {
339
+ return DropdownActionType.ACTION
340
+ }
341
+ return item.actionType ?? DropdownActionType.ACTION
342
+ }
343
+
227
344
  const clearCloseTimer = () => {
228
345
  if (closeTimer.value !== null) {
229
346
  globalThis.clearTimeout(closeTimer.value)
@@ -317,6 +434,11 @@ const shouldCloseFromTarget = (target: Node) => {
317
434
 
318
435
  if (!dropdownEl || !activatorEl) return false
319
436
 
437
+ const targetElement = target as HTMLElement | null
438
+ if (targetElement?.closest('[data-dropdown-menu-panel]')) {
439
+ return false
440
+ }
441
+
320
442
  return !dropdownEl.contains(target) && !activatorEl.contains(target)
321
443
  }
322
444
 
@@ -53,6 +53,12 @@
53
53
  :imgUrl="userProfileImg"
54
54
  :size="AvatarSize.XS"
55
55
  />
56
+
57
+ <Icon
58
+ v-if="hasNestedLevels"
59
+ name="mdi:chevron-right"
60
+ iconClass="text-icon-neutral-subtle ml-auto"
61
+ />
56
62
  </div>
57
63
  <p
58
64
  v-if="helpText"
@@ -125,6 +131,10 @@ const props = defineProps({
125
131
  type: Boolean as PropType<boolean>,
126
132
  default: false,
127
133
  },
134
+ hasNestedLevels: {
135
+ type: Boolean as PropType<boolean>,
136
+ default: false,
137
+ },
128
138
  prefetchOn: {
129
139
  type: [String, Object] as PropType<PrefetchOnStrategy>,
130
140
  default: PrefetchOn.VISIBILITY,
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <div
3
+ :class="[
4
+ 'flex items-center gap-3 w-full',
5
+ 'px-3 py-2',
6
+ 'text-sm font-semibold text-text-neutral-subtle',
7
+ 'border-b border-border-default',
8
+ 'select-none',
9
+ ]"
10
+ >
11
+ <Icon
12
+ v-if="icon"
13
+ :name="icon"
14
+ iconClass="!text-icon-neutral-subtle"
15
+ />
16
+
17
+ <span>{{ text }}</span>
18
+ </div>
19
+ </template>
20
+ <script setup lang="ts">
21
+ defineProps({
22
+ text: {
23
+ type: String as PropType<string>,
24
+ default: 'Section title',
25
+ },
26
+ icon: String as PropType<string>,
27
+ })
28
+ </script>
@@ -151,31 +151,41 @@
151
151
 
152
152
  <!-- Filtered options -->
153
153
  <div v-if="filteredOptions.length > 0">
154
- <DropdownSelectItem
154
+ <template
155
155
  v-for="(option, index) in filteredOptions"
156
156
  :key="index"
157
- :type="type"
158
- :text="option.text"
159
- :icon="option.icon"
160
- :userDisplayName="option.userDisplayName"
161
- :userProfileImg="option.userProfileImg"
162
- :imgUrl="option.imgUrl"
163
- :alt="option.alt"
164
- :helpText="option.helpText"
165
- :isSelected="isSelected(option)"
166
- :activeStyle="activeStyle"
167
- :to="option.to"
168
- :isExternal="option.isExternal"
169
- :class="[
170
- hasSeparator && index !== filteredOptions.length - 1
171
- ? 'border-b border-border-default'
172
- : undefined
173
- ]"
174
- @click="() => {
175
- handleOptionClick(option)
176
- if (!multiple) onClose()
177
- }"
178
- />
157
+ >
158
+ <DropdownSectionItem
159
+ v-if="option.sectionTitle"
160
+ :text="option.text"
161
+ :icon="option.icon"
162
+ />
163
+
164
+ <DropdownSelectItem
165
+ v-else
166
+ :type="type"
167
+ :text="option.text"
168
+ :icon="option.icon"
169
+ :userDisplayName="option.userDisplayName"
170
+ :userProfileImg="option.userProfileImg"
171
+ :imgUrl="option.imgUrl"
172
+ :alt="option.alt"
173
+ :helpText="option.helpText"
174
+ :isSelected="isSelected(option)"
175
+ :activeStyle="activeStyle"
176
+ :to="option.to"
177
+ :isExternal="option.isExternal"
178
+ :class="[
179
+ hasSeparator && index !== filteredOptions.length - 1
180
+ ? 'border-b border-border-default'
181
+ : undefined
182
+ ]"
183
+ @click="() => {
184
+ handleOptionClick(option)
185
+ if (!multiple) onClose()
186
+ }"
187
+ />
188
+ </template>
179
189
  </div>
180
190
 
181
191
  <!-- No Results Message -->
@@ -55,7 +55,7 @@
55
55
  :isCollapsed
56
56
  :class="[
57
57
  !$slots['sidebar-footer'] && '80% lg:90%',
58
- isCollapsed && '!px-0 items-center',
58
+ isCollapsed && 'px-0! items-center',
59
59
  ]"
60
60
  :style="{
61
61
  height: computedMenuHeight
@@ -72,93 +72,79 @@
72
72
 
73
73
  <slot name="sidebar-menu-prefix-content" />
74
74
 
75
- <template v-for="(item, index) in menuItems" :key="index">
76
- <!-- Section Title -->
77
- <NavSidebarMenuSectionTitle
78
- v-if="item.isSectionTitle"
79
- :text="item.text"
80
- :icon="item.icon"
81
- :styleType="itemsStyleType"
82
- :isCollapsed
83
- :showCollapseDivider
84
- />
85
-
86
- <!-- Collapsed Dropdown using NavSidebarMenuItem as activator -->
87
- <template v-else-if="isCollapsed && item.children">
88
- <DropdownMenu
89
- :key="item.text"
90
- :position="index < props.collapsedFlipLimit ? DropdownPosition.RIGHT_TOP : DropdownPosition.RIGHT_BOTTOM"
91
- :positionXOffset="collapsedSubmenuOffset"
92
- :trigger="collapsedSubmenuTrigger"
93
- :style="{ minWidth: `${collapsedSubmenuWidth}px` }"
94
- >
95
- <!-- Use NavSidebarMenuItem as activator -->
96
- <template #activator>
97
- <NavSidebarMenuItem
98
- :text="item.text"
99
- :icon="item.icon"
100
- :styleType="itemsStyleType"
101
- :textClass="itemsTextClass"
102
- :iconClass="itemsIconClass"
103
- isCollapsed
104
- :showDropdownArrow="false"
105
- :class="itemsCustomClass"
106
- />
107
- </template>
108
-
109
- <template #items>
110
- <DropdownMenuItem
111
- v-for="(child, childIndex) in item.children"
112
- :key="`${item.text}-${childIndex}`"
113
- :text="child.text"
114
- :type="child.icon ? DropdownItemType.ICON : DropdownItemType.TEXT"
115
- :icon="child.icon"
116
- :to="child.to"
117
- />
118
- </template>
119
- </DropdownMenu>
120
- </template>
121
-
122
- <!-- Regular item if not collapsed or no children -->
123
- <template v-else>
124
- <NavSidebarMenuItem
75
+ <template v-if="isCollapsed">
76
+ <template v-for="(item, index) in menuItems" :key="`${item.text}-${index}`">
77
+ <NavSidebarMenuSectionTitle
78
+ v-if="item.isSectionTitle"
125
79
  :text="item.text"
126
80
  :icon="item.icon"
127
- :to="item.to"
128
- :disabled="item.disabled"
129
- :prefetchOn
130
81
  :styleType="itemsStyleType"
131
- :textClass="itemsTextClass"
132
- :iconClass="itemsIconClass"
133
- :showDropdownArrow="!!item.children"
134
- :isOpen="openItems[index]"
135
82
  :isCollapsed
136
- :class="itemsCustomClass"
137
- @click="item.children ? toggleItem(index) : undefined"
83
+ :showCollapseDivider
138
84
  />
139
85
 
140
- <!-- Render nested children only when expanded -->
141
- <template v-if="item.children && openItems[index] && !isCollapsed">
142
- <NavSidebarMenuItem
143
- v-for="(child, childIndex) in item.children"
144
- :key="`${index}-${childIndex}`"
145
- :text="child.text"
146
- :icon="child.icon"
147
- :to="child.to"
148
- :disabled="child.disabled"
86
+ <template v-else-if="hasChildren(item)">
87
+ <DropdownMenu
88
+ :items="getCollapsedDropdownItems(item.children ?? [])"
89
+ :position="index < props.collapsedFlipLimit ? DropdownPosition.RIGHT_TOP : DropdownPosition.RIGHT_BOTTOM"
90
+ :positionXOffset="collapsedSubmenuOffset"
91
+ :trigger="collapsedSubmenuTrigger"
92
+ :style="{ minWidth: `${collapsedSubmenuWidth}px` }"
93
+ :prefetchOn
94
+ >
95
+ <template #activator>
96
+ <NavSidebarMenuItem
97
+ :text="item.text"
98
+ :icon="item.icon"
99
+ :styleType="itemsStyleType"
100
+ :textClass="itemsTextClass"
101
+ :iconClass="itemsIconClass"
102
+ isCollapsed
103
+ :showDropdownArrow="false"
104
+ :class="itemsCustomClass"
105
+ />
106
+ </template>
107
+ </DropdownMenu>
108
+ </template>
109
+
110
+ <template v-else>
111
+ <NavSidebarMenuItem
112
+ :text="item.text"
113
+ :icon="item.icon"
114
+ :to="item.to"
115
+ :disabled="item.disabled"
149
116
  :prefetchOn
150
117
  :styleType="itemsStyleType"
151
- :textClass="subItemsTextClass"
152
- :iconClass="subItemsIconClass"
153
- :class="[
154
- 'ml-4',
155
- subItemsCustomClass ? subItemsCustomClass : '!font-medium',
156
- ]"
118
+ :textClass="itemsTextClass"
119
+ :iconClass="itemsIconClass"
120
+ isCollapsed
121
+ :showDropdownArrow="false"
122
+ :class="itemsCustomClass"
157
123
  />
158
124
  </template>
159
125
  </template>
160
126
  </template>
161
127
 
128
+ <template v-else>
129
+ <NavSidebarMenuItemsTree
130
+ :items="menuItems"
131
+ :isCollapsed
132
+ :openItems
133
+ :itemsStyleType
134
+ :itemsCustomClass
135
+ :itemsTextClass
136
+ :itemsIconClass
137
+ :subItemsCustomClass
138
+ :subItemsTextClass
139
+ :subItemsIconClass
140
+ :thirdLevelItemsCustomClass
141
+ :prefetchOn
142
+ :showCollapseDivider
143
+ :showNestedLevelGuide="showNestedSectionLevelGuide"
144
+ @toggle="toggleItem"
145
+ />
146
+ </template>
147
+
162
148
  <slot name="sidebar-menu-suffix-content" />
163
149
  </NavSidebarMenu>
164
150
 
@@ -216,10 +202,26 @@ const props = defineProps({
216
202
  text: 'Item 3',
217
203
  icon: 'mdi:help',
218
204
  children: [
205
+ {
206
+ isSectionTitle: true,
207
+ text: 'Subsection title',
208
+ icon: 'mdi:shape-outline',
209
+ },
219
210
  {
220
211
  text: 'Subitem 1',
221
212
  icon: 'mdi:help',
222
- to: '/',
213
+ children: [
214
+ {
215
+ isSectionTitle: true,
216
+ text: 'Nested subsection title',
217
+ icon: 'mdi:format-list-bulleted-square',
218
+ },
219
+ {
220
+ text: 'Third level item',
221
+ icon: 'mdi:help-circle-outline',
222
+ to: '/',
223
+ },
224
+ ],
223
225
  },
224
226
  {
225
227
  text: 'Subitem 2',
@@ -324,6 +326,11 @@ const props = defineProps({
324
326
  subItemsCustomClass: String as PropType<string>,
325
327
  subItemsTextClass: String as PropType<string>,
326
328
  subItemsIconClass: String as PropType<string>,
329
+ thirdLevelItemsCustomClass: String as PropType<string>,
330
+ showNestedSectionLevelGuide: {
331
+ type: Boolean as PropType<boolean>,
332
+ default: true,
333
+ },
327
334
  prefetchOn: {
328
335
  type: [String, Object] as PropType<PrefetchOnStrategy>,
329
336
  default: PrefetchOn.VISIBILITY,
@@ -332,7 +339,7 @@ const props = defineProps({
332
339
 
333
340
  // States
334
341
  const isSticky = ref(false)
335
- const openItems = ref<Record<number, boolean>>({})
342
+ const openItems = ref<Record<string, boolean>>({})
336
343
 
337
344
  // Emits
338
345
  const emit = defineEmits<(e: 'update:isCollapsed', value: boolean) => void>()
@@ -349,29 +356,106 @@ const {
349
356
  } = useSidebar()
350
357
  const { isMobile } = useIsMobile()
351
358
 
359
+ const MAX_NESTING_LEVEL = 3
360
+
352
361
  // Methods
362
+ const hasChildren = (item: SidebarMenuItem) => {
363
+ return Array.isArray(item.children) && item.children.length > 0
364
+ }
365
+
366
+ const getNodeKey = (path: number[]) => {
367
+ return path.join('-')
368
+ }
369
+
370
+ const parseNodeKey = (key: string) => {
371
+ return key.split('-').map(Number)
372
+ }
373
+
374
+ const isSameParent = (a: number[], b: number[]) => {
375
+ if (a.length !== b.length) return false
376
+
377
+ const aParent = a.slice(0, -1)
378
+ const bParent = b.slice(0, -1)
379
+
380
+ return aParent.length === bParent.length && aParent.every((value, index) => value === bParent[index])
381
+ }
382
+
383
+ const isPrefixPath = (prefix: number[], path: number[]) => {
384
+ if (prefix.length > path.length) return false
385
+
386
+ return prefix.every((value, index) => value === path[index])
387
+ }
388
+
353
389
  const handleScroll = () => {
354
390
  if(props.stickOnScroll) {
355
391
  isSticky.value = window.scrollY > props.stickyScrollHeight
356
392
  }
357
393
  }
358
394
 
359
- const toggleItem = (index: number) => {
395
+ const toggleItem = (path: number[]) => {
396
+ const nodeKey = getNodeKey(path)
397
+
360
398
  if (props.multipleSubmenusOpen) {
361
- openItems.value[index] = !openItems.value[index]
399
+ openItems.value[nodeKey] = !openItems.value[nodeKey]
362
400
  } else {
363
- const wasOpen = openItems.value[index]
401
+ const wasOpen = openItems.value[nodeKey]
402
+
403
+ const nextOpenItems = Object.fromEntries(
404
+ Object.entries(openItems.value).filter(([key]) => {
405
+ const keyPath = parseNodeKey(key)
406
+
407
+ // Always close the toggled branch descendants and the branch itself.
408
+ if (isPrefixPath(path, keyPath)) {
409
+ return false
410
+ }
364
411
 
365
- // Close all
366
- openItems.value = {}
412
+ // Close siblings at the same level to preserve single-open behavior per level.
413
+ if (isSameParent(keyPath, path)) {
414
+ return false
415
+ }
416
+
417
+ return true
418
+ })
419
+ )
367
420
 
368
421
  // Reopen the one that was toggled if it wasn't already open
369
422
  if (!wasOpen) {
370
- openItems.value[index] = true
423
+ nextOpenItems[nodeKey] = true
371
424
  }
425
+
426
+ openItems.value = nextOpenItems
372
427
  }
373
428
  }
374
429
 
430
+ const getCollapsedDropdownItems = (
431
+ items: SidebarMenuItem[],
432
+ level = 2
433
+ ): CollapsedDropdownItem[] => {
434
+ if (level > MAX_NESTING_LEVEL) {
435
+ return []
436
+ }
437
+
438
+ return items.flatMap((item) => {
439
+ const hasItemChildren = hasChildren(item)
440
+ const dropdownItem: CollapsedDropdownItem = {
441
+ sectionTitle: item.isSectionTitle,
442
+ text: item.text,
443
+ to: item.to,
444
+ icon: item.icon,
445
+ type: item.icon ? DropdownItemType.ICON : DropdownItemType.TEXT,
446
+ disabled: Boolean(item.disabled),
447
+ actionType: item.isSectionTitle
448
+ ? DropdownActionType.ACTION
449
+ : DropdownActionType.LINK,
450
+ children: hasItemChildren && level < MAX_NESTING_LEVEL
451
+ ? getCollapsedDropdownItems(item.children ?? [], level + 1)
452
+ : undefined,
453
+ }
454
+
455
+ return [dropdownItem]
456
+ })
457
+ }
458
+
375
459
  // Computed
376
460
  const computedMenuHeight = computed(() => {
377
461
  if (slots['sidebar-footer']) {
@@ -3,14 +3,14 @@
3
3
  :is="componentTag"
4
4
  v-bind="componentProps"
5
5
  :class="[
6
+ !isCollapsed && 'w-full',
6
7
  'flex',
7
8
  'items-center',
8
- 'text-sm',
9
9
  'text-left',
10
- 'font-semibold',
11
10
  'rounded-lg',
12
11
  'hover:bg-background-neutral-hover',
13
12
  'justify-between',
13
+ levelTextClass,
14
14
  spacingClass,
15
15
  !isActive && 'text-text-default',
16
16
  isActive && 'text-text-primary-brand-on-neutral-hover-bg bg-background-neutral-hover',
@@ -82,6 +82,14 @@ const props = defineProps({
82
82
  type: Boolean as PropType<boolean>,
83
83
  default: false,
84
84
  },
85
+ level: {
86
+ type: Number as PropType<number>,
87
+ default: 1,
88
+ },
89
+ showNestedLevelGuide: {
90
+ type: Boolean as PropType<boolean>,
91
+ default: true,
92
+ },
85
93
  textClass: String as PropType<string>,
86
94
  iconClass: String as PropType<string>,
87
95
  disabled: {
@@ -101,6 +109,20 @@ defineEmits(['click'])
101
109
  const route = useRoute()
102
110
 
103
111
  // Computed classes
112
+ const resolvedLevel = computed(() => {
113
+ return Math.min(Math.max(props.level, 1), 3)
114
+ })
115
+
116
+ const levelTextClass = computed(() => {
117
+ const variant = {
118
+ 1: 'text-sm font-semibold',
119
+ 2: 'text-sm font-medium',
120
+ 3: 'text-xs font-medium',
121
+ }
122
+
123
+ return variant[resolvedLevel.value as 1 | 2 | 3]
124
+ })
125
+
104
126
  const spacingClass = computed(() => {
105
127
  if (props.isCollapsed) {
106
128
  const collapsedVariant = {
@@ -111,11 +133,23 @@ const spacingClass = computed(() => {
111
133
  return collapsedVariant[props.styleType as SidebarNavMenuItemStyleType] || 'p-2'
112
134
  }
113
135
 
114
- const variant = {
115
- [SidebarNavMenuItemStyleType.SPACED]: 'min-h-[40px] py-2 pl-3 pr-2',
116
- [SidebarNavMenuItemStyleType.COMPACT]: 'py-1 pl-2 pr-1',
136
+ const levelVariant = {
137
+ 1: {
138
+ [SidebarNavMenuItemStyleType.SPACED]: 'min-h-[40px] py-2 pl-3 pr-2',
139
+ [SidebarNavMenuItemStyleType.COMPACT]: 'py-1 pl-2 pr-1',
140
+ },
141
+ 2: {
142
+ [SidebarNavMenuItemStyleType.SPACED]: 'min-h-[36px] py-1.5 pl-3 pr-2',
143
+ [SidebarNavMenuItemStyleType.COMPACT]: 'py-1 pl-2 pr-1',
144
+ },
145
+ 3: {
146
+ [SidebarNavMenuItemStyleType.SPACED]: 'min-h-[32px] py-1 pl-3 pr-2',
147
+ [SidebarNavMenuItemStyleType.COMPACT]: 'py-0.5 pl-2 pr-1',
148
+ },
117
149
  }
118
150
 
151
+ const variant = levelVariant[resolvedLevel.value as 1 | 2 | 3]
152
+
119
153
  return variant[props.styleType as SidebarNavMenuItemStyleType] || 'min-h-[40px] py-2 pl-3 pr-2'
120
154
  })
121
155
 
@@ -0,0 +1,202 @@
1
+ <template>
2
+ <div :class="levelWrapperClass">
3
+ <div
4
+ v-if="showGuideLine"
5
+ :class="guideLineClass"
6
+ />
7
+
8
+ <template v-for="(item, index) in items" :key="getNodeKey(getNodePath(index))">
9
+ <NavSidebarMenuSectionTitle
10
+ v-if="item.isSectionTitle"
11
+ :text="item.text"
12
+ :icon="item.icon"
13
+ :styleType="itemsStyleType"
14
+ :isCollapsed
15
+ :level
16
+ :showNestedLevelGuide
17
+ :showCollapseDivider
18
+ :class="[
19
+ level > 1 && 'mt-2',
20
+ ]"
21
+ />
22
+
23
+ <template v-else>
24
+ <NavSidebarMenuItem
25
+ :text="item.text"
26
+ :icon="item.icon"
27
+ :to="item.to"
28
+ :disabled="item.disabled"
29
+ :prefetchOn
30
+ :styleType="itemsStyleType"
31
+ :textClass="getTextClass(level)"
32
+ :iconClass="getIconClass(level)"
33
+ :showDropdownArrow="canRenderChildren(item)"
34
+ :isOpen="isNodeOpen(getNodePath(index))"
35
+ :isCollapsed
36
+ :level
37
+ :showNestedLevelGuide
38
+ :class="[
39
+ getItemCustomClass(level),
40
+ ]"
41
+ @click="handleItemClick(item, getNodePath(index))"
42
+ />
43
+
44
+ <NavSidebarMenuItemsTree
45
+ v-if="canRenderChildren(item) && isNodeOpen(getNodePath(index))"
46
+ :items="item.children ?? []"
47
+ :level="level + 1"
48
+ :isCollapsed
49
+ :openItems
50
+ :itemsStyleType
51
+ :itemsTextClass
52
+ :itemsIconClass
53
+ :subItemsCustomClass
54
+ :subItemsTextClass
55
+ :subItemsIconClass
56
+ :thirdLevelItemsCustomClass
57
+ :prefetchOn
58
+ :showCollapseDivider
59
+ :showNestedLevelGuide
60
+ :pathPrefix="getNodePath(index)"
61
+ @toggle="emit('toggle', $event)"
62
+ />
63
+ </template>
64
+ </template>
65
+ </div>
66
+ </template>
67
+ <script setup lang="ts">
68
+ defineOptions({
69
+ name: 'NavSidebarMenuItemsTree',
70
+ })
71
+
72
+ const props = defineProps({
73
+ items: {
74
+ type: Array as PropType<SidebarMenuItem[]>,
75
+ default: () => [],
76
+ },
77
+ level: {
78
+ type: Number as PropType<number>,
79
+ default: 1,
80
+ },
81
+ isCollapsed: {
82
+ type: Boolean as PropType<boolean>,
83
+ default: false,
84
+ },
85
+ openItems: {
86
+ type: Object as PropType<Record<string, boolean>>,
87
+ default: () => ({}),
88
+ },
89
+ itemsStyleType: {
90
+ type: String as PropType<SidebarNavMenuItemStyleType>,
91
+ default: SidebarNavMenuItemStyleType.COMPACT,
92
+ validator: (value: SidebarNavMenuItemStyleType) => Object.values(SidebarNavMenuItemStyleType).includes(value),
93
+ },
94
+ itemsCustomClass: String as PropType<string>,
95
+ itemsTextClass: String as PropType<string>,
96
+ itemsIconClass: String as PropType<string>,
97
+ subItemsCustomClass: String as PropType<string>,
98
+ subItemsTextClass: String as PropType<string>,
99
+ subItemsIconClass: String as PropType<string>,
100
+ thirdLevelItemsCustomClass: String as PropType<string>,
101
+ prefetchOn: {
102
+ type: [String, Object] as PropType<PrefetchOnStrategy>,
103
+ default: PrefetchOn.VISIBILITY,
104
+ },
105
+ showCollapseDivider: {
106
+ type: Boolean as PropType<boolean>,
107
+ default: false,
108
+ },
109
+ showNestedLevelGuide: {
110
+ type: Boolean as PropType<boolean>,
111
+ default: true,
112
+ },
113
+ pathPrefix: {
114
+ type: Array as PropType<number[]>,
115
+ default: () => [],
116
+ },
117
+ })
118
+
119
+ const emit = defineEmits<{
120
+ (e: 'toggle', path: number[]): void
121
+ }>()
122
+
123
+ const MAX_NESTING_LEVEL = 3
124
+
125
+ const getNodePath = (index: number) => {
126
+ return [...props.pathPrefix, index]
127
+ }
128
+
129
+ const getNodeKey = (path: number[]) => {
130
+ return path.join('-')
131
+ }
132
+
133
+ const hasChildren = (item: SidebarMenuItem) => {
134
+ return Array.isArray(item.children) && item.children.length > 0
135
+ }
136
+
137
+ const canRenderChildren = (item: SidebarMenuItem) => {
138
+ return !props.isCollapsed && !item.isSectionTitle && hasChildren(item) && props.level < MAX_NESTING_LEVEL
139
+ }
140
+
141
+ const isNodeOpen = (path: number[]) => {
142
+ return !!props.openItems[getNodeKey(path)]
143
+ }
144
+
145
+ const showGuideLine = computed(() => {
146
+ return !props.isCollapsed && props.showNestedLevelGuide && props.level > 1
147
+ })
148
+
149
+ const levelWrapperClass = computed(() => {
150
+ if (props.level <= 1) return ''
151
+
152
+ const variant = {
153
+ 2: 'relative ml-4 pl-3',
154
+ 3: 'relative ml-8 pl-3',
155
+ }
156
+
157
+ return variant[props.level as 2 | 3] || 'relative ml-4 pl-3'
158
+ })
159
+
160
+ const guideLineClass = computed(() => {
161
+ const variant = {
162
+ 2: 'pointer-events-none absolute left-0 top-0 bottom-0 w-[2px] bg-border-neutral-subtle/70',
163
+ 3: 'pointer-events-none absolute left-0 top-0 bottom-0 w-[2px] bg-border-neutral-subtle/50',
164
+ }
165
+
166
+ return variant[props.level as 2 | 3] || 'pointer-events-none absolute left-0 top-0 bottom-0 w-[2px] bg-border-neutral-subtle/70'
167
+ })
168
+
169
+ const getItemCustomClass = (level: number) => {
170
+ if (level === 1) {
171
+ return props.itemsCustomClass
172
+ }
173
+
174
+ if (level === 2) {
175
+ return props.subItemsCustomClass ?? '!font-medium'
176
+ }
177
+
178
+ return props.thirdLevelItemsCustomClass ?? props.subItemsCustomClass ?? '!font-medium'
179
+ }
180
+
181
+ const getTextClass = (level: number) => {
182
+ if (level === 1) {
183
+ return props.itemsTextClass
184
+ }
185
+
186
+ return props.subItemsTextClass
187
+ }
188
+
189
+ const getIconClass = (level: number) => {
190
+ if (level === 1) {
191
+ return props.itemsIconClass
192
+ }
193
+
194
+ return props.subItemsIconClass
195
+ }
196
+
197
+ const handleItemClick = (item: SidebarMenuItem, path: number[]) => {
198
+ if (!canRenderChildren(item)) return
199
+
200
+ emit('toggle', path)
201
+ }
202
+ </script>
@@ -1,15 +1,14 @@
1
1
  <template>
2
2
  <div
3
3
  v-if="!isCollapsed"
4
+ v-bind="attrs"
4
5
  :class="[
5
6
  'flex',
6
7
  'items-center',
7
- 'text-sm',
8
- 'font-semibold',
9
- 'text-text-neutral-subtle',
10
- 'border-b',
11
- 'border-border-neutral-subtle',
8
+ textClass,
9
+ borderClass,
12
10
  spacingClass,
11
+ expandedLevelOneBottomMarginClass,
13
12
  ]"
14
13
  >
15
14
  <Icon
@@ -22,9 +21,17 @@
22
21
  {{ text }}
23
22
  </span>
24
23
  </div>
25
- <Divider v-if="isCollapsed && showCollapseDivider" />
24
+
25
+ <Divider
26
+ v-if="isCollapsed && showCollapseDivider"
27
+ v-bind="attrs"
28
+ />
26
29
  </template>
27
30
  <script setup lang="ts">
31
+ defineOptions({
32
+ inheritAttrs: false,
33
+ })
34
+
28
35
  // Props
29
36
  const props = defineProps({
30
37
  text: {
@@ -41,14 +48,63 @@ const props = defineProps({
41
48
  type: Boolean as PropType<boolean>,
42
49
  default: false,
43
50
  },
51
+ level: {
52
+ type: Number as PropType<number>,
53
+ default: 1,
54
+ },
55
+ showNestedLevelGuide: {
56
+ type: Boolean as PropType<boolean>,
57
+ default: true,
58
+ },
44
59
  showCollapseDivider: {
45
60
  type: Boolean as PropType<boolean>,
46
61
  default: true,
47
62
  },
48
63
  })
49
64
 
65
+ const attrs = useAttrs()
66
+
50
67
  // Computed classes
68
+ const resolvedLevel = computed(() => {
69
+ return Math.min(Math.max(props.level, 1), 3)
70
+ })
71
+
72
+ const textClass = computed(() => {
73
+ const variant = {
74
+ 1: 'text-sm font-semibold text-text-neutral-subtle',
75
+ 2: 'text-sm font-bold text-text-neutral-subtle',
76
+ 3: 'text-xs font-bold text-text-neutral-subtle',
77
+ }
78
+
79
+ return variant[resolvedLevel.value as 1 | 2 | 3]
80
+ })
81
+
82
+ const borderClass = computed(() => {
83
+ if (resolvedLevel.value === 1) {
84
+ return 'border-b border-border-neutral-subtle'
85
+ }
86
+
87
+ return ''
88
+ })
89
+
51
90
  const spacingClass = computed(() => {
91
+ if (resolvedLevel.value > 1) {
92
+ const nestedVariant = {
93
+ 2: {
94
+ [SidebarNavMenuItemStyleType.SPACED]: 'min-h-[32px] gap-2 py-2 pl-3 pr-2',
95
+ [SidebarNavMenuItemStyleType.COMPACT]: 'min-h-[28px] gap-2 py-1.5 pl-2 pr-2',
96
+ },
97
+ 3: {
98
+ [SidebarNavMenuItemStyleType.SPACED]: 'min-h-[30px] gap-2 py-1.5 pl-3 pr-2',
99
+ [SidebarNavMenuItemStyleType.COMPACT]: 'min-h-[26px] gap-2 py-1 pl-2 pr-2',
100
+ },
101
+ }
102
+
103
+ const levelVariant = nestedVariant[resolvedLevel.value as 2 | 3]
104
+
105
+ return levelVariant[props.styleType as SidebarNavMenuItemStyleType] || 'min-h-[28px] gap-2 py-1.5 pl-3 pr-2'
106
+ }
107
+
52
108
  const variant = {
53
109
  [SidebarNavMenuItemStyleType.SPACED]: 'min-h-[40px] gap-3 pt-4 pb-2 px-3',
54
110
  [SidebarNavMenuItemStyleType.COMPACT]: 'gap-2 pt-3 pb-1 px-2',
@@ -63,4 +119,17 @@ const iconSizeClass = computed(() => {
63
119
  }
64
120
  return variant[props.styleType as SidebarNavMenuItemStyleType] || 'w-[20px] h-[20px] min-w-[20px] min-h-[20px]'
65
121
  })
122
+
123
+ const expandedLevelOneBottomMarginClass = computed(() => {
124
+ if (props.isCollapsed || resolvedLevel.value !== 1) {
125
+ return ''
126
+ }
127
+
128
+ const variant = {
129
+ [SidebarNavMenuItemStyleType.SPACED]: 'mb-2',
130
+ [SidebarNavMenuItemStyleType.COMPACT]: 'mb-1',
131
+ }
132
+
133
+ return variant[props.styleType as SidebarNavMenuItemStyleType] || 'mb-2'
134
+ })
66
135
  </script>
@@ -1,5 +1,6 @@
1
1
  export interface DropdownMenuItem {
2
2
  actionType?: DropdownActionType
3
+ sectionTitle?: boolean
3
4
  text?: string
4
5
  icon?: string
5
6
  size?: DropdownItemSize
@@ -14,4 +15,21 @@ export interface DropdownMenuItem {
14
15
  hasSeparator?: boolean
15
16
  disabled?: boolean
16
17
  callback?: () => void
18
+ children?: DropdownMenuItem[]
17
19
  }
20
+
21
+ export interface CollapsedDropdownItem {
22
+ sectionTitle?: boolean
23
+ text: string
24
+ to?: string
25
+ icon?: string
26
+ type?: DropdownItemType
27
+ size?: DropdownItemSize
28
+ helpText?: string
29
+ isExternal?: boolean
30
+ disabled?: boolean
31
+ hasSeparator?: boolean
32
+ actionType: DropdownActionType
33
+ children?: CollapsedDropdownItem[]
34
+ }
35
+
@@ -6,6 +6,7 @@ import type { IconPosition } from '../enums/icons'
6
6
  export interface SelectOption {
7
7
  id?: string | number
8
8
  value: string | number
9
+ sectionTitle?: boolean
9
10
  text?: string
10
11
  inputType?: AllowedInputType
11
12
  applicableTypes?: AllowedInputType[]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imaginario27/air-ui-ds",
3
- "version": "1.10.2",
3
+ "version": "1.11.0",
4
4
  "author": "imaginario27",
5
5
  "type": "module",
6
6
  "homepage": "https://air-ui.netlify.app/",