@hina-ui/vue 1.5.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 (68) hide show
  1. package/dist/index.js +831 -390
  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/multi-combobox/MultiCombobox.vue.d.ts +1 -0
  12. package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts.map +1 -1
  13. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts +2 -1
  14. package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts.map +1 -1
  15. package/dist/types/components/nav-link/NavLink.vue.d.ts.map +1 -1
  16. package/dist/types/components/nav-link/nav-link.variants.d.ts +1 -18
  17. package/dist/types/components/nav-link/nav-link.variants.d.ts.map +1 -1
  18. package/dist/types/components/sidebar/Sidebar.vue.d.ts +15 -11
  19. package/dist/types/components/sidebar/Sidebar.vue.d.ts.map +1 -1
  20. package/dist/types/components/sidebar/SidebarGroup.vue.d.ts.map +1 -1
  21. package/dist/types/components/sidebar/SidebarLabel.vue.d.ts +20 -0
  22. package/dist/types/components/sidebar/SidebarLabel.vue.d.ts.map +1 -0
  23. package/dist/types/components/sidebar/sidebar.variants.d.ts +46 -0
  24. package/dist/types/components/sidebar/sidebar.variants.d.ts.map +1 -0
  25. package/dist/types/components/tree/Tree.vue.d.ts +37 -0
  26. package/dist/types/components/tree/Tree.vue.d.ts.map +1 -0
  27. package/dist/types/components/tree/TreeCheck.vue.d.ts +8 -0
  28. package/dist/types/components/tree/TreeCheck.vue.d.ts.map +1 -0
  29. package/dist/types/components/tree/composables/useTree.d.ts +23 -0
  30. package/dist/types/components/tree/composables/useTree.d.ts.map +1 -0
  31. package/dist/types/components/tree/tree.variants.d.ts +5 -0
  32. package/dist/types/components/tree/tree.variants.d.ts.map +1 -0
  33. package/dist/types/components/tree/types.d.ts +18 -0
  34. package/dist/types/components/tree/types.d.ts.map +1 -0
  35. package/dist/types/components/tree/utils/selection.d.ts +16 -0
  36. package/dist/types/components/tree/utils/selection.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +3 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/locale/en-US.d.ts.map +1 -1
  40. package/dist/types/locale/index.d.ts.map +1 -1
  41. package/dist/types/locale/types.d.ts +3 -0
  42. package/dist/types/locale/types.d.ts.map +1 -1
  43. package/dist/types/locale/zh-CN.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/app-shell/AppShell.vue +2 -1
  46. package/src/components/combobox/Combobox.vue +14 -23
  47. package/src/components/combobox/composables/useCombobox.ts +63 -0
  48. package/src/components/combobox/composables/useOptionLabels.ts +20 -0
  49. package/src/components/multi-combobox/MultiCombobox.vue +2 -0
  50. package/src/components/multi-combobox/composables/useMultiCombobox.ts +5 -10
  51. package/src/components/nav-link/NavLink.vue +7 -1
  52. package/src/components/nav-link/nav-link.variants.ts +1 -10
  53. package/src/components/sidebar/Sidebar.vue +43 -33
  54. package/src/components/sidebar/SidebarGroup.vue +12 -11
  55. package/src/components/sidebar/SidebarLabel.vue +24 -0
  56. package/src/components/sidebar/sidebar.variants.ts +36 -0
  57. package/src/components/tree/Tree.vue +132 -0
  58. package/src/components/tree/TreeCheck.vue +33 -0
  59. package/src/components/tree/composables/useTree.ts +81 -0
  60. package/src/components/tree/tree.variants.ts +22 -0
  61. package/src/components/tree/types.ts +20 -0
  62. package/src/components/tree/utils/selection.ts +78 -0
  63. package/src/index.ts +3 -0
  64. package/src/locale/en-US.ts +1 -0
  65. package/src/locale/index.ts +1 -0
  66. package/src/locale/types.ts +3 -0
  67. package/src/locale/zh-CN.ts +1 -0
  68. package/src/styles/motion.css +40 -0
@@ -1,8 +1,7 @@
1
1
  <script setup lang="ts" generic="T extends SelectOption = SelectOption">
2
- import { computed, ref, shallowRef, watch } from 'vue'
2
+ import { computed } from 'vue'
3
3
  import { ComboboxAnchor, ComboboxInput, ComboboxRoot, ComboboxTrigger } from 'reka-ui'
4
4
  import { cn } from '../../lib/cn'
5
- import { focusFieldFrom } from '../../lib/field-focus'
6
5
  import { useUiLocale } from '../../locale'
7
6
  import { X } from '@lucide/vue'
8
7
  import InputAction from '../input/InputAction.vue'
@@ -18,13 +17,15 @@
18
17
  inputIndicator,
19
18
  type InputVariants,
