@sabrenski/spire-ui 0.0.1
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/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/index.d.ts +4981 -0
- package/dist/spire-ui.css +1 -0
- package/dist/spire-ui.es.js +18403 -0
- package/dist/spire-ui.umd.js +45 -0
- package/package.json +83 -0
- package/src/components/Accordion/Accordion.test.ts +218 -0
- package/src/components/Accordion/AccordionContent.vue +112 -0
- package/src/components/Accordion/AccordionItem.vue +87 -0
- package/src/components/Accordion/AccordionRoot.vue +111 -0
- package/src/components/Accordion/AccordionTrigger.vue +125 -0
- package/src/components/Accordion/index.ts +11 -0
- package/src/components/Accordion/keys.ts +23 -0
- package/src/components/Avatar/Avatar.test.ts +181 -0
- package/src/components/Avatar/Avatar.vue +150 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.test.ts +141 -0
- package/src/components/Badge/Badge.vue +133 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
- package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
- package/src/components/BadgeContainer/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
- package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
- package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
- package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
- package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
- package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
- package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
- package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
- package/src/components/Breadcrumb/index.ts +13 -0
- package/src/components/Breadcrumb/keys.ts +7 -0
- package/src/components/Button/Button.test.ts +231 -0
- package/src/components/Button/Button.vue +349 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Callout/Callout.test.ts +260 -0
- package/src/components/Callout/Callout.vue +341 -0
- package/src/components/Callout/index.ts +2 -0
- package/src/components/Card/Card.test.ts +565 -0
- package/src/components/Card/Card.vue +209 -0
- package/src/components/Card/CardContent.vue +57 -0
- package/src/components/Card/CardFooter.vue +72 -0
- package/src/components/Card/CardHeader.vue +111 -0
- package/src/components/Card/CardImage.vue +124 -0
- package/src/components/Card/index.ts +14 -0
- package/src/components/Chart/BarChart.vue +208 -0
- package/src/components/Chart/BaseChart.vue +444 -0
- package/src/components/Chart/Chart.test.ts +359 -0
- package/src/components/Chart/DonutChart.vue +283 -0
- package/src/components/Chart/LineChart.vue +211 -0
- package/src/components/Chart/index.ts +20 -0
- package/src/components/Chart/useChartTheme.ts +192 -0
- package/src/components/Checkbox/Checkbox.test.ts +209 -0
- package/src/components/Checkbox/Checkbox.vue +285 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
- package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
- package/src/components/ChoiceChip/index.ts +2 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
- package/src/components/ChoiceChipGroup/index.ts +2 -0
- package/src/components/ColorPicker/ColorArea.vue +159 -0
- package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
- package/src/components/ColorPicker/ColorPicker.vue +339 -0
- package/src/components/ColorPicker/ColorSlider.vue +191 -0
- package/src/components/ColorPicker/index.ts +7 -0
- package/src/components/Combobox/Combobox.test.ts +891 -0
- package/src/components/Combobox/Combobox.vue +934 -0
- package/src/components/Combobox/index.ts +2 -0
- package/src/components/DataTable/DataTable.test.ts +1221 -0
- package/src/components/DataTable/DataTable.vue +1415 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/DatePicker/DatePicker.test.ts +625 -0
- package/src/components/DatePicker/DatePicker.vue +1586 -0
- package/src/components/DatePicker/index.ts +2 -0
- package/src/components/Drawer/Drawer.test.ts +336 -0
- package/src/components/Drawer/Drawer.vue +466 -0
- package/src/components/Drawer/index.ts +2 -0
- package/src/components/Dropdown/Dropdown.test.ts +607 -0
- package/src/components/Dropdown/Dropdown.vue +807 -0
- package/src/components/Dropdown/DropdownItem.vue +227 -0
- package/src/components/Dropdown/DropdownSeparator.vue +14 -0
- package/src/components/Dropdown/DropdownSub.vue +104 -0
- package/src/components/Dropdown/DropdownSubContent.vue +187 -0
- package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
- package/src/components/Dropdown/index.ts +14 -0
- package/src/components/EmptyState/EmptyState.test.ts +180 -0
- package/src/components/EmptyState/EmptyState.vue +137 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/FileUpload/FileUpload.test.ts +1151 -0
- package/src/components/FileUpload/FileUpload.vue +1042 -0
- package/src/components/FileUpload/index.ts +2 -0
- package/src/components/Heading/Heading.test.ts +107 -0
- package/src/components/Heading/Heading.vue +67 -0
- package/src/components/Heading/index.ts +2 -0
- package/src/components/Icon/Icon.test.ts +157 -0
- package/src/components/Icon/Icon.vue +86 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Input/Input.test.ts +273 -0
- package/src/components/Input/Input.vue +388 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Layout/Container.vue +67 -0
- package/src/components/Layout/Grid.vue +159 -0
- package/src/components/Layout/GridItem.vue +154 -0
- package/src/components/Layout/Layout.test.ts +202 -0
- package/src/components/Layout/Stack.vue +128 -0
- package/src/components/Layout/index.ts +9 -0
- package/src/components/Layout/keys.ts +7 -0
- package/src/components/Modal/Modal.test.ts +311 -0
- package/src/components/Modal/Modal.vue +336 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Pagination/Pagination.test.ts +303 -0
- package/src/components/Pagination/Pagination.vue +212 -0
- package/src/components/Pagination/index.ts +3 -0
- package/src/components/Pagination/utils.ts +86 -0
- package/src/components/Popover/Popover.test.ts +285 -0
- package/src/components/Popover/Popover.vue +441 -0
- package/src/components/Popover/index.ts +2 -0
- package/src/components/Progress/Progress.test.ts +361 -0
- package/src/components/Progress/Progress.vue +363 -0
- package/src/components/Progress/index.ts +7 -0
- package/src/components/Radio/Radio.test.ts +216 -0
- package/src/components/Radio/Radio.vue +214 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Rating/Rating.test.ts +319 -0
- package/src/components/Rating/Rating.vue +247 -0
- package/src/components/Rating/index.ts +2 -0
- package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
- package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
- package/src/components/SegmentedControl/index.ts +2 -0
- package/src/components/Select/Select.test.ts +589 -0
- package/src/components/Select/Select.vue +666 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.test.ts +301 -0
- package/src/components/Sidebar/SidebarGroup.vue +103 -0
- package/src/components/Sidebar/SidebarItem.vue +196 -0
- package/src/components/Sidebar/SidebarLayout.vue +42 -0
- package/src/components/Sidebar/SidebarRoot.vue +122 -0
- package/src/components/Sidebar/index.ts +11 -0
- package/src/components/Sidebar/keys.ts +14 -0
- package/src/components/Skeleton/Skeleton.test.ts +130 -0
- package/src/components/Skeleton/Skeleton.vue +104 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.test.ts +416 -0
- package/src/components/Slider/Slider.vue +435 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Slider/utils.ts +91 -0
- package/src/components/Spinner/Spinner.test.ts +79 -0
- package/src/components/Spinner/Spinner.vue +159 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/SpireProvider/SpireProvider.vue +71 -0
- package/src/components/SpireProvider/index.ts +11 -0
- package/src/components/Stepper/Stepper.test.ts +221 -0
- package/src/components/Stepper/StepperContent.vue +51 -0
- package/src/components/Stepper/StepperItem.vue +89 -0
- package/src/components/Stepper/StepperRoot.vue +101 -0
- package/src/components/Stepper/StepperSeparator.vue +52 -0
- package/src/components/Stepper/StepperTrigger.vue +144 -0
- package/src/components/Stepper/index.ts +11 -0
- package/src/components/Stepper/keys.ts +27 -0
- package/src/components/Switch/Switch.test.ts +214 -0
- package/src/components/Switch/Switch.vue +235 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Tabs/Tabs.test.ts +363 -0
- package/src/components/Tabs/Tabs.vue +318 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Text/Text.test.ts +154 -0
- package/src/components/Text/Text.vue +100 -0
- package/src/components/Text/index.ts +2 -0
- package/src/components/Textarea/Textarea.test.ts +432 -0
- package/src/components/Textarea/Textarea.vue +411 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/TimePicker/TimePicker.test.ts +352 -0
- package/src/components/TimePicker/TimePicker.vue +569 -0
- package/src/components/TimePicker/index.ts +2 -0
- package/src/components/Timeline/Timeline.test.ts +193 -0
- package/src/components/Timeline/Timeline.vue +111 -0
- package/src/components/Timeline/TimelineItem.vue +167 -0
- package/src/components/Timeline/index.ts +13 -0
- package/src/components/Timeline/keys.ts +21 -0
- package/src/components/Toast/ToastItem.test.ts +289 -0
- package/src/components/Toast/ToastItem.vue +370 -0
- package/src/components/Toast/ToastProvider.test.ts +158 -0
- package/src/components/Toast/ToastProvider.vue +181 -0
- package/src/components/Toast/index.ts +83 -0
- package/src/components/Toast/toastState.test.ts +165 -0
- package/src/components/Toast/toastState.ts +161 -0
- package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
- package/src/components/ToggleButton/ToggleButton.vue +197 -0
- package/src/components/ToggleButton/index.ts +2 -0
- package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
- package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/Tooltip/Tooltip.test.ts +238 -0
- package/src/components/Tooltip/Tooltip.vue +217 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/components/TreeView/TreeView.test.ts +357 -0
- package/src/components/TreeView/TreeView.vue +251 -0
- package/src/components/TreeView/TreeViewItem.vue +288 -0
- package/src/components/TreeView/index.ts +11 -0
- package/src/components/TreeView/keys.ts +35 -0
- package/src/composables/index.ts +12 -0
- package/src/composables/useClickOutside.ts +36 -0
- package/src/composables/useClipboard.ts +35 -0
- package/src/composables/useEventListener.ts +48 -0
- package/src/composables/useFocusTrap.ts +58 -0
- package/src/composables/useHoverReveal.ts +98 -0
- package/src/composables/useId.ts +10 -0
- package/src/composables/useMagnetic.ts +171 -0
- package/src/composables/useRelativePosition.ts +127 -0
- package/src/composables/useRipple.ts +146 -0
- package/src/composables/useScrollLock.ts +25 -0
- package/src/composables/useSpireConfig.ts +27 -0
- package/src/composables/useStagger.ts +224 -0
- package/src/config/icons.test.ts +115 -0
- package/src/config/icons.ts +170 -0
- package/src/index.ts +361 -0
- package/src/styles/depth.css +129 -0
- package/src/styles/effects.css +169 -0
- package/src/styles/fallback.css +152 -0
- package/src/styles/main.css +25 -0
- package/src/styles/mood.css +211 -0
- package/src/styles/motion.css +159 -0
- package/src/styles/reset.css +97 -0
- package/src/styles/theme.css +708 -0
- package/src/styles/tokens.css +183 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/color.ts +277 -0
- package/src/utils/date.test.ts +522 -0
- package/src/utils/date.ts +380 -0
- package/src/utils/index.ts +23 -0
- package/src/utils/object.test.ts +80 -0
- package/src/utils/object.ts +25 -0
- package/src/utils/string.test.ts +64 -0
- package/src/utils/string.ts +32 -0
- package/src/utils/time.ts +156 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { h, nextTick } from 'vue'
|
|
4
|
+
import TreeView from './TreeView.vue'
|
|
5
|
+
import TreeViewItem from './TreeViewItem.vue'
|
|
6
|
+
import type { TreeNode } from './keys'
|
|
7
|
+
|
|
8
|
+
const sampleData: TreeNode[] = [
|
|
9
|
+
{
|
|
10
|
+
id: 'root-1',
|
|
11
|
+
label: 'Root 1',
|
|
12
|
+
children: [
|
|
13
|
+
{ id: 'child-1-1', label: 'Child 1.1' },
|
|
14
|
+
{
|
|
15
|
+
id: 'child-1-2',
|
|
16
|
+
label: 'Child 1.2',
|
|
17
|
+
children: [
|
|
18
|
+
{ id: 'grandchild-1-2-1', label: 'Grandchild 1.2.1' }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'root-2',
|
|
25
|
+
label: 'Root 2',
|
|
26
|
+
disabled: true
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'root-3',
|
|
30
|
+
label: 'Root 3'
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function createTreeView(props: Record<string, unknown> = {}, data = sampleData) {
|
|
35
|
+
return mount(TreeView, {
|
|
36
|
+
props: {
|
|
37
|
+
data,
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
slots: {
|
|
41
|
+
default: () => data.map(node => h(TreeViewItem, { key: node.id, node }))
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('TreeView', () => {
|
|
47
|
+
describe('Rendering', () => {
|
|
48
|
+
it('renders root nodes', async () => {
|
|
49
|
+
const wrapper = createTreeView()
|
|
50
|
+
await nextTick()
|
|
51
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
52
|
+
expect(items.length).toBeGreaterThanOrEqual(3)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders node labels', async () => {
|
|
56
|
+
const wrapper = createTreeView()
|
|
57
|
+
await nextTick()
|
|
58
|
+
expect(wrapper.text()).toContain('Root 1')
|
|
59
|
+
expect(wrapper.text()).toContain('Root 2')
|
|
60
|
+
expect(wrapper.text()).toContain('Root 3')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders chevron for nodes with children', async () => {
|
|
64
|
+
const wrapper = createTreeView()
|
|
65
|
+
await nextTick()
|
|
66
|
+
const chevrons = wrapper.findAll('.ui-treeview__chevron')
|
|
67
|
+
expect(chevrons.length).toBeGreaterThan(0)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not render chevron for leaf nodes', async () => {
|
|
71
|
+
const wrapper = createTreeView({}, [
|
|
72
|
+
{ id: 'leaf', label: 'Leaf Node' }
|
|
73
|
+
])
|
|
74
|
+
await nextTick()
|
|
75
|
+
expect(wrapper.find('.ui-treeview__chevron').exists()).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('Expansion', () => {
|
|
80
|
+
it('expands node when chevron is clicked', async () => {
|
|
81
|
+
const wrapper = createTreeView()
|
|
82
|
+
await nextTick()
|
|
83
|
+
|
|
84
|
+
const chevron = wrapper.find('.ui-treeview__chevron')
|
|
85
|
+
await chevron.trigger('click')
|
|
86
|
+
|
|
87
|
+
expect(wrapper.emitted('update:expandedIds')).toBeTruthy()
|
|
88
|
+
expect(wrapper.emitted('update:expandedIds')![0][0]).toContain('root-1')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('collapses expanded node when chevron is clicked again', async () => {
|
|
92
|
+
const wrapper = createTreeView({ expandedIds: ['root-1'] })
|
|
93
|
+
await nextTick()
|
|
94
|
+
|
|
95
|
+
const chevron = wrapper.find('.ui-treeview__chevron')
|
|
96
|
+
await chevron.trigger('click')
|
|
97
|
+
|
|
98
|
+
expect(wrapper.emitted('update:expandedIds')![0][0]).not.toContain('root-1')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('shows children when expanded', async () => {
|
|
102
|
+
const wrapper = createTreeView({ expandedIds: ['root-1'] })
|
|
103
|
+
await nextTick()
|
|
104
|
+
|
|
105
|
+
expect(wrapper.text()).toContain('Child 1.1')
|
|
106
|
+
expect(wrapper.text()).toContain('Child 1.2')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('hides children when collapsed', async () => {
|
|
110
|
+
const wrapper = createTreeView({ expandedIds: [] })
|
|
111
|
+
await nextTick()
|
|
112
|
+
|
|
113
|
+
expect(wrapper.text()).not.toContain('Child 1.1')
|
|
114
|
+
expect(wrapper.text()).not.toContain('Child 1.2')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('Selection', () => {
|
|
119
|
+
it('emits update:modelValue when node is clicked', async () => {
|
|
120
|
+
const wrapper = createTreeView()
|
|
121
|
+
await nextTick()
|
|
122
|
+
|
|
123
|
+
const content = wrapper.find('.ui-treeview__content')
|
|
124
|
+
await content.trigger('click')
|
|
125
|
+
|
|
126
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('applies selected class to selected node', async () => {
|
|
130
|
+
const wrapper = createTreeView({ modelValue: 'root-1' })
|
|
131
|
+
await nextTick()
|
|
132
|
+
|
|
133
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
134
|
+
expect(items[0].classes()).toContain('ui-treeview__item--selected')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('supports multi-select mode', async () => {
|
|
138
|
+
const wrapper = createTreeView({ multiSelect: true, modelValue: ['root-1'] })
|
|
139
|
+
await nextTick()
|
|
140
|
+
|
|
141
|
+
const contents = wrapper.findAll('.ui-treeview__content')
|
|
142
|
+
await contents[2].trigger('click')
|
|
143
|
+
|
|
144
|
+
const emitted = wrapper.emitted('update:modelValue')![0][0] as string[]
|
|
145
|
+
expect(emitted).toContain('root-1')
|
|
146
|
+
expect(emitted).toContain('root-3')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('deselects in multi-select mode when clicking selected node', async () => {
|
|
150
|
+
const wrapper = createTreeView({ multiSelect: true, modelValue: ['root-1', 'root-3'] })
|
|
151
|
+
await nextTick()
|
|
152
|
+
|
|
153
|
+
const content = wrapper.find('.ui-treeview__content')
|
|
154
|
+
await content.trigger('click')
|
|
155
|
+
|
|
156
|
+
const emitted = wrapper.emitted('update:modelValue')![0][0] as string[]
|
|
157
|
+
expect(emitted).not.toContain('root-1')
|
|
158
|
+
expect(emitted).toContain('root-3')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('Disabled state', () => {
|
|
163
|
+
it('applies disabled class to disabled nodes', async () => {
|
|
164
|
+
const wrapper = createTreeView()
|
|
165
|
+
await nextTick()
|
|
166
|
+
|
|
167
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
168
|
+
expect(items[1].classes()).toContain('ui-treeview__item--disabled')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('does not select disabled nodes', async () => {
|
|
172
|
+
const wrapper = createTreeView()
|
|
173
|
+
await nextTick()
|
|
174
|
+
|
|
175
|
+
const contents = wrapper.findAll('.ui-treeview__content')
|
|
176
|
+
await contents[1].trigger('click')
|
|
177
|
+
|
|
178
|
+
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('Keyboard navigation', () => {
|
|
183
|
+
it('focuses first node when tree receives focus', async () => {
|
|
184
|
+
const wrapper = createTreeView()
|
|
185
|
+
await nextTick()
|
|
186
|
+
|
|
187
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
188
|
+
await nextTick()
|
|
189
|
+
|
|
190
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
191
|
+
expect(items[0].classes()).toContain('ui-treeview__item--focused')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('moves focus down with ArrowDown', async () => {
|
|
195
|
+
const wrapper = createTreeView()
|
|
196
|
+
await nextTick()
|
|
197
|
+
|
|
198
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
199
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'ArrowDown' })
|
|
200
|
+
await nextTick()
|
|
201
|
+
|
|
202
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
203
|
+
expect(items[1].classes()).toContain('ui-treeview__item--focused')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('moves focus up with ArrowUp', async () => {
|
|
207
|
+
const wrapper = createTreeView()
|
|
208
|
+
await nextTick()
|
|
209
|
+
|
|
210
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
211
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'ArrowDown' })
|
|
212
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'ArrowUp' })
|
|
213
|
+
await nextTick()
|
|
214
|
+
|
|
215
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
216
|
+
expect(items[0].classes()).toContain('ui-treeview__item--focused')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('expands node with ArrowRight', async () => {
|
|
220
|
+
const wrapper = createTreeView()
|
|
221
|
+
await nextTick()
|
|
222
|
+
|
|
223
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
224
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'ArrowRight' })
|
|
225
|
+
await nextTick()
|
|
226
|
+
|
|
227
|
+
expect(wrapper.emitted('update:expandedIds')?.[0][0]).toContain('root-1')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('collapses node with ArrowLeft', async () => {
|
|
231
|
+
const wrapper = createTreeView({ expandedIds: ['root-1'] })
|
|
232
|
+
await nextTick()
|
|
233
|
+
|
|
234
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
235
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'ArrowLeft' })
|
|
236
|
+
await nextTick()
|
|
237
|
+
|
|
238
|
+
expect(wrapper.emitted('update:expandedIds')?.[0][0]).not.toContain('root-1')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('selects node with Enter', async () => {
|
|
242
|
+
const wrapper = createTreeView()
|
|
243
|
+
await nextTick()
|
|
244
|
+
|
|
245
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
246
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'Enter' })
|
|
247
|
+
await nextTick()
|
|
248
|
+
|
|
249
|
+
expect(wrapper.emitted('update:modelValue')?.[0][0]).toBe('root-1')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('selects node with Space', async () => {
|
|
253
|
+
const wrapper = createTreeView()
|
|
254
|
+
await nextTick()
|
|
255
|
+
|
|
256
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
257
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: ' ' })
|
|
258
|
+
await nextTick()
|
|
259
|
+
|
|
260
|
+
expect(wrapper.emitted('update:modelValue')?.[0][0]).toBe('root-1')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('moves focus to first node with Home', async () => {
|
|
264
|
+
const wrapper = createTreeView()
|
|
265
|
+
await nextTick()
|
|
266
|
+
|
|
267
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
268
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'ArrowDown' })
|
|
269
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'ArrowDown' })
|
|
270
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'Home' })
|
|
271
|
+
await nextTick()
|
|
272
|
+
|
|
273
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
274
|
+
expect(items[0].classes()).toContain('ui-treeview__item--focused')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('moves focus to last node with End', async () => {
|
|
278
|
+
const wrapper = createTreeView()
|
|
279
|
+
await nextTick()
|
|
280
|
+
|
|
281
|
+
await wrapper.find('.ui-treeview').trigger('focus')
|
|
282
|
+
await wrapper.find('.ui-treeview').trigger('keydown', { key: 'End' })
|
|
283
|
+
await nextTick()
|
|
284
|
+
|
|
285
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
286
|
+
expect(items[items.length - 1].classes()).toContain('ui-treeview__item--focused')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('Accessibility', () => {
|
|
291
|
+
it('has role="tree" on root', () => {
|
|
292
|
+
const wrapper = createTreeView()
|
|
293
|
+
expect(wrapper.find('.ui-treeview').attributes('role')).toBe('tree')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('has aria-multiselectable when multiSelect is true', () => {
|
|
297
|
+
const wrapper = createTreeView({ multiSelect: true })
|
|
298
|
+
expect(wrapper.find('.ui-treeview').attributes('aria-multiselectable')).toBe('true')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('items have role="treeitem"', async () => {
|
|
302
|
+
const wrapper = createTreeView()
|
|
303
|
+
await nextTick()
|
|
304
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
305
|
+
expect(items[0].attributes('role')).toBe('treeitem')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('items have aria-selected', async () => {
|
|
309
|
+
const wrapper = createTreeView({ modelValue: 'root-1' })
|
|
310
|
+
await nextTick()
|
|
311
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
312
|
+
expect(items[0].attributes('aria-selected')).toBe('true')
|
|
313
|
+
expect(items[1].attributes('aria-selected')).toBe('false')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('items with children have aria-expanded', async () => {
|
|
317
|
+
const wrapper = createTreeView({ expandedIds: ['root-1'] })
|
|
318
|
+
await nextTick()
|
|
319
|
+
const items = wrapper.findAll('.ui-treeview__item')
|
|
320
|
+
expect(items[0].attributes('aria-expanded')).toBe('true')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('children container has role="group"', async () => {
|
|
324
|
+
const wrapper = createTreeView({ expandedIds: ['root-1'] })
|
|
325
|
+
await nextTick()
|
|
326
|
+
expect(wrapper.find('.ui-treeview__children').attributes('role')).toBe('group')
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
describe('Lazy loading', () => {
|
|
331
|
+
it('calls loadChildren when expanding a node without children', async () => {
|
|
332
|
+
const loadChildren = vi.fn().mockResolvedValue([
|
|
333
|
+
{ id: 'lazy-child', label: 'Lazy Child' }
|
|
334
|
+
])
|
|
335
|
+
|
|
336
|
+
const lazyData: TreeNode[] = [
|
|
337
|
+
{ id: 'lazy-root', label: 'Lazy Root', isLeaf: false }
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
const wrapper = mount(TreeView, {
|
|
341
|
+
props: {
|
|
342
|
+
data: lazyData,
|
|
343
|
+
loadChildren
|
|
344
|
+
},
|
|
345
|
+
slots: {
|
|
346
|
+
default: () => lazyData.map(node => h(TreeViewItem, { key: node.id, node }))
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
await nextTick()
|
|
350
|
+
|
|
351
|
+
const chevron = wrapper.find('.ui-treeview__chevron')
|
|
352
|
+
await chevron.trigger('click')
|
|
353
|
+
|
|
354
|
+
expect(loadChildren).toHaveBeenCalledWith(lazyData[0])
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
})
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { provide, ref, computed, watch, toRef } from 'vue'
|
|
3
|
+
import { TreeViewKey, TreeViewItemKey } from './keys'
|
|
4
|
+
import type { TreeNode } from './keys'
|
|
5
|
+
|
|
6
|
+
export interface TreeViewProps {
|
|
7
|
+
/** Hierarchical data */
|
|
8
|
+
data: TreeNode[]
|
|
9
|
+
/** Selected node ID(s) - v-model */
|
|
10
|
+
modelValue?: string | string[]
|
|
11
|
+
/** Expanded node IDs - v-model:expandedIds */
|
|
12
|
+
expandedIds?: string[]
|
|
13
|
+
/** Enable multi-selection */
|
|
14
|
+
multiSelect?: boolean
|
|
15
|
+
/** Lazy load callback */
|
|
16
|
+
loadChildren?: (node: TreeNode) => Promise<TreeNode[]>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<TreeViewProps>(), {
|
|
20
|
+
multiSelect: false,
|
|
21
|
+
expandedIds: () => []
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits<{
|
|
25
|
+
'update:modelValue': [value: string | string[] | undefined]
|
|
26
|
+
'update:expandedIds': [value: string[]]
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const internalSelected = ref<Set<string>>(new Set())
|
|
30
|
+
const internalExpanded = ref<Set<string>>(new Set(props.expandedIds))
|
|
31
|
+
const focusedId = ref<string | null>(null)
|
|
32
|
+
const loadingIds = ref<Set<string>>(new Set())
|
|
33
|
+
|
|
34
|
+
const nodeMap = ref<Map<string, { parentId: string | null; hasChildren: boolean }>>(new Map())
|
|
35
|
+
const childrenMap = ref<Map<string | null, string[]>>(new Map())
|
|
36
|
+
|
|
37
|
+
watch(() => props.modelValue, (value) => {
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
internalSelected.value = new Set()
|
|
40
|
+
} else if (Array.isArray(value)) {
|
|
41
|
+
internalSelected.value = new Set(value)
|
|
42
|
+
} else {
|
|
43
|
+
internalSelected.value = new Set([value])
|
|
44
|
+
}
|
|
45
|
+
}, { immediate: true })
|
|
46
|
+
|
|
47
|
+
watch(() => props.expandedIds, (value) => {
|
|
48
|
+
internalExpanded.value = new Set(value)
|
|
49
|
+
}, { immediate: true })
|
|
50
|
+
|
|
51
|
+
const selectedIds = computed(() => internalSelected.value)
|
|
52
|
+
const expandedIds = computed(() => internalExpanded.value)
|
|
53
|
+
|
|
54
|
+
function selectNode(id: string) {
|
|
55
|
+
if (props.multiSelect) {
|
|
56
|
+
const newSet = new Set(internalSelected.value)
|
|
57
|
+
if (newSet.has(id)) {
|
|
58
|
+
newSet.delete(id)
|
|
59
|
+
} else {
|
|
60
|
+
newSet.add(id)
|
|
61
|
+
}
|
|
62
|
+
internalSelected.value = newSet
|
|
63
|
+
emit('update:modelValue', Array.from(newSet))
|
|
64
|
+
} else {
|
|
65
|
+
if (internalSelected.value.has(id)) {
|
|
66
|
+
internalSelected.value = new Set()
|
|
67
|
+
emit('update:modelValue', undefined)
|
|
68
|
+
} else {
|
|
69
|
+
internalSelected.value = new Set([id])
|
|
70
|
+
emit('update:modelValue', id)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toggleExpand(id: string) {
|
|
76
|
+
const newSet = new Set(internalExpanded.value)
|
|
77
|
+
if (newSet.has(id)) {
|
|
78
|
+
newSet.delete(id)
|
|
79
|
+
} else {
|
|
80
|
+
newSet.add(id)
|
|
81
|
+
}
|
|
82
|
+
internalExpanded.value = newSet
|
|
83
|
+
emit('update:expandedIds', Array.from(newSet))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function setFocusedId(id: string | null) {
|
|
87
|
+
focusedId.value = id
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function registerNode(id: string, parentId: string | null, hasChildren: boolean) {
|
|
91
|
+
nodeMap.value.set(id, { parentId, hasChildren })
|
|
92
|
+
|
|
93
|
+
if (!childrenMap.value.has(parentId)) {
|
|
94
|
+
childrenMap.value.set(parentId, [])
|
|
95
|
+
}
|
|
96
|
+
const children = childrenMap.value.get(parentId)!
|
|
97
|
+
if (!children.includes(id)) {
|
|
98
|
+
children.push(id)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getParentId(id: string): string | null {
|
|
103
|
+
return nodeMap.value.get(id)?.parentId ?? null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getChildIds(id: string): string[] {
|
|
107
|
+
return childrenMap.value.get(id) ?? []
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasChildren(id: string): boolean {
|
|
111
|
+
return nodeMap.value.get(id)?.hasChildren ?? false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getVisibleNodes(): string[] {
|
|
115
|
+
const result: string[] = []
|
|
116
|
+
|
|
117
|
+
function traverse(nodes: TreeNode[], parentExpanded: boolean) {
|
|
118
|
+
for (const node of nodes) {
|
|
119
|
+
if (parentExpanded) {
|
|
120
|
+
result.push(node.id)
|
|
121
|
+
if (node.children && expandedIds.value.has(node.id)) {
|
|
122
|
+
traverse(node.children, true)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
traverse(props.data, true)
|
|
129
|
+
return result
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
133
|
+
const visibleNodes = getVisibleNodes()
|
|
134
|
+
const currentIndex = focusedId.value ? visibleNodes.indexOf(focusedId.value) : -1
|
|
135
|
+
|
|
136
|
+
switch (event.key) {
|
|
137
|
+
case 'ArrowDown': {
|
|
138
|
+
event.preventDefault()
|
|
139
|
+
const nextIndex = currentIndex < visibleNodes.length - 1 ? currentIndex + 1 : 0
|
|
140
|
+
focusedId.value = visibleNodes[nextIndex]
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
case 'ArrowUp': {
|
|
144
|
+
event.preventDefault()
|
|
145
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : visibleNodes.length - 1
|
|
146
|
+
focusedId.value = visibleNodes[prevIndex]
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
case 'ArrowRight': {
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
if (focusedId.value) {
|
|
152
|
+
if (hasChildren(focusedId.value)) {
|
|
153
|
+
if (!expandedIds.value.has(focusedId.value)) {
|
|
154
|
+
toggleExpand(focusedId.value)
|
|
155
|
+
} else {
|
|
156
|
+
const children = getChildIds(focusedId.value)
|
|
157
|
+
if (children.length > 0) {
|
|
158
|
+
focusedId.value = children[0]
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
case 'ArrowLeft': {
|
|
166
|
+
event.preventDefault()
|
|
167
|
+
if (focusedId.value) {
|
|
168
|
+
if (expandedIds.value.has(focusedId.value)) {
|
|
169
|
+
toggleExpand(focusedId.value)
|
|
170
|
+
} else {
|
|
171
|
+
const parentId = getParentId(focusedId.value)
|
|
172
|
+
if (parentId) {
|
|
173
|
+
focusedId.value = parentId
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
case 'Home': {
|
|
180
|
+
event.preventDefault()
|
|
181
|
+
if (visibleNodes.length > 0) {
|
|
182
|
+
focusedId.value = visibleNodes[0]
|
|
183
|
+
}
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
case 'End': {
|
|
187
|
+
event.preventDefault()
|
|
188
|
+
if (visibleNodes.length > 0) {
|
|
189
|
+
focusedId.value = visibleNodes[visibleNodes.length - 1]
|
|
190
|
+
}
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
case 'Enter':
|
|
194
|
+
case ' ': {
|
|
195
|
+
event.preventDefault()
|
|
196
|
+
if (focusedId.value) {
|
|
197
|
+
selectNode(focusedId.value)
|
|
198
|
+
}
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
provide(TreeViewKey, {
|
|
205
|
+
selectedIds,
|
|
206
|
+
expandedIds,
|
|
207
|
+
focusedId,
|
|
208
|
+
multiSelect: toRef(props, 'multiSelect'),
|
|
209
|
+
selectNode,
|
|
210
|
+
toggleExpand,
|
|
211
|
+
setFocusedId,
|
|
212
|
+
loadChildren: props.loadChildren ?? null,
|
|
213
|
+
loadingIds,
|
|
214
|
+
registerNode,
|
|
215
|
+
getVisibleNodes,
|
|
216
|
+
getParentId,
|
|
217
|
+
getChildIds,
|
|
218
|
+
hasChildren
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
provide(TreeViewItemKey, {
|
|
222
|
+
depth: 0,
|
|
223
|
+
parentId: null
|
|
224
|
+
})
|
|
225
|
+
</script>
|
|
226
|
+
|
|
227
|
+
<template>
|
|
228
|
+
<div
|
|
229
|
+
class="ui-treeview"
|
|
230
|
+
role="tree"
|
|
231
|
+
:aria-multiselectable="multiSelect"
|
|
232
|
+
tabindex="0"
|
|
233
|
+
@keydown="handleKeydown"
|
|
234
|
+
@focus="() => { if (!focusedId && data.length > 0) focusedId = data[0].id }"
|
|
235
|
+
>
|
|
236
|
+
<slot />
|
|
237
|
+
</div>
|
|
238
|
+
</template>
|
|
239
|
+
|
|
240
|
+
<style scoped>
|
|
241
|
+
.ui-treeview {
|
|
242
|
+
--tree-indent: 1.25rem;
|
|
243
|
+
outline: none;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.ui-treeview:focus-visible {
|
|
247
|
+
outline: 2px solid var(--ring-color);
|
|
248
|
+
outline-offset: 2px;
|
|
249
|
+
border-radius: var(--radius-md);
|
|
250
|
+
}
|
|
251
|
+
</style>
|