@hina-ui/vue 1.4.0 → 1.6.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 (107) hide show
  1. package/dist/index.js +1222 -599
  2. package/dist/index.js.map +1 -1
  3. package/dist/types/components/app-shell/AppShell.vue.d.ts +1 -0
  4. package/dist/types/components/app-shell/AppShell.vue.d.ts.map +1 -1
  5. package/dist/types/components/combobox/Combobox.vue.d.ts +2 -1
  6. package/dist/types/components/combobox/Combobox.vue.d.ts.map +1 -1
  7. package/dist/types/components/combobox/composables/useCombobox.d.ts +25 -0
  8. package/dist/types/components/combobox/composables/useCombobox.d.ts.map +1 -0
  9. package/dist/types/components/combobox/composables/useOptionLabels.d.ts +3 -0
  10. package/dist/types/components/combobox/composables/useOptionLabels.d.ts.map +1 -0
  11. package/dist/types/components/dialog/Dialog.vue.d.ts +3 -1
  12. package/dist/types/components/dialog/Dialog.vue.d.ts.map +1 -1
  13. package/dist/types/components/drawer/Drawer.vue.d.ts +21 -14
  14. package/dist/types/components/drawer/Drawer.vue.d.ts.map +1 -1
  15. package/dist/types/components/drawer/drawer.variants.d.ts +10 -1
  16. package/dist/types/components/drawer/drawer.variants.d.ts.map +1 -1
  17. package/dist/types/components/dropdown-menu/DropdownMenu.vue.d.ts +4 -1
  18. package/dist/types/components/dropdown-menu/DropdownMenu.vue.d.ts.map +1 -1
  19. package/dist/types/components/hover-card/HoverCard.vue.d.ts +4 -1
  20. package/dist/types/components/hover-card/HoverCard.vue.d.ts.map +1 -1
  21. package/dist/types/components/hover-card/HoverCardAnchor.vue.d.ts +6 -4
  22. package/dist/types/components/hover-card/HoverCardAnchor.vue.d.ts.map +1 -1
  23. package/dist/types/components/hover-card/composables/useHoverCardAnchor.d.ts +9 -4
  24. package/dist/types/components/hover-card/composables/useHoverCardAnchor.d.ts.map +1 -1
  25. package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts +1 -0
  26. package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts.map +1 -1
  27. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts +2 -1
  28. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts.map +1 -1
  29. package/dist/types/components/nav-link/NavLink.vue.d.ts.map +1 -1
  30. package/dist/types/components/nav-link/nav-link.variants.d.ts +1 -18
  31. package/dist/types/components/nav-link/nav-link.variants.d.ts.map +1 -1
  32. package/dist/types/components/popover/Popover.vue.d.ts +4 -1
  33. package/dist/types/components/popover/Popover.vue.d.ts.map +1 -1
  34. package/dist/types/components/sheet/Sheet.vue.d.ts +21 -14
  35. package/dist/types/components/sheet/Sheet.vue.d.ts.map +1 -1
  36. package/dist/types/components/sheet/sheet.variants.d.ts +54 -3
  37. package/dist/types/components/sheet/sheet.variants.d.ts.map +1 -1
  38. package/dist/types/components/sidebar/Sidebar.vue.d.ts +15 -11
  39. package/dist/types/components/sidebar/Sidebar.vue.d.ts.map +1 -1
  40. package/dist/types/components/sidebar/SidebarGroup.vue.d.ts.map +1 -1
  41. package/dist/types/components/sidebar/SidebarLabel.vue.d.ts +20 -0
  42. package/dist/types/components/sidebar/SidebarLabel.vue.d.ts.map +1 -0
  43. package/dist/types/components/sidebar/sidebar.variants.d.ts +46 -0
  44. package/dist/types/components/sidebar/sidebar.variants.d.ts.map +1 -0
  45. package/dist/types/components/tree/Tree.vue.d.ts +37 -0
  46. package/dist/types/components/tree/Tree.vue.d.ts.map +1 -0
  47. package/dist/types/components/tree/TreeCheck.vue.d.ts +8 -0
  48. package/dist/types/components/tree/TreeCheck.vue.d.ts.map +1 -0
  49. package/dist/types/components/tree/composables/useTree.d.ts +23 -0
  50. package/dist/types/components/tree/composables/useTree.d.ts.map +1 -0
  51. package/dist/types/components/tree/tree.variants.d.ts +5 -0
  52. package/dist/types/components/tree/tree.variants.d.ts.map +1 -0
  53. package/dist/types/components/tree/types.d.ts +18 -0
  54. package/dist/types/components/tree/types.d.ts.map +1 -0
  55. package/dist/types/components/tree/utils/selection.d.ts +16 -0
  56. package/dist/types/components/tree/utils/selection.d.ts.map +1 -0
  57. package/dist/types/index.d.ts +4 -0
  58. package/dist/types/index.d.ts.map +1 -1
  59. package/dist/types/lib/anchored-overlay.d.ts +12 -2
  60. package/dist/types/lib/anchored-overlay.d.ts.map +1 -1
  61. package/dist/types/lib/overlay-anchor.d.ts +16 -0
  62. package/dist/types/lib/overlay-anchor.d.ts.map +1 -0
  63. package/dist/types/lib/scroll-viewport.d.ts +9 -0
  64. package/dist/types/lib/scroll-viewport.d.ts.map +1 -0
  65. package/dist/types/locale/en-US.d.ts.map +1 -1
  66. package/dist/types/locale/index.d.ts.map +1 -1
  67. package/dist/types/locale/types.d.ts +3 -0
  68. package/dist/types/locale/types.d.ts.map +1 -1
  69. package/dist/types/locale/zh-CN.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/components/app-shell/AppShell.vue +2 -1
  72. package/src/components/combobox/Combobox.vue +14 -23
  73. package/src/components/combobox/composables/useCombobox.ts +63 -0
  74. package/src/components/combobox/composables/useOptionLabels.ts +20 -0
  75. package/src/components/dialog/Dialog.vue +5 -1
  76. package/src/components/drawer/Drawer.vue +69 -32
  77. package/src/components/drawer/drawer.variants.ts +6 -2
  78. package/src/components/dropdown-menu/DropdownMenu.vue +18 -5
  79. package/src/components/hover-card/HoverCard.vue +6 -1
  80. package/src/components/hover-card/HoverCardAnchor.vue +3 -1
  81. package/src/components/hover-card/composables/useHoverCardAnchor.ts +33 -42
  82. package/src/components/multi-combobox/MultiCombobox.vue +2 -0
  83. package/src/components/multi-combobox/composables/useMultiCombobox.ts +5 -10
  84. package/src/components/nav-link/NavLink.vue +7 -1
  85. package/src/components/nav-link/nav-link.variants.ts +1 -10
  86. package/src/components/popover/Popover.vue +17 -7
  87. package/src/components/sheet/Sheet.vue +67 -24
  88. package/src/components/sheet/sheet.variants.ts +17 -4
  89. package/src/components/sidebar/Sidebar.vue +43 -33
  90. package/src/components/sidebar/SidebarGroup.vue +12 -11
  91. package/src/components/sidebar/SidebarLabel.vue +24 -0
  92. package/src/components/sidebar/sidebar.variants.ts +36 -0
  93. package/src/components/tree/Tree.vue +132 -0
  94. package/src/components/tree/TreeCheck.vue +33 -0
  95. package/src/components/tree/composables/useTree.ts +81 -0
  96. package/src/components/tree/tree.variants.ts +22 -0
  97. package/src/components/tree/types.ts +20 -0
  98. package/src/components/tree/utils/selection.ts +78 -0
  99. package/src/index.ts +4 -0
  100. package/src/lib/anchored-overlay.ts +28 -19
  101. package/src/lib/overlay-anchor.ts +65 -0
  102. package/src/lib/scroll-viewport.ts +8 -0
  103. package/src/locale/en-US.ts +1 -0
  104. package/src/locale/index.ts +1 -0
  105. package/src/locale/types.ts +3 -0
  106. package/src/locale/zh-CN.ts +1 -0
  107. package/src/styles/motion.css +40 -0
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, shallowRef } from 'vue'
2
+ import { computed } from 'vue'
3
3
  import {
4
4
  DialogClose,
5
5
  DialogContent,
@@ -10,8 +10,9 @@
10
10
  DialogTitle,
11
11
  DialogTrigger,
12
12
  } from 'reka-ui'
