@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.
- package/dist/index.js +945 -420
- package/dist/index.js.map +1 -1
- package/dist/types/components/anchor/Anchor.vue.d.ts +26 -10
- package/dist/types/components/anchor/Anchor.vue.d.ts.map +1 -1
- package/dist/types/components/anchor/composables/useAnchor.d.ts +15 -0
- package/dist/types/components/anchor/composables/useAnchor.d.ts.map +1 -0
- package/dist/types/components/anchor/composables/useScrollSpy.d.ts.map +1 -1
- package/dist/types/components/anchor/types.d.ts +9 -0
- package/dist/types/components/anchor/types.d.ts.map +1 -0
- package/dist/types/components/app-shell/AppShell.vue.d.ts +1 -0
- package/dist/types/components/app-shell/AppShell.vue.d.ts.map +1 -1
- package/dist/types/components/app-shell/composables/useSidebarScrollUpdates.d.ts +4 -0
- package/dist/types/components/app-shell/composables/useSidebarScrollUpdates.d.ts.map +1 -0
- package/dist/types/components/combobox/Combobox.vue.d.ts +2 -1
- package/dist/types/components/combobox/Combobox.vue.d.ts.map +1 -1
- package/dist/types/components/combobox/composables/useCombobox.d.ts +25 -0
- package/dist/types/components/combobox/composables/useCombobox.d.ts.map +1 -0
- package/dist/types/components/combobox/composables/useOptionLabels.d.ts +3 -0
- package/dist/types/components/combobox/composables/useOptionLabels.d.ts.map +1 -0
- package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts +1 -0
- package/dist/types/components/multi-combobox/MultiCombobox.vue.d.ts.map +1 -1
- package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts +2 -1
- package/dist/types/components/multi-combobox/composables/useMultiCombobox.d.ts.map +1 -1
- package/dist/types/components/nav-link/NavLink.vue.d.ts.map +1 -1
- package/dist/types/components/nav-link/nav-link.variants.d.ts +1 -18
- package/dist/types/components/nav-link/nav-link.variants.d.ts.map +1 -1
- package/dist/types/components/sidebar/DrawerScope.d.ts +13 -2
- package/dist/types/components/sidebar/DrawerScope.d.ts.map +1 -1
- package/dist/types/components/sidebar/Sidebar.vue.d.ts +19 -12
- package/dist/types/components/sidebar/Sidebar.vue.d.ts.map +1 -1
- package/dist/types/components/sidebar/SidebarGroup.vue.d.ts.map +1 -1
- package/dist/types/components/sidebar/SidebarLabel.vue.d.ts +20 -0
- package/dist/types/components/sidebar/SidebarLabel.vue.d.ts.map +1 -0
- package/dist/types/components/sidebar/context.d.ts +1 -0
- package/dist/types/components/sidebar/context.d.ts.map +1 -1
- package/dist/types/components/sidebar/sidebar.variants.d.ts +46 -0
- package/dist/types/components/sidebar/sidebar.variants.d.ts.map +1 -0
- package/dist/types/components/tree/Tree.vue.d.ts +37 -0
- package/dist/types/components/tree/Tree.vue.d.ts.map +1 -0
- package/dist/types/components/tree/TreeCheck.vue.d.ts +8 -0
- package/dist/types/components/tree/TreeCheck.vue.d.ts.map +1 -0
- package/dist/types/components/tree/composables/useTree.d.ts +23 -0
- package/dist/types/components/tree/composables/useTree.d.ts.map +1 -0
- package/dist/types/components/tree/tree.variants.d.ts +5 -0
- package/dist/types/components/tree/tree.variants.d.ts.map +1 -0
- package/dist/types/components/tree/types.d.ts +18 -0
- package/dist/types/components/tree/types.d.ts.map +1 -0
- package/dist/types/components/tree/utils/selection.d.ts +16 -0
- package/dist/types/components/tree/utils/selection.d.ts.map +1 -0
- package/dist/types/index.d.ts +4 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/locale/en-US.d.ts.map +1 -1
- package/dist/types/locale/index.d.ts.map +1 -1
- package/dist/types/locale/types.d.ts +3 -0
- package/dist/types/locale/types.d.ts.map +1 -1
- package/dist/types/locale/zh-CN.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/anchor/Anchor.vue +15 -19
- package/src/components/anchor/composables/useAnchor.ts +19 -0
- package/src/components/anchor/composables/useScrollSpy.ts +6 -1
- package/src/components/anchor/types.ts +8 -0
- package/src/components/app-shell/AppShell.vue +7 -3
- package/src/components/app-shell/composables/useSidebarScrollUpdates.ts +52 -0
- package/src/components/combobox/Combobox.vue +14 -23
- package/src/components/combobox/composables/useCombobox.ts +63 -0
- package/src/components/combobox/composables/useOptionLabels.ts +20 -0
- package/src/components/multi-combobox/MultiCombobox.vue +2 -0
- package/src/components/multi-combobox/composables/useMultiCombobox.ts +5 -10
- package/src/components/nav-link/NavLink.vue +7 -1
- package/src/components/nav-link/nav-link.variants.ts +1 -10
- package/src/components/sidebar/DrawerScope.ts +12 -4
- package/src/components/sidebar/Sidebar.vue +68 -38
- package/src/components/sidebar/SidebarGroup.vue +12 -11
- package/src/components/sidebar/SidebarLabel.vue +24 -0
- package/src/components/sidebar/context.ts +1 -0
- package/src/components/sidebar/sidebar.variants.ts +36 -0
- package/src/components/tree/Tree.vue +132 -0
- package/src/components/tree/TreeCheck.vue +33 -0
- package/src/components/tree/composables/useTree.ts +81 -0
- package/src/components/tree/tree.variants.ts +22 -0
- package/src/components/tree/types.ts +20 -0
- package/src/components/tree/utils/selection.ts +78 -0
- package/src/index.ts +4 -1
- package/src/locale/en-US.ts +1 -0
- package/src/locale/index.ts +1 -0
- package/src/locale/types.ts +3 -0
- package/src/locale/zh-CN.ts +1 -0
- 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 =
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
<
|
|
33
|
-
as="aside"
|
|
34
|
-
:initial="false"
|
|
35
|
-
:animate="inDrawer ? undefined : { width }"
|
|
36
|
-
:transition="transition"
|
|
43
|
+
<aside
|
|
37
44
|
:data-state="state"
|
|
38
|
-
:
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
50
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
-
</
|
|
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="
|
|
35
|
-
<div
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 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/
|
|
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'
|
package/src/locale/en-US.ts
CHANGED
package/src/locale/index.ts
CHANGED
|
@@ -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 },
|