@hina-ui/vue 1.5.0 → 1.7.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 (88) hide show
  1. package/dist/index.js +945 -420
  2. package/dist/index.js.map +1 -1
  3. package/dist/types/components/anchor/Anchor.vue.d.ts +26 -10
  4. package/dist/types/components/anchor/Anchor.vue.d.ts.map +1 -1
  5. package/dist/types/components/anchor/composables/useAnchor.d.ts +15 -0
  6. package/dist/types/components/anchor/composables/useAnchor.d.ts.map +1 -0
  7. package/dist/types/components/anchor/composables/useScrollSpy.d.ts.map +1 -1
  8. package/dist/types/components/anchor/types.d.ts +9 -0
  9. package/dist/types/components/anchor/types.d.ts.map +1 -0
  10. package/dist/types/components/app-shell/AppShell.vue.d.ts +1 -0
  11. package/dist/types/components/app-shell/AppShell.vue.d.ts.map +1 -1
  12. package/dist/types/components/app-shell/composables/useSidebarScrollUpdates.d.ts +4 -0
  13. package/dist/types/components/app-shell/composables/useSidebarScrollUpdates.d.ts.map +1 -0
  14. package/dist/types/components/combobox/Combobox.vue.d.ts +2 -1
  15. package/dist/types/components/combobox/Combobox.vue.d.ts.map +1 -1
  16. package/dist/types/components/combobox/composables/useCombobox.d.ts +25 -0
  17. package/dist/types/components/combobox/composables/useCombobox.d.ts.map +1 -0
  18. package/dist/types/components/combobox/composables/useOptionLabels.d.ts +3 -0
  19. package/dist/types/components/combobox/composables/useOptionLabels.d.ts.map +1 -0
  20. package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts +1 -0
  21. package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts.map +1 -1
  22. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts +2 -1
  23. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts.map +1 -1
  24. package/dist/types/components/nav-link/NavLink.vue.d.ts.map +1 -1
  25. package/dist/types/components/nav-link/nav-link.variants.d.ts +1 -18
  26. package/dist/types/components/nav-link/nav-link.variants.d.ts.map +1 -1
  27. package/dist/types/components/sidebar/DrawerScope.d.ts +13 -2
  28. package/dist/types/components/sidebar/DrawerScope.d.ts.map +1 -1
  29. package/dist/types/components/sidebar/Sidebar.vue.d.ts +19 -12
  30. package/dist/types/components/sidebar/Sidebar.vue.d.ts.map +1 -1
  31. package/dist/types/components/sidebar/SidebarGroup.vue.d.ts.map +1 -1
  32. package/dist/types/components/sidebar/SidebarLabel.vue.d.ts +20 -0
  33. package/dist/types/components/sidebar/SidebarLabel.vue.d.ts.map +1 -0
  34. package/dist/types/components/sidebar/context.d.ts +1 -0
  35. package/dist/types/components/sidebar/context.d.ts.map +1 -1
  36. package/dist/types/components/sidebar/sidebar.variants.d.ts +46 -0
  37. package/dist/types/components/sidebar/sidebar.variants.d.ts.map +1 -0
  38. package/dist/types/components/tree/Tree.vue.d.ts +37 -0
  39. package/dist/types/components/tree/Tree.vue.d.ts.map +1 -0
  40. package/dist/types/components/tree/TreeCheck.vue.d.ts +8 -0
  41. package/dist/types/components/tree/TreeCheck.vue.d.ts.map +1 -0
  42. package/dist/types/components/tree/composables/useTree.d.ts +23 -0
  43. package/dist/types/components/tree/composables/useTree.d.ts.map +1 -0
  44. package/dist/types/components/tree/tree.variants.d.ts +5 -0
  45. package/dist/types/components/tree/tree.variants.d.ts.map +1 -0
  46. package/dist/types/components/tree/types.d.ts +18 -0
  47. package/dist/types/components/tree/types.d.ts.map +1 -0
  48. package/dist/types/components/tree/utils/selection.d.ts +16 -0
  49. package/dist/types/components/tree/utils/selection.d.ts.map +1 -0
  50. package/dist/types/index.d.ts +4 -1
  51. package/dist/types/index.d.ts.map +1 -1
  52. package/dist/types/locale/en-US.d.ts.map +1 -1
  53. package/dist/types/locale/index.d.ts.map +1 -1
  54. package/dist/types/locale/types.d.ts +3 -0
  55. package/dist/types/locale/types.d.ts.map +1 -1
  56. package/dist/types/locale/zh-CN.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/components/anchor/Anchor.vue +15 -19
  59. package/src/components/anchor/composables/useAnchor.ts +19 -0
  60. package/src/components/anchor/composables/useScrollSpy.ts +6 -1
  61. package/src/components/anchor/types.ts +8 -0
  62. package/src/components/app-shell/AppShell.vue +7 -3
  63. package/src/components/app-shell/composables/useSidebarScrollUpdates.ts +52 -0
  64. package/src/components/combobox/Combobox.vue +14 -23
  65. package/src/components/combobox/composables/useCombobox.ts +63 -0
  66. package/src/components/combobox/composables/useOptionLabels.ts +20 -0
  67. package/src/components/multi-combobox/MultiCombobox.vue +2 -0
  68. package/src/components/multi-combobox/composables/useMultiCombobox.ts +5 -10
  69. package/src/components/nav-link/NavLink.vue +7 -1
  70. package/src/components/nav-link/nav-link.variants.ts +1 -10
  71. package/src/components/sidebar/DrawerScope.ts +12 -4
  72. package/src/components/sidebar/Sidebar.vue +68 -38
  73. package/src/components/sidebar/SidebarGroup.vue +12 -11
  74. package/src/components/sidebar/SidebarLabel.vue +24 -0
  75. package/src/components/sidebar/context.ts +1 -0
  76. package/src/components/sidebar/sidebar.variants.ts +36 -0
  77. package/src/components/tree/Tree.vue +132 -0
  78. package/src/components/tree/TreeCheck.vue +33 -0
  79. package/src/components/tree/composables/useTree.ts +81 -0
  80. package/src/components/tree/tree.variants.ts +22 -0
  81. package/src/components/tree/types.ts +20 -0
  82. package/src/components/tree/utils/selection.ts +78 -0
  83. package/src/index.ts +4 -1
  84. package/src/locale/en-US.ts +1 -0
  85. package/src/locale/index.ts +1 -0
  86. package/src/locale/types.ts +3 -0
  87. package/src/locale/zh-CN.ts +1 -0
  88. package/src/styles/motion.css +40 -0