20
19
  } from '../input/input.variants'
21
- import { flattenOptions, type SelectItems, type SelectOption } from '../select/types'
20
+ import type { SelectItems, SelectOption } from '../select/types'
21
+ import { useCombobox } from './composables/useCombobox'
22
22
  import ComboboxList from './ComboboxList.vue'
23
23
 
24
24
  defineOptions({ name: 'HnCombobox', inheritAttrs: false })
25
25
 
26
26
  const props = defineProps<{
27
27
  options: SelectItems<T>
28
+ selectedOption?: T | null
28
29
  placeholder?: string
29
30
  ignoreFilter?: boolean
30
31
  clearable?: boolean
@@ -44,8 +45,6 @@
44
45
 
45
46
  const t = useUiLocale()
46
47
  const group = injectInputGroup()
47
- const keyboard = ref(false)
48
- const input = shallowRef<{ $el: HTMLInputElement } | null>(null)
49
48
 
50
49
  const size = computed(() => (group ? group.size.value : props.size))
51
50
  const {
@@ -61,23 +60,14 @@
61
60
  () => !!props.clearable && model.value != null && model.value !== '' && !disabled.value,
62
61
  )
63
62
 
64
- function displayValue(value: string | number | null | undefined) {
65
- return flattenOptions(props.options).find(option => option.value === value)?.label ?? ''
66
- }
67
-
68
- function clear() {
69
- model.value = null
70
- search.value = ''
71
- emit('clear')
72
- input.value?.$el.focus()
73
- }
74
-
75
- watch(search, value => {
76
- if (value === '' && model.value != null) model.value = null
77
- })
78
-
79
- watch(open, value => {
80
- if (!value) keyboard.value = false
63
+ const { keyboard, input, displayValue, clear, onInput, onHostClick } = useCombobox({
64
+ options: () => props.options,
65
+ selectedOption: () => props.selectedOption,
66
+ model,
67
+ search,
68
+ open,
69
+ disabled: () => disabled.value,
70
+ onClear: () => emit('clear'),
81
71
  })
82
72
  </script>
83
73
 
@@ -100,7 +90,7 @@
100
90
  props.class,
101
91
  )
102
92
  "
103
- @click="focusFieldFrom($event.currentTarget as HTMLElement, $event.target as HTMLElement)"
93
+ @click="onHostClick"
104
94
  @keydown="keyboard = true"
105
95
  >
106
96
  <ComboboxInput
@@ -114,6 +104,7 @@
114
104
  :disabled="disabled"
115
105
  :aria-invalid="invalid || undefined"
116
106
  :class="inputControl({ trailing: true })"
107
+ @input="onInput"
117
108
  />
118
109
  <Transition
119
110
  enter-active-class="hn-transition-base"
@@ -0,0 +1,63 @@
1
+ import { computed, ref, shallowRef, watch, type Ref } from 'vue'
2
+ import { focusFieldFrom } from '../../../lib/field-focus'
3
+ import type { SelectItems, SelectOption } from '../../select/types'
4
+ import { useOptionLabels } from './useOptionLabels'
5
+
6
+ interface ComboboxOptions<T extends SelectOption> {
7
+ options: () => SelectItems<T>
8
+ selectedOption: () => T | null | undefined
9
+ model: Ref<string | number | null | undefined>
10
+ search: Ref<string>
11
+ open: Ref<boolean>
12
+ disabled: () => boolean
13
+ onClear: () => void
14
+ }
15
+
16
+ export function useCombobox<T extends SelectOption>(options: ComboboxOptions<T>) {
17
+ const { model, search, open } = options
18
+ const labels = useOptionLabels(options.options, () => {
19
+ const option = options.selectedOption()
20
+ return option ? [option] : []
21
+ })
22
+ const label = computed(() => displayValue(model.value))
23
+ const editing = ref(false)
24
+ const keyboard = ref(false)
25
+ const input = shallowRef<{ $el: HTMLInputElement } | null>(null)
26
+
27
+ function displayValue(value: string | number | null | undefined) {
28
+ return value == null ? '' : (labels.get(value) ?? '')
29
+ }
30
+
31
+ function clear() {
32
+ model.value = null
33
+ search.value = ''
34
+ options.onClear()
35
+ input.value?.$el.focus()
36
+ }
37
+
38
+ function onInput(event: Event) {
39
+ if (options.disabled()) return
40
+ editing.value = true
41
+ if ((event as InputEvent).isComposing) return
42
+ if ((event.target as HTMLInputElement).value === '' && model.value != null) model.value = null
43
+ }
44
+
45
+ function onHostClick(event: MouseEvent) {
46
+ focusFieldFrom(event.currentTarget as HTMLElement, event.target as HTMLElement)
47
+ }
48
+
49
+ watch(open, value => {
50
+ if (!value) {
51
+ keyboard.value = false
52
+ editing.value = false
53
+ }
54
+ })
55
+
56
+ watch(model, () => (editing.value = false), { flush: 'sync' })
57
+
58
+ watch(label, value => {
59
+ if (!editing.value) search.value = value
60
+ })
61
+
62
+ return { keyboard, input, displayValue, clear, onInput, onHostClick }
63
+ }
@@ -0,0 +1,20 @@
1
+ import { reactive, watch } from 'vue'
2
+ import { flattenOptions, type SelectItems, type SelectOption } from '../../select/types'
3
+
4
+ export function useOptionLabels<T extends SelectOption>(
5
+ options: () => SelectItems<T>,
6
+ selectedOptions: () => T[] | undefined,
7
+ ) {
8
+ const labels = reactive(new Map<string | number, string>())
9
+
10
+ watch(
11
+ [options, selectedOptions],
12
+ ([items, selected]) => {
13
+ for (const option of flattenOptions(items)) labels.set(option.value, option.label)
14
+ for (const option of selected ?? []) labels.set(option.value, option.label)
15
+ },
16
+ { immediate: true, deep: true },
17
+ )
18
+
19
+ return labels
20
+ }
@@ -33,6 +33,7 @@
33
33
  const props = withDefaults(
34
34
  defineProps<{
35
35
  options: SelectItems<T>
36
+ selectedOptions?: T[]
36
37
  placeholder?: string
37
38
  ignoreFilter?: boolean
38
39
  loading?: boolean
@@ -69,6 +70,7 @@
69
70
  const { selected, keyboard, input, remove, clear, onHostClick, onInputKeydown } =
70
71
  useMultiCombobox({
71
72
  options: () => props.options,
73
+ selectedOptions: () => props.selectedOptions,
72
74
  model,
73
75
  open,
74
76
  disabled: () => disabled.value,
@@ -1,9 +1,11 @@
1
- import { computed, reactive, ref, shallowRef, watch, type Ref } from 'vue'
1
+ import { computed, ref, shallowRef, watch, type Ref } from 'vue'
2
2
  import { focusFieldFrom } from '../../../lib/field-focus'
3
- import { flattenOptions, type SelectItems, type SelectOption } from '../../select/types'
3
+ import type { SelectItems, SelectOption } from '../../select/types'
4
+ import { useOptionLabels } from '../../combobox/composables/useOptionLabels'
4
5
 
5
6
  interface MultiComboboxOptions<T extends SelectOption> {
6
7
  options: () => SelectItems<T>
8
+ selectedOptions: () => T[] | undefined
7
9
  model: Ref<Array<string | number>>
8
10
  open: Ref<boolean>
9
11
  disabled: () => boolean
@@ -14,14 +16,7 @@ export function useMultiCombobox<T extends SelectOption>(options: MultiComboboxO
14
16
  const { model, open } = options
15
17
  const keyboard = ref(false)
16
18
  const input = shallowRef<{ $el: HTMLInputElement } | null>(null)
17
- const labels = reactive(new Map<string | number, string>())
18
- watch(
19
- options.options,
20
- options => {
21
- for (const option of flattenOptions(options)) labels.set(option.value, option.label)
22
- },
23
- { immediate: true, deep: true },
24
- )
19
+ const labels = useOptionLabels(options.options, options.selectedOptions)
25
20
 
26
21
  const selected = computed(() =>
27
22
  model.value.map(value => ({ value, label: labels.get(value) ?? String(value) })),
@@ -42,7 +42,13 @@
42
42
  :class="cn(navLink({ active: props.active }), props.class)"
43
43
  >
44
44
  <slot name="icon" />
45
- <span :aria-hidden="rail ? 'true' : undefined" data-hn-label :class="navLinkLabel({ rail })">
45
+ <span
46
+ :aria-hidden="rail ? 'true' : undefined"
47
+ :inert="rail"
48
+ :data-collapsed="rail ? '' : undefined"
49
+ data-hn-label
50
+ :class="navLinkLabel()"
51
+ >
46
52
  <slot />
47
53
  </span>
48
54
  </Primitive>
@@ -17,14 +17,5 @@ export const navLink = tv({
17
17
  })
18
18
 
19
19
  export const navLinkLabel = tv({
20
- base: 'shrink-0 whitespace-nowrap',
21
- variants: {
22
- rail: {
23
- true: 'opacity-0 [transition:opacity_calc(var(--hn-duration-fast)/2)_var(--hn-ease-exit)]',
24
- false: 'opacity-100 [transition:opacity_var(--hn-duration-fast)_var(--hn-ease-enter)]',
25
- },
26
- },
27
- defaultVariants: { rail: false },
20
+ base: 'hn-sidebar-label shrink-0 whitespace-nowrap',
28
21
  })
29
-
30
- export type NavLinkVariants = VariantProps<typeof navLink>
@@ -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>