13
+ import { useOverlayPortal } from '../../lib/overlay-portal'
14
+ import { useScrollViewport } from '../../lib/scroll-viewport'
13
15
  import { cn } from '../../lib/cn'
14
- import { useUiLocale } from '../../locale'
15
16
  import Card from '../card/Card.vue'
16
17
  import CloseButton from '../close-button/CloseButton.vue'
17
18
  import Heading from '../heading/Heading.vue'
@@ -26,16 +27,29 @@
26
27
  defineProps<{
27
28
  title: string
28
29
  description?: string
30
+ header?: boolean
31
+ closable?: boolean
29
32
  handle?: boolean
30
33
  locked?: boolean
31
34
  class?: string
32
35
  }>(),
33
- { handle: true, locked: false },
36
+ { header: true, closable: true, handle: true, locked: false },
34
37
  )
35
38
 
39
+ const slots = defineSlots<{
40
+ default?(): unknown
41
+ icon?(): unknown
42
+ title?(): unknown
43
+ body?(props: { close: () => void }): unknown
44
+ content?(props: { close: () => void }): unknown
45
+ footer?(props: { close: () => void }): unknown
46
+ }>()
47
+
48
+ const { scrollArea, viewport } = useScrollViewport()
49
+ defineExpose({ viewport })
50
+
36
51
  const open = defineModel<boolean>('open')
