@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.
Files changed (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/index.d.ts +4981 -0
  4. package/dist/spire-ui.css +1 -0
  5. package/dist/spire-ui.es.js +18403 -0
  6. package/dist/spire-ui.umd.js +45 -0
  7. package/package.json +83 -0
  8. package/src/components/Accordion/Accordion.test.ts +218 -0
  9. package/src/components/Accordion/AccordionContent.vue +112 -0
  10. package/src/components/Accordion/AccordionItem.vue +87 -0
  11. package/src/components/Accordion/AccordionRoot.vue +111 -0
  12. package/src/components/Accordion/AccordionTrigger.vue +125 -0
  13. package/src/components/Accordion/index.ts +11 -0
  14. package/src/components/Accordion/keys.ts +23 -0
  15. package/src/components/Avatar/Avatar.test.ts +181 -0
  16. package/src/components/Avatar/Avatar.vue +150 -0
  17. package/src/components/Avatar/index.ts +2 -0
  18. package/src/components/Badge/Badge.test.ts +141 -0
  19. package/src/components/Badge/Badge.vue +133 -0
  20. package/src/components/Badge/index.ts +2 -0
  21. package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
  22. package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
  23. package/src/components/BadgeContainer/index.ts +2 -0
  24. package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
  25. package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
  26. package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
  27. package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
  28. package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
  29. package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
  30. package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
  31. package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
  32. package/src/components/Breadcrumb/index.ts +13 -0
  33. package/src/components/Breadcrumb/keys.ts +7 -0
  34. package/src/components/Button/Button.test.ts +231 -0
  35. package/src/components/Button/Button.vue +349 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Callout/Callout.test.ts +260 -0
  38. package/src/components/Callout/Callout.vue +341 -0
  39. package/src/components/Callout/index.ts +2 -0
  40. package/src/components/Card/Card.test.ts +565 -0
  41. package/src/components/Card/Card.vue +209 -0
  42. package/src/components/Card/CardContent.vue +57 -0
  43. package/src/components/Card/CardFooter.vue +72 -0
  44. package/src/components/Card/CardHeader.vue +111 -0
  45. package/src/components/Card/CardImage.vue +124 -0
  46. package/src/components/Card/index.ts +14 -0
  47. package/src/components/Chart/BarChart.vue +208 -0
  48. package/src/components/Chart/BaseChart.vue +444 -0
  49. package/src/components/Chart/Chart.test.ts +359 -0
  50. package/src/components/Chart/DonutChart.vue +283 -0
  51. package/src/components/Chart/LineChart.vue +211 -0
  52. package/src/components/Chart/index.ts +20 -0
  53. package/src/components/Chart/useChartTheme.ts +192 -0
  54. package/src/components/Checkbox/Checkbox.test.ts +209 -0
  55. package/src/components/Checkbox/Checkbox.vue +285 -0
  56. package/src/components/Checkbox/index.ts +2 -0
  57. package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
  58. package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
  59. package/src/components/ChoiceChip/index.ts +2 -0
  60. package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
  61. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
  62. package/src/components/ChoiceChipGroup/index.ts +2 -0
  63. package/src/components/ColorPicker/ColorArea.vue +159 -0
  64. package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
  65. package/src/components/ColorPicker/ColorPicker.vue +339 -0
  66. package/src/components/ColorPicker/ColorSlider.vue +191 -0
  67. package/src/components/ColorPicker/index.ts +7 -0
  68. package/src/components/Combobox/Combobox.test.ts +891 -0
  69. package/src/components/Combobox/Combobox.vue +934 -0
  70. package/src/components/Combobox/index.ts +2 -0
  71. package/src/components/DataTable/DataTable.test.ts +1221 -0
  72. package/src/components/DataTable/DataTable.vue +1415 -0
  73. package/src/components/DataTable/index.ts +10 -0
  74. package/src/components/DatePicker/DatePicker.test.ts +625 -0
  75. package/src/components/DatePicker/DatePicker.vue +1586 -0
  76. package/src/components/DatePicker/index.ts +2 -0
  77. package/src/components/Drawer/Drawer.test.ts +336 -0
  78. package/src/components/Drawer/Drawer.vue +466 -0
  79. package/src/components/Drawer/index.ts +2 -0
  80. package/src/components/Dropdown/Dropdown.test.ts +607 -0
  81. package/src/components/Dropdown/Dropdown.vue +807 -0
  82. package/src/components/Dropdown/DropdownItem.vue +227 -0
  83. package/src/components/Dropdown/DropdownSeparator.vue +14 -0
  84. package/src/components/Dropdown/DropdownSub.vue +104 -0
  85. package/src/components/Dropdown/DropdownSubContent.vue +187 -0
  86. package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
  87. package/src/components/Dropdown/index.ts +14 -0
  88. package/src/components/EmptyState/EmptyState.test.ts +180 -0
  89. package/src/components/EmptyState/EmptyState.vue +137 -0
  90. package/src/components/EmptyState/index.ts +2 -0
  91. package/src/components/FileUpload/FileUpload.test.ts +1151 -0
  92. package/src/components/FileUpload/FileUpload.vue +1042 -0
  93. package/src/components/FileUpload/index.ts +2 -0
  94. package/src/components/Heading/Heading.test.ts +107 -0
  95. package/src/components/Heading/Heading.vue +67 -0
  96. package/src/components/Heading/index.ts +2 -0
  97. package/src/components/Icon/Icon.test.ts +157 -0
  98. package/src/components/Icon/Icon.vue +86 -0
  99. package/src/components/Icon/index.ts +2 -0
  100. package/src/components/Input/Input.test.ts +273 -0
  101. package/src/components/Input/Input.vue +388 -0
  102. package/src/components/Input/index.ts +2 -0
  103. package/src/components/Layout/Container.vue +67 -0
  104. package/src/components/Layout/Grid.vue +159 -0
  105. package/src/components/Layout/GridItem.vue +154 -0
  106. package/src/components/Layout/Layout.test.ts +202 -0
  107. package/src/components/Layout/Stack.vue +128 -0
  108. package/src/components/Layout/index.ts +9 -0
  109. package/src/components/Layout/keys.ts +7 -0
  110. package/src/components/Modal/Modal.test.ts +311 -0
  111. package/src/components/Modal/Modal.vue +336 -0
  112. package/src/components/Modal/index.ts +2 -0
  113. package/src/components/Pagination/Pagination.test.ts +303 -0
  114. package/src/components/Pagination/Pagination.vue +212 -0
  115. package/src/components/Pagination/index.ts +3 -0
  116. package/src/components/Pagination/utils.ts +86 -0
  117. package/src/components/Popover/Popover.test.ts +285 -0
  118. package/src/components/Popover/Popover.vue +441 -0
  119. package/src/components/Popover/index.ts +2 -0
  120. package/src/components/Progress/Progress.test.ts +361 -0
  121. package/src/components/Progress/Progress.vue +363 -0
  122. package/src/components/Progress/index.ts +7 -0
  123. package/src/components/Radio/Radio.test.ts +216 -0
  124. package/src/components/Radio/Radio.vue +214 -0
  125. package/src/components/Radio/index.ts +2 -0
  126. package/src/components/Rating/Rating.test.ts +319 -0
  127. package/src/components/Rating/Rating.vue +247 -0
  128. package/src/components/Rating/index.ts +2 -0
  129. package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
  130. package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
  131. package/src/components/SegmentedControl/index.ts +2 -0
  132. package/src/components/Select/Select.test.ts +589 -0
  133. package/src/components/Select/Select.vue +666 -0
  134. package/src/components/Select/index.ts +2 -0
  135. package/src/components/Sidebar/Sidebar.test.ts +301 -0
  136. package/src/components/Sidebar/SidebarGroup.vue +103 -0
  137. package/src/components/Sidebar/SidebarItem.vue +196 -0
  138. package/src/components/Sidebar/SidebarLayout.vue +42 -0
  139. package/src/components/Sidebar/SidebarRoot.vue +122 -0
  140. package/src/components/Sidebar/index.ts +11 -0
  141. package/src/components/Sidebar/keys.ts +14 -0
  142. package/src/components/Skeleton/Skeleton.test.ts +130 -0
  143. package/src/components/Skeleton/Skeleton.vue +104 -0
  144. package/src/components/Skeleton/index.ts +2 -0
  145. package/src/components/Slider/Slider.test.ts +416 -0
  146. package/src/components/Slider/Slider.vue +435 -0
  147. package/src/components/Slider/index.ts +2 -0
  148. package/src/components/Slider/utils.ts +91 -0
  149. package/src/components/Spinner/Spinner.test.ts +79 -0
  150. package/src/components/Spinner/Spinner.vue +159 -0
  151. package/src/components/Spinner/index.ts +2 -0
  152. package/src/components/SpireProvider/SpireProvider.vue +71 -0
  153. package/src/components/SpireProvider/index.ts +11 -0
  154. package/src/components/Stepper/Stepper.test.ts +221 -0
  155. package/src/components/Stepper/StepperContent.vue +51 -0
  156. package/src/components/Stepper/StepperItem.vue +89 -0
  157. package/src/components/Stepper/StepperRoot.vue +101 -0
  158. package/src/components/Stepper/StepperSeparator.vue +52 -0
  159. package/src/components/Stepper/StepperTrigger.vue +144 -0
  160. package/src/components/Stepper/index.ts +11 -0
  161. package/src/components/Stepper/keys.ts +27 -0
  162. package/src/components/Switch/Switch.test.ts +214 -0
  163. package/src/components/Switch/Switch.vue +235 -0
  164. package/src/components/Switch/index.ts +2 -0
  165. package/src/components/Tabs/Tabs.test.ts +363 -0
  166. package/src/components/Tabs/Tabs.vue +318 -0
  167. package/src/components/Tabs/index.ts +2 -0
  168. package/src/components/Text/Text.test.ts +154 -0
  169. package/src/components/Text/Text.vue +100 -0
  170. package/src/components/Text/index.ts +2 -0
  171. package/src/components/Textarea/Textarea.test.ts +432 -0
  172. package/src/components/Textarea/Textarea.vue +411 -0
  173. package/src/components/Textarea/index.ts +2 -0
  174. package/src/components/TimePicker/TimePicker.test.ts +352 -0
  175. package/src/components/TimePicker/TimePicker.vue +569 -0
  176. package/src/components/TimePicker/index.ts +2 -0
  177. package/src/components/Timeline/Timeline.test.ts +193 -0
  178. package/src/components/Timeline/Timeline.vue +111 -0
  179. package/src/components/Timeline/TimelineItem.vue +167 -0
  180. package/src/components/Timeline/index.ts +13 -0
  181. package/src/components/Timeline/keys.ts +21 -0
  182. package/src/components/Toast/ToastItem.test.ts +289 -0
  183. package/src/components/Toast/ToastItem.vue +370 -0
  184. package/src/components/Toast/ToastProvider.test.ts +158 -0
  185. package/src/components/Toast/ToastProvider.vue +181 -0
  186. package/src/components/Toast/index.ts +83 -0
  187. package/src/components/Toast/toastState.test.ts +165 -0
  188. package/src/components/Toast/toastState.ts +161 -0
  189. package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
  190. package/src/components/ToggleButton/ToggleButton.vue +197 -0
  191. package/src/components/ToggleButton/index.ts +2 -0
  192. package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
  193. package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
  194. package/src/components/ToggleGroup/index.ts +2 -0
  195. package/src/components/Tooltip/Tooltip.test.ts +238 -0
  196. package/src/components/Tooltip/Tooltip.vue +217 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/components/TreeView/TreeView.test.ts +357 -0
  199. package/src/components/TreeView/TreeView.vue +251 -0
  200. package/src/components/TreeView/TreeViewItem.vue +288 -0
  201. package/src/components/TreeView/index.ts +11 -0
  202. package/src/components/TreeView/keys.ts +35 -0
  203. package/src/composables/index.ts +12 -0
  204. package/src/composables/useClickOutside.ts +36 -0
  205. package/src/composables/useClipboard.ts +35 -0
  206. package/src/composables/useEventListener.ts +48 -0
  207. package/src/composables/useFocusTrap.ts +58 -0
  208. package/src/composables/useHoverReveal.ts +98 -0
  209. package/src/composables/useId.ts +10 -0
  210. package/src/composables/useMagnetic.ts +171 -0
  211. package/src/composables/useRelativePosition.ts +127 -0
  212. package/src/composables/useRipple.ts +146 -0
  213. package/src/composables/useScrollLock.ts +25 -0
  214. package/src/composables/useSpireConfig.ts +27 -0
  215. package/src/composables/useStagger.ts +224 -0
  216. package/src/config/icons.test.ts +115 -0
  217. package/src/config/icons.ts +170 -0
  218. package/src/index.ts +361 -0
  219. package/src/styles/depth.css +129 -0
  220. package/src/styles/effects.css +169 -0
  221. package/src/styles/fallback.css +152 -0
  222. package/src/styles/main.css +25 -0
  223. package/src/styles/mood.css +211 -0
  224. package/src/styles/motion.css +159 -0
  225. package/src/styles/reset.css +97 -0
  226. package/src/styles/theme.css +708 -0
  227. package/src/styles/tokens.css +183 -0
  228. package/src/utils/.gitkeep +0 -0
  229. package/src/utils/color.ts +277 -0
  230. package/src/utils/date.test.ts +522 -0
  231. package/src/utils/date.ts +380 -0
  232. package/src/utils/index.ts +23 -0
  233. package/src/utils/object.test.ts +80 -0
  234. package/src/utils/object.ts +25 -0
  235. package/src/utils/string.test.ts +64 -0
  236. package/src/utils/string.ts +32 -0
  237. package/src/utils/time.ts +156 -0
@@ -0,0 +1,301 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h, nextTick } from 'vue'
4
+ import SidebarLayout from './SidebarLayout.vue'
5
+ import SidebarRoot from './SidebarRoot.vue'
6
+ import SidebarItem from './SidebarItem.vue'
7
+ import SidebarGroup from './SidebarGroup.vue'
8
+
9
+ function createSidebar(props: Record<string, unknown> = {}) {
10
+ return mount(SidebarRoot, {
11
+ props,
12
+ slots: {
13
+ default: () => [
14
+ h(SidebarGroup, { label: 'Navigation' }, {
15
+ default: () => [
16
+ h(SidebarItem, { label: 'Home', active: true }),
17
+ h(SidebarItem, { label: 'Dashboard' }),
18
+ h(SidebarItem, { label: 'Settings', disabled: true })
19
+ ]
20
+ }),
21
+ h(SidebarGroup, { label: 'Other' }, {
22
+ default: () => [
23
+ h(SidebarItem, { label: 'Help' })
24
+ ]
25
+ })
26
+ ],
27
+ header: () => h('div', { class: 'logo' }, 'Logo'),
28
+ footer: () => h('div', { class: 'user' }, 'User')
29
+ }
30
+ })
31
+ }
32
+
33
+ function createSidebarItem(props: Record<string, unknown> = {}) {
34
+ return mount(SidebarRoot, {
35
+ props: {},
36
+ slots: {
37
+ default: () => h(SidebarItem, { label: 'Test Item', ...props })
38
+ }
39
+ })
40
+ }
41
+
42
+ describe('Sidebar', () => {
43
+ describe('SidebarRoot', () => {
44
+ describe('Rendering', () => {
45
+ it('renders sidebar root element', () => {
46
+ const wrapper = createSidebar()
47
+ expect(wrapper.find('.ui-sidebar').exists()).toBe(true)
48
+ })
49
+
50
+ it('renders header slot', () => {
51
+ const wrapper = createSidebar()
52
+ expect(wrapper.find('.ui-sidebar__header').exists()).toBe(true)
53
+ expect(wrapper.find('.logo').exists()).toBe(true)
54
+ })
55
+
56
+ it('renders footer slot', () => {
57
+ const wrapper = createSidebar()
58
+ expect(wrapper.find('.ui-sidebar__footer').exists()).toBe(true)
59
+ expect(wrapper.find('.user').exists()).toBe(true)
60
+ })
61
+
62
+ it('renders navigation area', () => {
63
+ const wrapper = createSidebar()
64
+ expect(wrapper.find('.ui-sidebar__nav').exists()).toBe(true)
65
+ })
66
+ })
67
+
68
+ describe('Collapsed state', () => {
69
+ it('starts expanded by default', () => {
70
+ const wrapper = createSidebar()
71
+ expect(wrapper.find('.ui-sidebar--collapsed').exists()).toBe(false)
72
+ })
73
+
74
+ it('applies collapsed class when modelValue is true', () => {
75
+ const wrapper = createSidebar({ modelValue: true })
76
+ expect(wrapper.find('.ui-sidebar--collapsed').exists()).toBe(true)
77
+ })
78
+
79
+ it('sets CSS variable for width', () => {
80
+ const wrapper = createSidebar({ expandedWidth: '280px' })
81
+ const style = wrapper.find('.ui-sidebar').attributes('style')
82
+ expect(style).toContain('--sidebar-current-width: 280px')
83
+ })
84
+
85
+ it('uses collapsed width when collapsed', () => {
86
+ const wrapper = createSidebar({ modelValue: true, collapsedWidth: '72px' })
87
+ const style = wrapper.find('.ui-sidebar').attributes('style')
88
+ expect(style).toContain('--sidebar-current-width: 72px')
89
+ })
90
+
91
+ it('emits update:modelValue on toggle', async () => {
92
+ const wrapper = createSidebar({ modelValue: false })
93
+ const vm = wrapper.vm as { toggle: () => void }
94
+ vm.toggle()
95
+ await nextTick()
96
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
97
+ })
98
+ })
99
+
100
+ describe('Accessibility', () => {
101
+ it('has role="navigation"', () => {
102
+ const wrapper = createSidebar()
103
+ expect(wrapper.find('.ui-sidebar').attributes('role')).toBe('navigation')
104
+ })
105
+
106
+ it('uses aside element', () => {
107
+ const wrapper = createSidebar()
108
+ expect(wrapper.find('.ui-sidebar').element.tagName).toBe('ASIDE')
109
+ })
110
+ })
111
+ })
112
+
113
+ describe('SidebarItem', () => {
114
+ describe('Rendering', () => {
115
+ it('renders item with label', () => {
116
+ const wrapper = createSidebarItem()
117
+ expect(wrapper.find('.ui-sidebar-item__label').text()).toBe('Test Item')
118
+ })
119
+
120
+ it('renders as button by default', () => {
121
+ const wrapper = createSidebarItem()
122
+ expect(wrapper.find('.ui-sidebar-item').element.tagName).toBe('BUTTON')
123
+ })
124
+
125
+ it('renders as anchor when href is provided', () => {
126
+ const wrapper = createSidebarItem({ as: 'a', href: '/test' })
127
+ const item = wrapper.find('.ui-sidebar-item')
128
+ expect(item.element.tagName).toBe('A')
129
+ expect(item.attributes('href')).toBe('/test')
130
+ })
131
+ })
132
+
133
+ describe('States', () => {
134
+ it('applies active class', () => {
135
+ const wrapper = createSidebarItem({ active: true })
136
+ expect(wrapper.find('.ui-sidebar-item--active').exists()).toBe(true)
137
+ })
138
+
139
+ it('applies disabled class', () => {
140
+ const wrapper = createSidebarItem({ disabled: true })
141
+ expect(wrapper.find('.ui-sidebar-item--disabled').exists()).toBe(true)
142
+ })
143
+ })
144
+
145
+ describe('Collapsed behavior', () => {
146
+ it('applies collapsed class when sidebar is collapsed', () => {
147
+ const wrapper = mount(SidebarRoot, {
148
+ props: { modelValue: true },
149
+ slots: {
150
+ default: () => h(SidebarItem, { label: 'Test' })
151
+ }
152
+ })
153
+ expect(wrapper.find('.ui-sidebar-item--collapsed').exists()).toBe(true)
154
+ })
155
+
156
+ it('has aria-label when collapsed', () => {
157
+ const wrapper = mount(SidebarRoot, {
158
+ props: { modelValue: true },
159
+ slots: {
160
+ default: () => h(SidebarItem, { label: 'Test Label' })
161
+ }
162
+ })
163
+ expect(wrapper.find('.ui-sidebar-item').attributes('aria-label')).toBe('Test Label')
164
+ })
165
+ })
166
+
167
+ describe('Accessibility', () => {
168
+ it('has aria-current="page" when active', () => {
169
+ const wrapper = createSidebarItem({ active: true })
170
+ expect(wrapper.find('.ui-sidebar-item').attributes('aria-current')).toBe('page')
171
+ })
172
+
173
+ it('does not have aria-current when not active', () => {
174
+ const wrapper = createSidebarItem({ active: false })
175
+ expect(wrapper.find('.ui-sidebar-item').attributes('aria-current')).toBeUndefined()
176
+ })
177
+ })
178
+ })
179
+
180
+ describe('SidebarGroup', () => {
181
+ describe('Rendering', () => {
182
+ it('renders group label', () => {
183
+ const wrapper = createSidebar()
184
+ expect(wrapper.find('.ui-sidebar-group__label').text()).toBe('Navigation')
185
+ })
186
+
187
+ it('renders items inside group', () => {
188
+ const wrapper = createSidebar()
189
+ const group = wrapper.find('.ui-sidebar-group')
190
+ expect(group.findAll('.ui-sidebar-item')).toHaveLength(3)
191
+ })
192
+ })
193
+
194
+ describe('Collapsed behavior', () => {
195
+ it('applies collapsed class when sidebar is collapsed', () => {
196
+ const wrapper = createSidebar({ modelValue: true })
197
+ expect(wrapper.find('.ui-sidebar-group--collapsed').exists()).toBe(true)
198
+ })
199
+
200
+ it('shows separator when collapsed', () => {
201
+ const wrapper = createSidebar({ modelValue: true })
202
+ const groups = wrapper.findAll('.ui-sidebar-group')
203
+ expect(groups[1].classes()).toContain('ui-sidebar-group--separator')
204
+ })
205
+ })
206
+
207
+ describe('Accessibility', () => {
208
+ it('group content has role="group"', () => {
209
+ const wrapper = createSidebar()
210
+ expect(wrapper.find('.ui-sidebar-group__content').attributes('role')).toBe('group')
211
+ })
212
+
213
+ it('group content has aria-labelledby when label exists', () => {
214
+ const wrapper = createSidebar()
215
+ const label = wrapper.find('.ui-sidebar-group__label')
216
+ const content = wrapper.find('.ui-sidebar-group__content')
217
+ expect(content.attributes('aria-labelledby')).toBe(label.attributes('id'))
218
+ })
219
+ })
220
+ })
221
+
222
+ describe('Error handling', () => {
223
+ it('throws error when SidebarItem is used outside SidebarRoot', () => {
224
+ expect(() => {
225
+ mount(SidebarItem, { props: { label: 'Test' } })
226
+ }).toThrow('SidebarItem must be used within SidebarRoot')
227
+ })
228
+
229
+ it('throws error when SidebarGroup is used outside SidebarRoot', () => {
230
+ expect(() => {
231
+ mount(SidebarGroup, { props: { label: 'Test' } })
232
+ }).toThrow('SidebarGroup must be used within SidebarRoot')
233
+ })
234
+ })
235
+
236
+ describe('SidebarLayout', () => {
237
+ describe('Rendering', () => {
238
+ it('renders layout container', () => {
239
+ const wrapper = mount(SidebarLayout)
240
+ expect(wrapper.find('.ui-sidebar-layout').exists()).toBe(true)
241
+ })
242
+
243
+ it('renders main content area', () => {
244
+ const wrapper = mount(SidebarLayout, {
245
+ slots: {
246
+ default: () => h('div', { class: 'content' }, 'Main content')
247
+ }
248
+ })
249
+ expect(wrapper.find('.ui-sidebar-layout__main').exists()).toBe(true)
250
+ expect(wrapper.find('.content').exists()).toBe(true)
251
+ })
252
+
253
+ it('renders sidebar slot', () => {
254
+ const wrapper = mount(SidebarLayout, {
255
+ slots: {
256
+ sidebar: () => h('div', { class: 'sidebar' }, 'Sidebar')
257
+ }
258
+ })
259
+ expect(wrapper.find('.sidebar').exists()).toBe(true)
260
+ })
261
+
262
+ it('renders both sidebar and main content', () => {
263
+ const wrapper = mount(SidebarLayout, {
264
+ slots: {
265
+ sidebar: () => h('div', { class: 'sidebar' }, 'Sidebar'),
266
+ default: () => h('div', { class: 'content' }, 'Content')
267
+ }
268
+ })
269
+ expect(wrapper.find('.sidebar').exists()).toBe(true)
270
+ expect(wrapper.find('.content').exists()).toBe(true)
271
+ })
272
+ })
273
+
274
+ describe('Position', () => {
275
+ it('defaults to left position', () => {
276
+ const wrapper = mount(SidebarLayout)
277
+ expect(wrapper.find('.ui-sidebar-layout--left').exists()).toBe(true)
278
+ })
279
+
280
+ it('applies right position class', () => {
281
+ const wrapper = mount(SidebarLayout, {
282
+ props: { sidebarPosition: 'right' }
283
+ })
284
+ expect(wrapper.find('.ui-sidebar-layout--right').exists()).toBe(true)
285
+ })
286
+ })
287
+
288
+ describe('Layout structure', () => {
289
+ it('uses flexbox layout', () => {
290
+ const wrapper = mount(SidebarLayout)
291
+ const layout = wrapper.find('.ui-sidebar-layout')
292
+ expect(layout.element.tagName).toBe('DIV')
293
+ })
294
+
295
+ it('main content uses main element', () => {
296
+ const wrapper = mount(SidebarLayout)
297
+ expect(wrapper.find('.ui-sidebar-layout__main').element.tagName).toBe('MAIN')
298
+ })
299
+ })
300
+ })
301
+ })
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject, provide } from 'vue'
3
+ import { useId } from '../../composables'
4
+ import { SidebarKey, SidebarGroupKey } from './keys'
5
+
6
+ export interface SidebarGroupProps {
7
+ /** Group label displayed when expanded */
8
+ label?: string
9
+ /** Show separator line when collapsed */
10
+ showSeparator?: boolean
11
+ }
12
+
13
+ const props = withDefaults(defineProps<SidebarGroupProps>(), {
14
+ showSeparator: true
15
+ })
16
+
17
+ const sidebar = inject(SidebarKey)
18
+
19
+ if (!sidebar) {
20
+ throw new Error('SidebarGroup must be used within SidebarRoot')
21
+ }
22
+
23
+ const groupId = useId('sidebar-group')
24
+ const isCollapsed = computed(() => sidebar.collapsed.value)
25
+
26
+ provide(SidebarGroupKey, { groupId })
27
+
28
+ const groupClasses = computed(() => [
29
+ 'ui-sidebar-group',
30
+ {
31
+ 'ui-sidebar-group--collapsed': isCollapsed.value,
32
+ 'ui-sidebar-group--separator': props.showSeparator && isCollapsed.value
33
+ }
34
+ ])
35
+ </script>
36
+
37
+ <template>
38
+ <div :class="groupClasses">
39
+ <div
40
+ v-if="label"
41
+ class="ui-sidebar-group__label"
42
+ :id="groupId"
43
+ >
44
+ {{ label }}
45
+ </div>
46
+ <div
47
+ class="ui-sidebar-group__content"
48
+ :aria-labelledby="label ? groupId : undefined"
49
+ role="group"
50
+ >
51
+ <slot />
52
+ </div>
53
+ </div>
54
+ </template>
55
+
56
+ <style scoped>
57
+ .ui-sidebar-group {
58
+ display: flex;
59
+ flex-direction: column;
60
+ }
61
+
62
+ .ui-sidebar-group + .ui-sidebar-group {
63
+ margin-top: var(--space-2);
64
+ }
65
+
66
+ .ui-sidebar-group__label {
67
+ padding: var(--space-2) var(--space-3);
68
+ font-family: var(--font-sans);
69
+ font-size: var(--text-xs);
70
+ font-weight: var(--font-semibold);
71
+ color: var(--sidebar-group-label, var(--text-tertiary));
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.05em;
74
+ transition:
75
+ opacity var(--duration-fast) var(--ease-default),
76
+ height var(--duration-fast) var(--ease-default);
77
+ overflow: hidden;
78
+ white-space: nowrap;
79
+ }
80
+
81
+ .ui-sidebar-group--collapsed .ui-sidebar-group__label {
82
+ opacity: 0;
83
+ height: 0;
84
+ padding-top: 0;
85
+ padding-bottom: 0;
86
+ }
87
+
88
+ .ui-sidebar-group__content {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: var(--space-1);
92
+ }
93
+
94
+ .ui-sidebar-group--separator {
95
+ padding-top: var(--space-2);
96
+ border-top: 1px solid var(--sidebar-border, var(--border-default));
97
+ }
98
+
99
+ .ui-sidebar-group--separator:first-child {
100
+ border-top: none;
101
+ padding-top: 0;
102
+ }
103
+ </style>
@@ -0,0 +1,196 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject, type Component } from 'vue'
3
+ import { SidebarKey } from './keys'
4
+ import Icon from '../Icon/Icon.vue'
5
+ import Tooltip from '../Tooltip/Tooltip.vue'
6
+
7
+ type HugeIconData = [string, Record<string, unknown>][]
8
+ type IconInput = Component | HugeIconData
9
+
10
+ export interface SidebarItemProps {
11
+ /** Text label for the item */
12
+ label: string
13
+ /** Icon component or HugeIcons data */
14
+ icon?: IconInput
15
+ /** Whether this item is currently active */
16
+ active?: boolean
17
+ /** Disable this item */
18
+ disabled?: boolean
19
+ /** Render as a different element (button, a, RouterLink) */
20
+ as?: string | Component
21
+ /** href for anchor elements */
22
+ href?: string
23
+ /** Target for anchor elements */
24
+ target?: string
25
+ /** Route object for router-link */
26
+ to?: string | Record<string, unknown>
27
+ }
28
+
29
+ const props = withDefaults(defineProps<SidebarItemProps>(), {
30
+ active: false,
31
+ disabled: false,
32
+ as: 'button'
33
+ })
34
+
35
+ const sidebar = inject(SidebarKey)
36
+
37
+ if (!sidebar) {
38
+ throw new Error('SidebarItem must be used within SidebarRoot')
39
+ }
40
+
41
+ const isCollapsed = computed(() => sidebar.collapsed.value)
42
+
43
+ const itemClasses = computed(() => [
44
+ 'ui-sidebar-item',
45
+ {
46
+ 'ui-sidebar-item--active': props.active,
47
+ 'ui-sidebar-item--disabled': props.disabled,
48
+ 'ui-sidebar-item--collapsed': isCollapsed.value
49
+ }
50
+ ])
51
+
52
+ const componentProps = computed(() => {
53
+ const base: Record<string, unknown> = {
54
+ class: itemClasses.value,
55
+ disabled: props.disabled || undefined,
56
+ 'aria-current': props.active ? 'page' : undefined,
57
+ 'aria-label': isCollapsed.value ? props.label : undefined
58
+ }
59
+
60
+ if (props.href) {
61
+ base.href = props.disabled ? undefined : props.href
62
+ base.target = props.target
63
+ }
64
+
65
+ if (props.to) {
66
+ base.to = props.disabled ? undefined : props.to
67
+ }
68
+
69
+ return base
70
+ })
71
+ </script>
72
+
73
+ <template>
74
+ <Tooltip
75
+ :text="label"
76
+ :disabled="!isCollapsed"
77
+ placement="right"
78
+ >
79
+ <component
80
+ :is="as"
81
+ v-bind="componentProps"
82
+ >
83
+ <span v-if="icon" class="ui-sidebar-item__icon" aria-hidden="true">
84
+ <Icon :icon="icon" size="md" />
85
+ </span>
86
+ <span class="ui-sidebar-item__label">
87
+ {{ label }}
88
+ </span>
89
+ <span v-if="$slots.badge" class="ui-sidebar-item__badge">
90
+ <slot name="badge" />
91
+ </span>
92
+ </component>
93
+ </Tooltip>
94
+ </template>
95
+
96
+ <style scoped>
97
+ .ui-sidebar-item {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: var(--space-3);
101
+ width: 100%;
102
+ padding: var(--space-2) var(--space-3);
103
+ border: none;
104
+ border-radius: var(--radius-md);
105
+ background: transparent;
106
+ color: var(--sidebar-item-text, var(--text-secondary));
107
+ font-family: var(--font-sans);
108
+ font-size: var(--text-sm);
109
+ font-weight: var(--font-medium);
110
+ line-height: var(--leading-normal);
111
+ text-align: left;
112
+ text-decoration: none;
113
+ cursor: pointer;
114
+ transition:
115
+ background-color var(--duration-fast) var(--ease-default),
116
+ color var(--duration-fast) var(--ease-default);
117
+ }
118
+
119
+ .ui-sidebar-item:hover:not(.ui-sidebar-item--disabled) {
120
+ background: var(--sidebar-item-bg-hover, var(--action-secondary));
121
+ color: var(--sidebar-item-text-hover, var(--text-primary));
122
+ }
123
+
124
+ .ui-sidebar-item:active:not(.ui-sidebar-item--disabled) {
125
+ background: var(--sidebar-item-bg-active, var(--action-secondary-hover));
126
+ }
127
+
128
+ .ui-sidebar-item:focus-visible {
129
+ outline: 2px solid var(--ring-color);
130
+ outline-offset: -2px;
131
+ }
132
+
133
+ .ui-sidebar-item--active {
134
+ background: var(--sidebar-item-bg-active, var(--action-secondary));
135
+ color: var(--sidebar-item-text-active, var(--action-primary));
136
+ }
137
+
138
+ .ui-sidebar-item--active:hover:not(.ui-sidebar-item--disabled) {
139
+ background: var(--sidebar-item-bg-active-hover, var(--action-secondary-hover));
140
+ }
141
+
142
+ .ui-sidebar-item--disabled {
143
+ opacity: 0.5;
144
+ cursor: not-allowed;
145
+ }
146
+
147
+ .ui-sidebar-item__icon {
148
+ flex-shrink: 0;
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ }
153
+
154
+ .ui-sidebar-item__label {
155
+ flex: 1;
156
+ overflow: hidden;
157
+ text-overflow: ellipsis;
158
+ white-space: nowrap;
159
+ transition: opacity var(--duration-fast) var(--ease-default);
160
+ }
161
+
162
+ .ui-sidebar-item--collapsed .ui-sidebar-item__label {
163
+ position: absolute;
164
+ width: 1px;
165
+ height: 1px;
166
+ padding: 0;
167
+ margin: -1px;
168
+ overflow: hidden;
169
+ clip: rect(0, 0, 0, 0);
170
+ white-space: nowrap;
171
+ border: 0;
172
+ }
173
+
174
+ .ui-sidebar-item__badge {
175
+ flex-shrink: 0;
176
+ transition: opacity var(--duration-fast) var(--ease-default);
177
+ }
178
+
179
+ .ui-sidebar-item--collapsed .ui-sidebar-item__badge {
180
+ position: absolute;
181
+ width: 1px;
182
+ height: 1px;
183
+ padding: 0;
184
+ margin: -1px;
185
+ overflow: hidden;
186
+ clip: rect(0, 0, 0, 0);
187
+ white-space: nowrap;
188
+ border: 0;
189
+ }
190
+
191
+ .ui-sidebar-item--collapsed {
192
+ justify-content: center;
193
+ padding: var(--space-2);
194
+ gap: 0;
195
+ }
196
+ </style>
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ export interface SidebarLayoutProps {
3
+ /** Position of the sidebar */
4
+ sidebarPosition?: 'left' | 'right'
5
+ }
6
+
7
+ const props = withDefaults(defineProps<SidebarLayoutProps>(), {
8
+ sidebarPosition: 'left'
9
+ })
10
+ </script>
11
+
12
+ <template>
13
+ <div
14
+ class="ui-sidebar-layout"
15
+ :class="[`ui-sidebar-layout--${sidebarPosition}`]"
16
+ >
17
+ <slot name="sidebar" />
18
+ <main class="ui-sidebar-layout__main">
19
+ <slot />
20
+ </main>
21
+ </div>
22
+ </template>
23
+
24
+ <style scoped>
25
+ .ui-sidebar-layout {
26
+ display: flex;
27
+ width: 100%;
28
+ height: 100%;
29
+ min-height: 0;
30
+ }
31
+
32
+ .ui-sidebar-layout--right {
33
+ flex-direction: row-reverse;
34
+ }
35
+
36
+ .ui-sidebar-layout__main {
37
+ flex: 1;
38
+ min-width: 0;
39
+ min-height: 0;
40
+ overflow: hidden;
41
+ }
42
+ </style>