@@ -1,67 +1,97 @@
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'
5
+ import CloseButton from '../close-button/CloseButton.vue'
7
6
  import ScrollArea from '../scroll-area/ScrollArea.vue'
8
- import { useSidebar } from './context'
7
+ import { useSidebar, type SidebarState } from './context'
8
+ import SidebarLabel from './SidebarLabel.vue'
9
+ import {
10
+ sidebarRoot,
11
+ sidebarRegion,
12
+ sidebarBrand,
13
+ sidebarIcon,
14
+ sidebarWordmark,
15
+ } from './sidebar.variants'
9
16
 
10
17
  defineOptions({ name: 'HnSidebar' })
11
18
 
12
- const props = defineProps<{
13
- label?: string
14
- class?: string
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ label?: string
22
+ closable?: boolean
23
+ class?: string
24
+ }>(),
25
+ { closable: true },
26
+ )
27
+
28
+ defineSlots<{
29
+ default?(): unknown
30
+ header?(props: { state: SidebarState }): unknown
31
+ icon?(props: { state: SidebarState }): unknown
32
+ wordmark?(props: { state: SidebarState }): unknown
33
+ footer?(props: { state: SidebarState }): unknown
15
34
  }>()
16
35
 
17
36
  const t = useUiLocale()
18
37
  const sidebar = useSidebar()
19
38
  const state = computed(() => sidebar?.state.value ?? 'expanded')
20
39
  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
40
  </script>
30
41
 
31
42
  <template>
32
- <Motion
33
- as="aside"
34
- :initial="false"
35
- :animate="inDrawer ? undefined : { width }"
36
- :transition="transition"
43
+ <aside
37
44
  :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
- "
45
+ :inert="state === 'hidden'"
46
+ :class="cn(sidebarRoot({ inDrawer }), props.class)"
47
+ @transitionrun="sidebar?.onTransitionRun"
48
48
  >
49
- <div v-if="$slots.header" :class="cn('shrink-0', !inDrawer && 'px-3 py-3')">
50
- <slot name="header" :state="state" />
49
+ <div v-if="$slots.header" :class="cn(sidebarRegion({ inDrawer }), 'flex items-center gap-2')">
50
+ <div class="min-w-0 flex-1">
51
+ <slot name="header" :state="state" />
52
+ </div>
53
+ <CloseButton v-if="inDrawer && props.closable" class="shrink-0" @click="sidebar?.toggle()" />
54
+ </div>
55
+ <div
56
+ v-else-if="$slots.icon || $slots.wordmark"
57
+ class="hn-collapse shrink-0"
58
+ :data-state="state === 'rail' && !$slots.icon ? 'closed' : 'open'"
59
+ :inert="state === 'rail' && !$slots.icon"
60
+ >
61
+ <div class="hn-collapse-body">
62
+ <div :class="sidebarRegion({ inDrawer })">
63
+ <div :class="sidebarBrand()">
64
+ <div v-if="$slots.icon" :class="sidebarIcon()">
65
+ <slot name="icon" :state="state" />
66
+ </div>
67
+ <SidebarLabel v-if="$slots.wordmark" as="div" :class="sidebarWordmark()">
68
+ <slot name="wordmark" :state="state" />
69
+ </SidebarLabel>
70
+ <CloseButton
71
+ v-if="inDrawer && props.closable"
72
+ class="ms-auto shrink-0"
73
+ @click="sidebar?.toggle()"
74
+ />
75
+ </div>
76
+ </div>
77
+ </div>
51
78
  </div>