37
- const t = useUiLocale()
38
- const panel = shallowRef<{ $el: HTMLElement } | null>(null)
52
+ const { content: panel, present } = useOverlayPortal(open)
39
53
 
40
54
  const { dragging, offset, onPointerDown } = useDragToDismiss(() => panel.value?.$el ?? null, {
41
55
  enabled: () => !props.locked,
@@ -63,54 +77,83 @@
63
77
  <DialogTrigger v-if="$slots.default" as-child>
64
78
  <slot />
65
79
  </DialogTrigger>
66
- <DialogPortal>
80
+ <DialogPortal v-if="present">
67
81
  <DialogOverlay class="hn-scrim" />
68
82
  <DialogContent as-child @escape-key-down="guard" @interact-outside="guard">
69
83
  <Card
70
84
  ref="panel"
85
+ v-bind="props.description ? {} : { 'aria-describedby': undefined }"
71
86
  data-hn-sheet
72
87
  :data-dragging="dragging ? '' : undefined"
73
88
  :padded="false"
74
89
  :style="style"
75
- :class="cn(sheetPanel(), props.class)"
90
+ :class="
91
+ cn(sheetPanel({ grip: props.header || props.handle, padded: !slots.body }), props.class)
92
+ "
76
93
  >
94
+ <template v-if="!props.header || slots.body">
95
+ <DialogTitle as-child>
96
+ <Heading :level="2" class="sr-only">{{ props.title }}</Heading>
97
+ </DialogTitle>
98
+ <DialogDescription v-if="props.description" class="sr-only">
99
+ {{ props.description }}
100
+ </DialogDescription>
101
+ </template>
77
102
  <div
103
+ v-if="props.handle || (props.header && !slots.body)"
78
104
  data-hn-sheet-grip
79
105
  :data-dragging="dragging ? '' : undefined"
80
- :class="sheetGrip()"
106
+ :class="sheetGrip({ standalone: !!slots.body })"
81
107
  @pointerdown="onPointerDown"
82
108
  >
83
109
  <div
84
110
  v-if="props.handle"
85
111
  aria-hidden="true"
86
112
  :data-disabled="props.locked ? '' : undefined"
87
- :class="sheetHandle()"
113
+ :class="sheetHandle({ header: props.header && !slots.body })"
88
114
  />
89
- <div class="flex items-start justify-between gap-4 px-(--hn-panel-p)">
115
+ <div
116
+ v-if="props.header && !slots.body"
117
+ class="flex items-start justify-between gap-4 px-(--hn-panel-p)"
118
+ >
90
119
  <div class="flex min-w-0 flex-col gap-1.5">
91
- <DialogTitle as-child>
92
- <Heading :level="2" size="lg">{{ props.title }}</Heading>
93
- </DialogTitle>
120
+ <div class="flex min-w-0 items-center gap-2">
121
+ <span
122
+ v-if="slots.icon"
123
+ class="text-muted flex shrink-0 [&_svg]:size-5"
124
+ aria-hidden="true"
125
+ >
126
+ <slot name="icon" />
127
+ </span>
128
+ <DialogTitle as-child>
129
+ <Heading :level="2" size="lg" class="min-w-0">
130
+ <slot name="title">{{ props.title }}</slot>
131
+ </Heading>
132
+ </DialogTitle>
133
+ </div>
94
134
  <DialogDescription v-if="props.description" as-child>
95
135
  <Text tone="muted">{{ props.description }}</Text>
96
136
  </DialogDescription>
97
137
  </div>
98
- <DialogClose v-if="!props.handle" as-child>
138
+ <DialogClose v-if="!props.handle && props.closable" as-child>
99
139
  <CloseButton :disabled="props.locked" class="-mt-1.5 -me-1.5 shrink-0" />
100
140
  </DialogClose>
101
141
  </div>
102
142
  </div>
103
- <ScrollArea v-if="$slots.content" class="min-h-0 grow">
104
- <div class="px-(--hn-panel-p) py-1">
105
- <slot name="content" :close="close" />
143
+ <slot v-if="slots.body" name="body" :close="close" />
144
+ <template v-else>
145
+ <ScrollArea v-if="$slots.content" ref="scrollArea" class="min-h-0 grow">
146
+ <div class="px-(--hn-panel-p) py-1">
147
+ <slot name="content" :close="close" />
148
+ </div>
149
+ </ScrollArea>
150
+ <div
151
+ v-if="$slots.footer"
152
+ class="flex shrink-0 justify-end gap-(--hn-inline-gap) px-(--hn-panel-p)"
153
+ >
154
+ <slot name="footer" :close="close" />
106
155
  </div>
107
- </ScrollArea>
108
- <div
109
- v-if="$slots.footer"
110
- class="flex shrink-0 justify-end gap-(--hn-inline-gap) px-(--hn-panel-p)"
111
- >
112
- <slot name="footer" :close="close" />
113
- </div>
156
+ </template>
114
157
  </Card>
115
158
  </DialogContent>
116
159
  </DialogPortal>
@@ -3,17 +3,30 @@ import { tv } from '../../lib/tv'
3
3
  export const sheetPanel = tv({
4
4
  base: [
5
5
  'hn-anim-sheet-bottom hn-transition pointer-events-auto fixed inset-x-0 bottom-0 z-(--hn-z-overlay)',
6
- 'mx-auto flex max-h-[calc(100dvh-3rem)] w-full flex-col gap-4 sm:max-w-xl',
7
- 'rounded-t-xl rounded-b-none border-b-0 pt-2 shadow-lg outline-none',
8
- 'pb-[max(var(--hn-panel-p),env(safe-area-inset-bottom))]',
6
+ 'mx-auto flex max-h-[calc(100dvh-3rem)] w-full flex-col sm:max-w-xl',
7
+ 'rounded-t-xl rounded-b-none border-b-0 shadow-lg outline-none',
9
8
  'data-dragging:transition-none',
10
9
  ],
10
+ variants: {
11
+ padded: { true: 'gap-4 pb-[max(var(--hn-panel-p),env(safe-area-inset-bottom))]' },
12
+ grip: { true: '', false: '' },
13
+ },
14
+ compoundVariants: [
15
+ { padded: true, grip: true, class: 'pt-2' },
16
+ { padded: true, grip: false, class: 'pt-(--hn-panel-p)' },
17
+ ],
18
+ defaultVariants: { grip: true, padded: true },
11
19
  })
12
20
 
13
21
  export const sheetGrip = tv({
14
22
  base: 'flex shrink-0 cursor-grab touch-none flex-col select-none data-dragging:cursor-grabbing',
23
+ variants: { standalone: { true: 'py-2' } },
15
24
  })
16
25
 
17
26
  export const sheetHandle = tv({
18
- base: 'bg-line-strong hn-transition-base mx-auto mb-3 h-1.5 w-10 rounded-full data-disabled:opacity-50',
27
+ base: 'bg-line-strong hn-transition-base mx-auto h-1.5 w-10 rounded-full data-disabled:opacity-50',
28
+ variants: {
29
+ header: { true: 'mb-3', false: '' },
30
+ },
31
+ defaultVariants: { header: true },
19
32
  })
@@ -1,11 +1,17 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
- import { Motion } from 'motion-v'
4
3
  import { cn } from '../../lib/cn'
5
- import { TRANSITION, prefersReducedMotion } from '../../motion'
6
4
  import { useUiLocale } from '../../locale'
7
5
  import ScrollArea from '../scroll-area/ScrollArea.vue'
8
- import { useSidebar } from './context'
6
+ import { useSidebar, type SidebarState } from './context'
7
+ import SidebarLabel from './SidebarLabel.vue'
8
+ import {
9
+ sidebarRoot,
10
+ sidebarRegion,
11
+ sidebarBrand,
12
+ sidebarIcon,
13
+ sidebarWordmark,
14
+ } from './sidebar.variants'
9
15
 
10
16
  defineOptions({ name: 'HnSidebar' })
11
17
 
@@ -14,54 +20,58 @@
14
20
  class?: string
15
21
  }>()
16
22
 
23
+ defineSlots<{
24
+ default?(): unknown
25
+ header?(props: { state: SidebarState }): unknown
26
+ icon?(props: { state: SidebarState }): unknown
27
+ wordmark?(props: { state: SidebarState }): unknown
28
+ footer?(props: { state: SidebarState }): unknown
29
+ }>()
30
+
17
31
  const t = useUiLocale()
18
32
  const sidebar = useSidebar()
19
33
  const state = computed(() => sidebar?.state.value ?? 'expanded')
20
34
  const inDrawer = computed(() => sidebar?.inDrawer ?? false)
21
-
22
- const width = computed(() => {
23
- if (state.value === 'rail') return 56
24
- if (state.value === 'hidden') return 0
25
- return 256
26
- })
27
-
28
- const transition = computed(() => (prefersReducedMotion() ? { duration: 0 } : TRANSITION.layout))
29
35
  </script>
30
36
 
31
37
  <template>
32
- <Motion
33
- as="aside"
34
- :initial="false"
35
- :animate="inDrawer ? undefined : { width }"
36
- :transition="transition"
38
+ <aside
37
39
  :data-state="state"
38
- :class="
39
- cn(
40
- 'flex h-full min-h-0 shrink-0 flex-col overflow-hidden',
41
- inDrawer
42
- ? 'w-full'
43
- : 'border-line border-e [transition:border-color_var(--hn-duration-base)_var(--hn-ease-move)]',
44
- !inDrawer && state === 'hidden' && 'border-e-transparent',
45
- props.class,
46
- )
47
- "
40
+ :inert="state === 'hidden'"
41
+ :class="cn(sidebarRoot({ inDrawer }), props.class)"
48
42
  >
49
- <div v-if="$slots.header" :class="cn('shrink-0', !inDrawer && 'px-3 py-3')">
43
+ <div v-if="$slots.header" :class="sidebarRegion({ inDrawer })">
50
44
  <slot name="header" :state="state" />
51
45
  </div>
46
+ <div
47
+ v-else-if="$slots.icon || $slots.wordmark"
48
+ class="hn-collapse shrink-0"
49
+ :data-state="state === 'rail' && !$slots.icon ? 'closed' : 'open'"
50
+ :inert="state === 'rail' && !$slots.icon"
51
+ >
52
+ <div class="hn-collapse-body">
53
+ <div :class="sidebarRegion({ inDrawer })">
54
+ <div :class="sidebarBrand()">
55
+ <div v-if="$slots.icon" :class="sidebarIcon()">
56
+ <slot name="icon" :state="state" />
57
+ </div>
58
+ <SidebarLabel v-if="$slots.wordmark" as="div" :class="sidebarWordmark()">
59
+ <slot name="wordmark" :state="state" />
60
+ </SidebarLabel>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </div>
52
65
  <ScrollArea class="min-h-0 flex-1">
53
66
  <nav
54
67
  :aria-label="props.label ?? t.sidebar.navLabel"
55
- :class="cn('flex flex-col gap-1', !inDrawer && 'px-2.5 py-2')"
68
+ :class="cn('flex flex-col gap-1 py-2', !inDrawer && 'px-2.5')"
56
69
  >
57
70
  <slot />
58
71
  </nav>
59
72
  </ScrollArea>
60
- <div
61
- v-if="$slots.footer"
62
- :class="cn('border-line shrink-0 border-t', !inDrawer && 'px-3 py-3')"
63
- >
73
+ <div v-if="$slots.footer" :class="sidebarRegion({ inDrawer, footer: true })">
64
74
  <slot name="footer" :state="state" />
65
75
  </div>
66
- </Motion>
76
+ </aside>
67
77
  </template>
@@ -31,8 +31,13 @@
31
31
  :class="cn(props.class)"
32
32
  @update:open="v => (rail ? undefined : (open = v))"
33
33
  >
34
- <div class="hn-collapse" :class="rail ? 'hn-collapse-closed' : 'hn-collapse-open'">
35
- <div class="hn-collapse-body">
34
+ <div class="relative h-(--hn-control-h-sm)">
35
+ <div
36
+ class="hn-sidebar-label"
37
+ :data-collapsed="rail ? '' : undefined"
38
+ :aria-hidden="rail ? 'true' : undefined"
39
+ :inert="rail"
40
+ >
36
41
  <CollapsibleTrigger as-child>
37
42
  <Button
38
43
  variant="ghost"
@@ -49,15 +54,11 @@
49
54
  </Button>
50
55
  </CollapsibleTrigger>
51
56
  </div>
52
- </div>
53
- <div
54
- aria-hidden="true"
55
- class="hn-collapse"
56
- :class="rail ? 'hn-collapse-open' : 'hn-collapse-closed'"
57
- >
58
- <div class="hn-collapse-body">
59
- <div class="border-line mx-2 my-1.5 border-t" />
60
- </div>
57
+ <div
58
+ aria-hidden="true"
59
+ class="hn-sidebar-label border-line pointer-events-none absolute inset-x-2 top-1/2 border-t"
60
+ :data-collapsed="rail ? undefined : ''"
61
+ />
61
62
  </div>
62
63
  <CollapsibleContent>
63
64
  <div class="flex flex-col gap-0.5 pt-1">
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { cn } from '../../lib/cn'
4
+ import { useSidebar } from './context'
5
+ import { sidebarLabel } from './sidebar.variants'
6
+
7
+ defineOptions({ name: 'HnSidebarLabel' })
8
+
9
+ const props = withDefaults(defineProps<{ as?: string; class?: string }>(), { as: 'span' })
10
+ const sidebar = useSidebar()
11
+ const collapsed = computed(() => sidebar?.state.value === 'rail')
12
+ </script>
13
+
14
+ <template>
15
+ <component
16
+ :is="props.as"
17
+ :data-collapsed="collapsed ? '' : undefined"
18
+ :aria-hidden="collapsed ? 'true' : undefined"
19
+ :inert="collapsed"
20
+ :class="cn(sidebarLabel(), props.class)"
21
+ >
22
+ <slot />
23
+ </component>
24
+ </template>
@@ -0,0 +1,36 @@
1
+ import { tv } from '../../lib/tv'
2
+
3
+ export const sidebarRoot = tv({
4
+ base: 'flex h-full min-h-0 shrink-0 flex-col overflow-hidden',
5
+ variants: {
6
+ inDrawer: {
7
+ true: 'w-full',
8
+ false:
9
+ 'hn-sidebar border-line w-64 border-e data-[state=rail]:w-14 data-[state=hidden]:w-0 data-[state=hidden]:border-e-transparent',
10
+ },
11
+ },
12
+ })
13
+
14
+ export const sidebarRegion = tv({
15
+ base: 'shrink-0 py-3',
16
+ variants: {
17
+ inDrawer: { true: 'w-full', false: 'w-[calc(16rem-1px)] px-3' },
18
+ footer: { true: 'border-line border-t' },
19
+ },
20
+ })
21
+
22
+ export const sidebarLabel = tv({
23
+ base: 'hn-sidebar-label min-w-0',
24
+ })
25
+
26
+ export const sidebarBrand = tv({
27
+ base: 'flex min-h-8 items-center gap-2',
28
+ })
29
+
30
+ export const sidebarIcon = tv({
31
+ base: 'flex size-8 shrink-0 items-center justify-center overflow-hidden [&>*]:size-full [&_img]:size-full [&_img]:object-contain [&_svg]:size-full',
32
+ })
33
+
34
+ export const sidebarWordmark = tv({
35
+ base: 'flex flex-1 items-center [&_img]:max-h-8 [&_img]:max-w-full [&_img]:object-contain [&_svg]:max-h-8 [&_svg]:max-w-full',
36
+ })
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ import { TreeItem, TreeRoot } from 'reka-ui'
3
+ import { cn } from '../../lib/cn'
4
+ import { useUiLocale } from '../../locale'
5
+ import { useFieldControl } from '../form-field/context'
6
+ import DisclosureIcon from '../disclosure-icon/DisclosureIcon.vue'
7
+ import TreeCheck from './TreeCheck.vue'
8
+ import { useTree } from './composables/useTree'
9
+ import { tree, treeEmpty, treeRow, treeToggle } from './tree.variants'
10
+ import type { TreeNode, TreeNodeSlot, TreeValue } from './types'
11
+
12
+ defineOptions({ name: 'HnTree', inheritAttrs: false })
13
+ const props = withDefaults(
14
+ defineProps<{
15
+ items: TreeNode[]
16
+ multiple?: boolean
17
+ defaultExpanded?: TreeValue[]
18
+ disabled?: boolean
19
+ invalid?: boolean
20
+ class?: string
21
+ }>(),
22
+ { defaultExpanded: () => [] },
23
+ )
24
+ const model = defineModel<TreeValue | TreeValue[] | null>()
25
+ const expandedModel = defineModel<TreeValue[]>('expanded')
26
+ defineSlots<{
27
+ node?(props: TreeNodeSlot): unknown
28
+ trailing?(props: TreeNodeSlot): unknown
29
+ empty?(): unknown
30
+ }>()
31
+ const t = useUiLocale()
32
+ const { id, labelledBy, describedBy, disabled, invalid } = useFieldControl({
33
+ disabled: () => props.disabled,
34
+ invalid: () => props.invalid,
35
+ })
36
+ const { selected, expanded, key, getChildren, state, select, toggle, keepRowClick } = useTree(
37
+ props,
38
+ model,
39
+ expandedModel,
40
+ disabled,
41
+ )
42
+ </script>
43
+
44
+ <template>
45
+ <div
46
+ v-if="props.items.length === 0"
47
+ v-bind="$attrs"
48
+ :id="id"
49
+ role="status"
50
+ :aria-labelledby="labelledBy"
51
+ :aria-describedby="describedBy"
52
+ data-hn-tree
53
+ :class="cn(treeEmpty(), props.class)"
54
+ >
55
+ <slot name="empty">{{ t.tree.empty }}</slot>
56
+ </div>
57
+ <TreeRoot
58
+ v-else
59
+ v-bind="$attrs"
60
+ :id="id"
61
+ v-slot="{ flattenItems }"
62
+ v-model:expanded="expanded"
63
+ :items="props.items"
64
+ :get-key="key"
65
+ :get-children="getChildren"
66
+ :model-value="props.multiple ? selected : selected[0]"
67
+ :multiple="props.multiple"
68
+ :disabled="disabled"
69
+ :aria-labelledby="labelledBy"
70
+ :aria-describedby="describedBy"
71
+ :aria-invalid="invalid || undefined"
72
+ :aria-disabled="disabled || undefined"
73
+ data-hn-tree
74
+ :data-disabled="disabled ? '' : undefined"
75
+ :data-invalid="invalid ? '' : undefined"
76
+ :class="cn(tree(), props.class)"
77
+ >
78
+ <TreeItem
79
+ v-for="item in flattenItems"
80
+ :key="item._id"
81
+ v-slot="{ isExpanded }"
82
+ v-bind="item.bind"
83
+ :disabled="state(item.value).disabled"
84
+ as-child
85
+ @select="select"
86
+ @toggle="keepRowClick"
87
+ >
88
+ <li
89
+ :aria-selected="props.multiple ? undefined : state(item.value).selected"
90
+ :aria-checked="
91
+ props.multiple
92
+ ? state(item.value).indeterminate
93
+ ? 'mixed'
94
+ : state(item.value).selected
95
+ : undefined
96
+ "
97
+ :style="{ '--hn-tree-level': item.level - 1 }"
98
+ :class="treeRow()"
99
+ >
100
+ <span
101
+ v-if="item.hasChildren"
102
+ :class="treeToggle()"
103
+ aria-hidden="true"
104
+ data-hn-tree-toggle
105
+ @click.stop="toggle(item.value)"
106
+ >
107
+ <DisclosureIcon direction="end" :open="isExpanded" />
108
+ </span>
109
+ <span v-else class="size-5 shrink-0" aria-hidden="true" />
110
+ <TreeCheck
111
+ v-if="props.multiple"
112
+ :selected="state(item.value).selected"
113
+ :indeterminate="state(item.value).indeterminate"
114
+ />
115
+ <span class="min-w-0 flex-1">
116
+ <slot name="node" :node="item.value" :expanded="isExpanded" v-bind="state(item.value)">
117
+ <span class="block truncate">{{ item.value.label }}</span>
118
+ <span v-if="item.value.description" class="text-muted block truncate text-xs">
119
+ {{ item.value.description }}
120
+ </span>
121
+ </slot>
122
+ </span>
123
+ <slot
124
+ name="trailing"
125
+ :node="item.value"
126
+ :expanded="isExpanded"
127
+ v-bind="state(item.value)"
128
+ />
129
+ </li>
130
+ </TreeItem>
131
+ </TreeRoot>
132
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import { Check, Minus } from '@lucide/vue'
3
+ import { checkboxBox } from '../checkbox/checkbox.variants'
4
+
5
+ defineOptions({ name: 'HnTreeCheck' })
6
+ const props = defineProps<{ selected: boolean; indeterminate: boolean }>()
7
+ </script>
8
+
9
+ <template>
10
+ <span
11
+ aria-hidden="true"
12
+ :data-state="props.indeterminate ? 'indeterminate' : props.selected ? 'checked' : 'unchecked'"
13
+ :class="checkboxBox()"
14
+ class="pointer-events-none"
15
+ >
16
+ <Transition
17
+ enter-active-class="hn-transition-press"
18
+ enter-from-class="scale-50 opacity-0"
19
+ leave-active-class="hn-transition"
20
+ leave-to-class="scale-50 opacity-0"
21
+ >
22
+ <Check v-if="props.selected" />
23
+ </Transition>
24
+ <Transition
25
+ enter-active-class="hn-transition-press"
26
+ enter-from-class="scale-50 opacity-0"
27
+ leave-active-class="hn-transition"
28
+ leave-to-class="scale-50 opacity-0"
29
+ >
30
+ <Minus v-if="props.indeterminate" />
31
+ </Transition>
32
+ </span>
33
+ </template>
@@ -0,0 +1,81 @@
1
+ import { computed, type Ref } from 'vue'
2
+ import type { TreeNode, TreeNodeState, TreeValue } from '../types'
3
+ import { checkedState, indexTree, toggleChecked } from '../utils/selection'
4
+
5
+ interface Options {
6
+ items: TreeNode[]
7
+ multiple?: boolean
8
+ defaultExpanded: TreeValue[]
9
+ }
10
+
11
+ export function useTree(
12
+ props: Options,
13
+ model: Ref<TreeValue | TreeValue[] | null | undefined>,
14
+ expandedModel: Ref<TreeValue[] | undefined>,
15
+ disabled: Ref<boolean>,
16
+ ) {
17
+ const initialExpanded = [...props.defaultExpanded]
18
+ const index = computed(() => indexTree(props.items))
19
+ const states = computed(() => {
20
+ if (props.multiple)
21
+ return checkedState(index.value, Array.isArray(model.value) ? model.value : [])
22
+ return new Map(
23
+ [...index.value.entries].map(([value, entry]) => [
24
+ value,
25
+ {
26
+ selected: value === model.value,
27
+ indeterminate: false,
28
+ disabled: entry.disabled,
29
+ },
30
+ ]),
31
+ )
32
+ })
33
+ const selected = computed(() =>
34
+ [...index.value.entries.values()]
35
+ .filter(entry => states.value.get(entry.node.value)?.selected)
36
+ .map(entry => entry.node),
37
+ )
38
+ const keys = computed(
39
+ () => new Map([...index.value.entries].map(([value, entry]) => [key(entry.node), value])),
40
+ )
41
+ const expanded = computed({
42
+ get: () => (expandedModel.value ?? initialExpanded).map(value => key({ value })),
43
+ set: value => {
44
+ expandedModel.value = value.flatMap(key =>
45
+ keys.value.has(key) ? [keys.value.get(key)!] : [],
46
+ )
47
+ },
48
+ })
49
+
50
+ function key(node: Pick<TreeNode, 'value'>) {
51
+ return `${typeof node.value}:${node.value}`
52
+ }
53
+ function getChildren(node: TreeNode) {
54
+ return node.children?.length ? node.children : undefined
55
+ }
56
+ function state(node: TreeNode): TreeNodeState {
57
+ const value = states.value.get(node.value)!
58
+ return { ...value, disabled: disabled.value || value.disabled }
59
+ }
60
+ function select(event: CustomEvent<{ value?: TreeNode }>) {
61
+ event.preventDefault()
62
+ const node = event.detail.value
63
+ if (!node || state(node).disabled) return
64
+ model.value = props.multiple
65
+ ? toggleChecked(index.value, Array.isArray(model.value) ? model.value : [], node.value)
66
+ : model.value === node.value
67
+ ? null
68
+ : node.value
69
+ }
70
+ function toggle(node: TreeNode) {
71
+ if (state(node).disabled || !getChildren(node)) return
72
+ const value = key(node)
73
+ expanded.value = expanded.value.includes(value)
74
+ ? expanded.value.filter(key => key !== value)
75
+ : [...expanded.value, value]
76
+ }
77
+ function keepRowClick(event: CustomEvent<{ originalEvent: Event }>) {
78
+ if (event.detail.originalEvent.type === 'click') event.preventDefault()
79
+ }
80
+ return { selected, expanded, key, getChildren, state, select, toggle, keepRowClick }
81
+ }
@@ -0,0 +1,22 @@
1
+ import { tv } from '../../lib/tv'
2
+
3
+ export const tree = tv({
4
+ base: 'flex w-full min-w-0 flex-col outline-none',
5
+ })
6
+
7
+ export const treeRow = tv({
8
+ base: [
9
+ 'hn-interactive hn-state-layer hn-press-none relative isolate flex w-full cursor-pointer',
10
+ 'items-center gap-(--hn-control-gap) rounded-md py-2 pe-2.5 text-start text-sm select-none',
11
+ 'ps-[calc(0.375rem+var(--hn-tree-level)*1.25rem)] text-fg outline-none',
12
+ 'data-disabled:pointer-events-none data-disabled:opacity-50',
13
+ ],
14
+ })
15
+
16
+ export const treeToggle = tv({
17
+ base: 'text-muted flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm rtl:-scale-x-100 [&>span>svg]:size-3.5',
18
+ })
19
+
20
+ export const treeEmpty = tv({
21
+ base: 'text-muted px-2.5 py-2 text-sm',
22
+ })