52
- <ScrollArea class="min-h-0 flex-1">
79
+ <div
80
+ v-else-if="inDrawer && props.closable"
81
+ :class="cn(sidebarRegion({ inDrawer }), 'flex justify-end')"
82
+ >
83
+ <CloseButton @click="sidebar?.toggle()" />
84
+ </div>
85
+ <ScrollArea :shadow="false" class="min-h-0 flex-1">
53
86
  <nav
54
87
  :aria-label="props.label ?? t.sidebar.navLabel"
55
- :class="cn('flex flex-col gap-1', !inDrawer && 'px-2.5 py-2')"
88
+ :class="cn('flex flex-col gap-1 py-2', !inDrawer && 'px-2.5')"
56
89
  >
57
90
  <slot />
58
91
  </nav>
59
92
  </ScrollArea>
60
- <div
61
- v-if="$slots.footer"
62
- :class="cn('border-line shrink-0 border-t', !inDrawer && 'px-3 py-3')"
63
- >
93
+ <div v-if="$slots.footer" :class="sidebarRegion({ inDrawer, footer: true })">
64
94
  <slot name="footer" :state="state" />
65
95
  </div>
66
- </Motion>
96
+ </aside>
67
97
  </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>
@@ -7,6 +7,7 @@ export interface SidebarContext {
7
7
  toggle: () => void
8
8
  openMobile: () => void
9
9
  inDrawer?: boolean
10
+ onTransitionRun?: (event: TransitionEvent) => void
10
11
  }
11
12
 
12
13
  const SIDEBAR_KEY = Symbol('hn-sidebar') as InjectionKey<SidebarContext>
@@ -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 w-full 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
+ })
@@ -0,0 +1,20 @@
1
+ export type TreeValue = string | number
2
+
3
+ export interface TreeNode {
4
+ value: TreeValue
5
+ label: string
6
+ description?: string
7
+ disabled?: boolean
8
+ children?: TreeNode[]
9
+ }
10
+
11
+ export interface TreeNodeState {
12
+ selected: boolean
13
+ indeterminate: boolean
14
+ disabled: boolean
15
+ }
16
+
17
+ export interface TreeNodeSlot extends TreeNodeState {
18
+ node: TreeNode
19
+ expanded: boolean
20
+ }
@@ -0,0 +1,78 @@
1
+ import type { TreeNode, TreeNodeState, TreeValue } from '../types'
2
+
3
+ interface Entry {
4
+ node: TreeNode
5
+ parent?: TreeValue
6
+ children: TreeValue[]
7
+ disabled: boolean
8
+ }
9
+
10
+ export interface TreeIndex {
11
+ roots: TreeValue[]
12
+ entries: Map<TreeValue, Entry>
13
+ }
14
+
15
+ export function indexTree(nodes: TreeNode[]): TreeIndex {
16
+ const entries = new Map<TreeValue, Entry>()
17
+ function visit(node: TreeNode, parent?: TreeValue, disabled = false) {
18
+ const entry = {
19
+ node,
20
+ parent,
21
+ disabled: disabled || !!node.disabled,
22
+ children: node.children?.map(child => child.value) ?? [],
23
+ }
24
+ entries.set(node.value, entry)
25
+ node.children?.forEach(child => visit(child, node.value, entry.disabled))
26
+ }
27
+ nodes.forEach(node => visit(node))
28
+ return { roots: nodes.map(node => node.value), entries }
29
+ }
30
+
31
+ export function checkedState(tree: TreeIndex, values: TreeValue[]) {
32
+ const checked = new Set(values)
33
+ const states = new Map<TreeValue, TreeNodeState>()
34
+ function visit(value: TreeValue, inherited = false): TreeNodeState {
35
+ const entry = tree.entries.get(value)!
36
+ const own = checked.has(value) || (!entry.disabled && inherited)
37
+ const children = entry.children.map(child => visit(child, !entry.disabled && own))
38
+ const enabled = children.filter(child => !child.disabled)
39
+ const selected =
40
+ entry.disabled || enabled.length === 0 ? own : enabled.every(child => child.selected)
41
+ const indeterminate =
42
+ !entry.disabled && !selected && enabled.some(child => child.selected || child.indeterminate)
43
+ const state = { selected, indeterminate, disabled: entry.disabled }
44
+ states.set(value, state)
45
+ return state
46
+ }
47
+ tree.roots.forEach(value => visit(value))
48
+ return states
49
+ }
50
+
51
+ export function toggleChecked(tree: TreeIndex, values: TreeValue[], value: TreeValue): TreeValue[] {
52
+ const entry = tree.entries.get(value)
53
+ if (!entry || entry.disabled) return values
54
+ const states = checkedState(tree, values)
55
+ const checked = new Set(values.filter(value => !tree.entries.has(value)))
56
+ states.forEach((state, value) => {
57
+ if (state.selected) checked.add(value)
58
+ })
59
+ const selected = !states.get(value)!.selected
60
+ function visit(value: TreeValue) {
61
+ const entry = tree.entries.get(value)!
62
+ if (entry.disabled) return
63
+ if (selected) checked.add(value)
64
+ else checked.delete(value)
65
+ entry.children.forEach(visit)
66
+ }
67
+ visit(value)
68
+ let parent = entry.parent
69
+ while (parent !== undefined) {
70
+ checked.delete(parent)
71
+ parent = tree.entries.get(parent)!.parent
72
+ }
73
+ const next = checkedState(tree, [...checked])
74
+ return [
75
+ ...[...checked].filter(value => !tree.entries.has(value)),
76
+ ...[...tree.entries.keys()].filter(value => next.get(value)!.selected),
77
+ ]
78
+ }
package/src/index.ts CHANGED
@@ -62,6 +62,8 @@ export { default as CommandPalette } from './components/command-palette/CommandP
62
62
  export type { CommandItem, CommandGroup, CommandItems } from './components/command-palette/types'
63
63
  export { default as Listbox } from './components/listbox/Listbox.vue'
64
64
  export type { ListboxVariants } from './components/listbox/listbox.variants'
65
+ export { default as Tree } from './components/tree/Tree.vue'
66
+ export type { TreeNode, TreeNodeSlot, TreeValue } from './components/tree/types'
65
67
  export { default as TreeSelect } from './components/tree-select/TreeSelect.vue'
66
68
  export type { TreeSelectNode } from './components/tree-select/types'
67
69
  export { default as Checkbox } from './components/checkbox/Checkbox.vue'
@@ -197,6 +199,7 @@ export { default as AccordionTrigger } from './components/accordion/AccordionTri
197
199
  export { default as AccordionContent } from './components/accordion/AccordionContent.vue'
198
200
  export { default as NavLink } from './components/nav-link/NavLink.vue'
199
201
  export { default as Sidebar } from './components/sidebar/Sidebar.vue'
202
+ export { default as SidebarLabel } from './components/sidebar/SidebarLabel.vue'
200
203
  export { default as SidebarGroup } from './components/sidebar/SidebarGroup.vue'
201
204
  export { default as AppShell } from './components/app-shell/AppShell.vue'
202
205
  export { default as Page } from './components/page/Page.vue'
@@ -206,7 +209,7 @@ export { default as PageAside } from './components/page/PageAside.vue'
206
209
  export { default as Section } from './components/section/Section.vue'
207
210
  export { default as Highlight } from './components/highlight/Highlight.vue'
208
211
  export { default as Anchor } from './components/anchor/Anchor.vue'
209
- export type { AnchorItem } from './components/anchor/Anchor.vue'
212
+ export type { AnchorItem } from './components/anchor/types'
210
213
  export { default as Breadcrumb } from './components/breadcrumb/Breadcrumb.vue'
211
214
  export { default as BreadcrumbItem } from './components/breadcrumb/BreadcrumbItem.vue'
212
215
  export { default as BreadcrumbSeparator } from './components/breadcrumb/BreadcrumbSeparator.vue'
@@ -105,6 +105,7 @@ export const enUS: UiMessages = {
105
105
  show: 'Show password',
106
106
  hide: 'Hide password',
107
107
  },
108
+ tree: { empty: 'No nodes' },
108
109
  treeSelect: {
109
110
  search: 'Search nodes',
110
111
  },
@@ -36,6 +36,7 @@ function merge(base: UiMessages, patch: PartialUiMessages): UiMessages {
36
36
  chip: { ...base.chip, ...patch.chip },
37
37
  numberInput: { ...base.numberInput, ...patch.numberInput },
38
38
  passwordInput: { ...base.passwordInput, ...patch.passwordInput },
39
+ tree: { ...base.tree, ...patch.tree },
39
40
  treeSelect: { ...base.treeSelect, ...patch.treeSelect },
40
41
  combobox: { ...base.combobox, ...patch.combobox },
41
42
  slider: { ...base.slider, ...patch.slider },
@@ -102,6 +102,9 @@ export interface UiMessages {
102
102
  show: string
103
103
  hide: string
104
104
  }
105
+ tree: {
106
+ empty: string
107
+ }
105
108
  treeSelect: {
106
109
  search: string
107
110
  }