@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,342 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h } from 'vue'
4
+ import BreadcrumbRoot from './BreadcrumbRoot.vue'
5
+ import BreadcrumbList from './BreadcrumbList.vue'
6
+ import BreadcrumbItem from './BreadcrumbItem.vue'
7
+ import BreadcrumbLink from './BreadcrumbLink.vue'
8
+ import BreadcrumbPage from './BreadcrumbPage.vue'
9
+ import BreadcrumbSeparator from './BreadcrumbSeparator.vue'
10
+ import BreadcrumbEllipsis from './BreadcrumbEllipsis.vue'
11
+
12
+ describe('BreadcrumbRoot', () => {
13
+ it('renders as nav element', () => {
14
+ const wrapper = mount(BreadcrumbRoot, {
15
+ slots: {
16
+ default: 'Content'
17
+ }
18
+ })
19
+ expect(wrapper.find('nav').exists()).toBe(true)
20
+ })
21
+
22
+ it('has aria-label for accessibility', () => {
23
+ const wrapper = mount(BreadcrumbRoot)
24
+ expect(wrapper.find('nav').attributes('aria-label')).toBe('Breadcrumb')
25
+ })
26
+
27
+ it('applies breadcrumb class', () => {
28
+ const wrapper = mount(BreadcrumbRoot)
29
+ expect(wrapper.find('nav').classes()).toContain('ui-breadcrumb')
30
+ })
31
+
32
+ it('renders slot content', () => {
33
+ const wrapper = mount(BreadcrumbRoot, {
34
+ slots: {
35
+ default: '<span data-testid="content">Test</span>'
36
+ }
37
+ })
38
+ expect(wrapper.find('[data-testid="content"]').exists()).toBe(true)
39
+ })
40
+ })
41
+
42
+ describe('BreadcrumbList', () => {
43
+ it('renders as ordered list', () => {
44
+ const wrapper = mount(BreadcrumbList)
45
+ expect(wrapper.find('ol').exists()).toBe(true)
46
+ })
47
+
48
+ it('applies list class', () => {
49
+ const wrapper = mount(BreadcrumbList)
50
+ expect(wrapper.find('ol').classes()).toContain('ui-breadcrumb__list')
51
+ })
52
+
53
+ it('renders slot content', () => {
54
+ const wrapper = mount(BreadcrumbList, {
55
+ slots: {
56
+ default: '<li data-testid="item">Item</li>'
57
+ }
58
+ })
59
+ expect(wrapper.find('[data-testid="item"]').exists()).toBe(true)
60
+ })
61
+ })
62
+
63
+ describe('BreadcrumbItem', () => {
64
+ it('renders as list item', () => {
65
+ const wrapper = mount(BreadcrumbItem)
66
+ expect(wrapper.find('li').exists()).toBe(true)
67
+ })
68
+
69
+ it('applies item class', () => {
70
+ const wrapper = mount(BreadcrumbItem)
71
+ expect(wrapper.find('li').classes()).toContain('ui-breadcrumb__item')
72
+ })
73
+
74
+ it('renders slot content', () => {
75
+ const wrapper = mount(BreadcrumbItem, {
76
+ slots: {
77
+ default: '<a data-testid="link" href="/">Link</a>'
78
+ }
79
+ })
80
+ expect(wrapper.find('[data-testid="link"]').exists()).toBe(true)
81
+ })
82
+ })
83
+
84
+ describe('BreadcrumbLink', () => {
85
+ it('renders as anchor by default', () => {
86
+ const wrapper = mount(BreadcrumbLink, {
87
+ props: { href: '/' },
88
+ slots: { default: 'Home' }
89
+ })
90
+ expect(wrapper.find('a').exists()).toBe(true)
91
+ })
92
+
93
+ it('applies href attribute', () => {
94
+ const wrapper = mount(BreadcrumbLink, {
95
+ props: { href: '/products' },
96
+ slots: { default: 'Products' }
97
+ })
98
+ expect(wrapper.find('a').attributes('href')).toBe('/products')
99
+ })
100
+
101
+ it('renders as router-link when to prop is provided', () => {
102
+ const wrapper = mount(BreadcrumbLink, {
103
+ props: { to: '/settings' },
104
+ slots: { default: 'Settings' },
105
+ global: {
106
+ stubs: {
107
+ 'router-link': {
108
+ template: '<a><slot /></a>'
109
+ }
110
+ }
111
+ }
112
+ })
113
+ expect(wrapper.find('a').exists()).toBe(true)
114
+ })
115
+
116
+ it('applies link class', () => {
117
+ const wrapper = mount(BreadcrumbLink, {
118
+ props: { href: '/' },
119
+ slots: { default: 'Home' }
120
+ })
121
+ expect(wrapper.find('a').classes()).toContain('ui-breadcrumb__link')
122
+ })
123
+
124
+ it('renders slot content', () => {
125
+ const wrapper = mount(BreadcrumbLink, {
126
+ props: { href: '/' },
127
+ slots: { default: '<span data-testid="text">Home</span>' }
128
+ })
129
+ expect(wrapper.find('[data-testid="text"]').exists()).toBe(true)
130
+ })
131
+
132
+ it('renders custom component via as prop', () => {
133
+ const CustomLink = {
134
+ template: '<span class="custom-link"><slot /></span>'
135
+ }
136
+ const wrapper = mount(BreadcrumbLink, {
137
+ props: { as: CustomLink },
138
+ slots: { default: 'Custom' }
139
+ })
140
+ expect(wrapper.find('.custom-link').exists()).toBe(true)
141
+ })
142
+ })
143
+
144
+ describe('BreadcrumbPage', () => {
145
+ it('renders as span', () => {
146
+ const wrapper = mount(BreadcrumbPage, {
147
+ slots: { default: 'Current Page' }
148
+ })
149
+ expect(wrapper.find('span').exists()).toBe(true)
150
+ })
151
+
152
+ it('has aria-current page attribute', () => {
153
+ const wrapper = mount(BreadcrumbPage, {
154
+ slots: { default: 'Current Page' }
155
+ })
156
+ expect(wrapper.find('span').attributes('aria-current')).toBe('page')
157
+ })
158
+
159
+ it('has aria-disabled attribute', () => {
160
+ const wrapper = mount(BreadcrumbPage, {
161
+ slots: { default: 'Current Page' }
162
+ })
163
+ expect(wrapper.find('span').attributes('aria-disabled')).toBe('true')
164
+ })
165
+
166
+ it('has role link', () => {
167
+ const wrapper = mount(BreadcrumbPage, {
168
+ slots: { default: 'Current Page' }
169
+ })
170
+ expect(wrapper.find('span').attributes('role')).toBe('link')
171
+ })
172
+
173
+ it('applies page class', () => {
174
+ const wrapper = mount(BreadcrumbPage, {
175
+ slots: { default: 'Current Page' }
176
+ })
177
+ expect(wrapper.find('span').classes()).toContain('ui-breadcrumb__page')
178
+ })
179
+ })
180
+
181
+ describe('BreadcrumbSeparator', () => {
182
+ it('renders as list item', () => {
183
+ const wrapper = mount(BreadcrumbSeparator)
184
+ expect(wrapper.find('li').exists()).toBe(true)
185
+ })
186
+
187
+ it('has aria-hidden for accessibility', () => {
188
+ const wrapper = mount(BreadcrumbSeparator)
189
+ expect(wrapper.find('li').attributes('aria-hidden')).toBe('true')
190
+ })
191
+
192
+ it('has presentation role', () => {
193
+ const wrapper = mount(BreadcrumbSeparator)
194
+ expect(wrapper.find('li').attributes('role')).toBe('presentation')
195
+ })
196
+
197
+ it('applies separator class', () => {
198
+ const wrapper = mount(BreadcrumbSeparator)
199
+ expect(wrapper.find('li').classes()).toContain('ui-breadcrumb__separator')
200
+ })
201
+
202
+ it('renders default separator from root', () => {
203
+ const wrapper = mount(BreadcrumbRoot, {
204
+ props: { separator: '>' },
205
+ slots: {
206
+ default: () => h(BreadcrumbSeparator)
207
+ }
208
+ })
209
+ expect(wrapper.text()).toContain('>')
210
+ })
211
+
212
+ it('renders custom slot content', () => {
213
+ const wrapper = mount(BreadcrumbSeparator, {
214
+ slots: {
215
+ default: '<span data-testid="custom">/</span>'
216
+ }
217
+ })
218
+ expect(wrapper.find('[data-testid="custom"]').exists()).toBe(true)
219
+ })
220
+ })
221
+
222
+ describe('BreadcrumbEllipsis', () => {
223
+ it('renders as list item', () => {
224
+ const wrapper = mount(BreadcrumbEllipsis)
225
+ expect(wrapper.find('li').exists()).toBe(true)
226
+ })
227
+
228
+ it('renders static ellipsis without items', () => {
229
+ const wrapper = mount(BreadcrumbEllipsis)
230
+ expect(wrapper.find('.ui-breadcrumb__ellipsis-static').exists()).toBe(true)
231
+ })
232
+
233
+ it('renders dropdown trigger with items', () => {
234
+ const wrapper = mount(BreadcrumbEllipsis, {
235
+ props: {
236
+ items: [
237
+ { label: 'Products', href: '/products' },
238
+ { label: 'Category', href: '/products/category' }
239
+ ]
240
+ }
241
+ })
242
+ expect(wrapper.find('.ui-breadcrumb__ellipsis').exists()).toBe(true)
243
+ })
244
+
245
+ it('has aria-label on ellipsis button', () => {
246
+ const wrapper = mount(BreadcrumbEllipsis, {
247
+ props: {
248
+ items: [{ label: 'Products', href: '/products' }]
249
+ }
250
+ })
251
+ expect(wrapper.find('.ui-breadcrumb__ellipsis').attributes('aria-label')).toBe('Show more breadcrumbs')
252
+ })
253
+ })
254
+
255
+ describe('Breadcrumb composition', () => {
256
+ it('renders full breadcrumb structure', () => {
257
+ const wrapper = mount(BreadcrumbRoot, {
258
+ slots: {
259
+ default: () => h(BreadcrumbList, {}, () => [
260
+ h(BreadcrumbItem, {}, () => h(BreadcrumbLink, { href: '/' }, () => 'Home')),
261
+ h(BreadcrumbSeparator),
262
+ h(BreadcrumbItem, {}, () => h(BreadcrumbLink, { href: '/products' }, () => 'Products')),
263
+ h(BreadcrumbSeparator),
264
+ h(BreadcrumbItem, {}, () => h(BreadcrumbPage, {}, () => 'Widget'))
265
+ ])
266
+ }
267
+ })
268
+
269
+ expect(wrapper.find('nav').exists()).toBe(true)
270
+ expect(wrapper.find('ol').exists()).toBe(true)
271
+ expect(wrapper.findAll('li').length).toBe(5)
272
+ expect(wrapper.findAll('.ui-breadcrumb__link').length).toBe(2)
273
+ expect(wrapper.find('.ui-breadcrumb__page').exists()).toBe(true)
274
+ expect(wrapper.findAll('.ui-breadcrumb__separator').length).toBe(2)
275
+ })
276
+
277
+ it('renders with custom separator', () => {
278
+ const ChevronIcon = {
279
+ template: '<svg data-testid="chevron">→</svg>'
280
+ }
281
+
282
+ const wrapper = mount(BreadcrumbRoot, {
283
+ props: { separator: ChevronIcon },
284
+ slots: {
285
+ default: () => h(BreadcrumbList, {}, () => [
286
+ h(BreadcrumbItem, {}, () => h(BreadcrumbLink, { href: '/' }, () => 'Home')),
287
+ h(BreadcrumbSeparator),
288
+ h(BreadcrumbItem, {}, () => h(BreadcrumbPage, {}, () => 'Current'))
289
+ ])
290
+ }
291
+ })
292
+
293
+ expect(wrapper.find('[data-testid="chevron"]').exists()).toBe(true)
294
+ })
295
+
296
+ it('renders with ellipsis for collapsed items', () => {
297
+ const wrapper = mount(BreadcrumbRoot, {
298
+ slots: {
299
+ default: () => h(BreadcrumbList, {}, () => [
300
+ h(BreadcrumbItem, {}, () => h(BreadcrumbLink, { href: '/' }, () => 'Home')),
301
+ h(BreadcrumbSeparator),
302
+ h(BreadcrumbEllipsis, {
303
+ items: [
304
+ { label: 'Products', href: '/products' },
305
+ { label: 'Category', href: '/products/category' }
306
+ ]
307
+ }),
308
+ h(BreadcrumbSeparator),
309
+ h(BreadcrumbItem, {}, () => h(BreadcrumbPage, {}, () => 'Widget'))
310
+ ])
311
+ }
312
+ })
313
+
314
+ expect(wrapper.find('.ui-breadcrumb__ellipsis').exists()).toBe(true)
315
+ expect(wrapper.find('.ui-breadcrumb__page').text()).toBe('Widget')
316
+ })
317
+ })
318
+
319
+ describe('Breadcrumb accessibility', () => {
320
+ it('has proper landmark role', () => {
321
+ const wrapper = mount(BreadcrumbRoot)
322
+ expect(wrapper.find('nav').exists()).toBe(true)
323
+ expect(wrapper.find('nav').attributes('aria-label')).toBe('Breadcrumb')
324
+ })
325
+
326
+ it('uses ordered list for semantic structure', () => {
327
+ const wrapper = mount(BreadcrumbList)
328
+ expect(wrapper.find('ol').exists()).toBe(true)
329
+ })
330
+
331
+ it('current page has aria-current', () => {
332
+ const wrapper = mount(BreadcrumbPage, {
333
+ slots: { default: 'Current' }
334
+ })
335
+ expect(wrapper.find('[aria-current="page"]').exists()).toBe(true)
336
+ })
337
+
338
+ it('separators are hidden from screen readers', () => {
339
+ const wrapper = mount(BreadcrumbSeparator)
340
+ expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
341
+ })
342
+ })
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { Dropdown, DropdownItem } from '../Dropdown'
3
+
4
+ export interface BreadcrumbEllipsisItem {
5
+ /** Display label */
6
+ label: string
7
+ /** URL for standard anchor */
8
+ href?: string
9
+ /** Route location for vue-router (string or route object) */
10
+ to?: string | Record<string, unknown>
11
+ }
12
+
13
+ export interface BreadcrumbEllipsisProps {
14
+ /** Hidden breadcrumb items to show in dropdown */
15
+ items?: BreadcrumbEllipsisItem[]
16
+ }
17
+
18
+ const props = withDefaults(defineProps<BreadcrumbEllipsisProps>(), {
19
+ items: () => []
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <li class="ui-breadcrumb__item">
25
+ <Dropdown v-if="items.length > 0" placement="bottom-start">
26
+ <template #trigger>
27
+ <button
28
+ type="button"
29
+ class="ui-breadcrumb__ellipsis"
30
+ aria-label="Show more breadcrumbs"
31
+ >
32
+ <svg
33
+ width="16"
34
+ height="16"
35
+ viewBox="0 0 24 24"
36
+ fill="currentColor"
37
+ aria-hidden="true"
38
+ >
39
+ <circle cx="12" cy="12" r="1.5" />
40
+ <circle cx="6" cy="12" r="1.5" />
41
+ <circle cx="18" cy="12" r="1.5" />
42
+ </svg>
43
+ </button>
44
+ </template>
45
+
46
+ <DropdownItem
47
+ v-for="(item, index) in items"
48
+ :key="index"
49
+ :href="item.href"
50
+ :to="item.to"
51
+ >
52
+ {{ item.label }}
53
+ </DropdownItem>
54
+ </Dropdown>
55
+
56
+ <span v-else class="ui-breadcrumb__ellipsis-static" aria-hidden="true">
57
+ <slot>…</slot>
58
+ </span>
59
+ </li>
60
+ </template>
61
+
62
+ <style scoped>
63
+ .ui-breadcrumb__ellipsis {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ width: 24px;
68
+ height: 24px;
69
+ padding: 0;
70
+ background: transparent;
71
+ border: none;
72
+ border-radius: var(--radius-sm);
73
+ color: var(--breadcrumb-ellipsis, var(--text-secondary));
74
+ cursor: pointer;
75
+ transition:
76
+ color var(--duration-fast) var(--ease-default),
77
+ background-color var(--duration-fast) var(--ease-default);
78
+ }
79
+
80
+ .ui-breadcrumb__ellipsis:hover {
81
+ color: var(--breadcrumb-ellipsis-hover, var(--text-primary));
82
+ background-color: var(--breadcrumb-ellipsis-bg-hover, var(--surface-hover));
83
+ }
84
+
85
+ .ui-breadcrumb__ellipsis:focus-visible {
86
+ outline: 2px solid var(--focus-ring);
87
+ outline-offset: 2px;
88
+ }
89
+
90
+ .ui-breadcrumb__ellipsis-static {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ padding: var(--space-1);
94
+ color: var(--breadcrumb-ellipsis, var(--text-secondary));
95
+ }
96
+ </style>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <li class="ui-breadcrumb__item">
6
+ <slot />
7
+ </li>
8
+ </template>
9
+
10
+ <style scoped>
11
+ .ui-breadcrumb__item {
12
+ display: inline-flex;
13
+ align-items: center;
14
+ gap: var(--space-1);
15
+ }
16
+ </style>
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { computed, type Component } from 'vue'
3
+
4
+ export interface BreadcrumbLinkProps {
5
+ /** URL for standard anchor */
6
+ href?: string
7
+ /** Route location for vue-router (string or route object) */
8
+ to?: string | Record<string, unknown>
9
+ /** Custom component to render as */
10
+ as?: string | Component
11
+ }
12
+
13
+ const props = defineProps<BreadcrumbLinkProps>()
14
+
15
+ const componentType = computed(() => {
16
+ if (props.as) return props.as
17
+ if (props.to) return 'router-link'
18
+ if (props.href) return 'a'
19
+ return 'a'
20
+ })
21
+
22
+ const linkProps = computed(() => {
23
+ if (props.to) {
24
+ return { to: props.to }
25
+ }
26
+ if (props.href) {
27
+ return { href: props.href }
28
+ }
29
+ return {}
30
+ })
31
+ </script>
32
+
33
+ <template>
34
+ <component
35
+ :is="componentType"
36
+ v-bind="linkProps"
37
+ class="ui-breadcrumb__link"
38
+ >
39
+ <slot />
40
+ </component>
41
+ </template>
42
+
43
+ <style scoped>
44
+ .ui-breadcrumb__link {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ gap: var(--space-1);
48
+ padding: var(--space-1);
49
+ margin: calc(var(--space-1) * -1);
50
+ color: var(--breadcrumb-link, var(--text-secondary));
51
+ text-decoration: none;
52
+ border-radius: var(--radius-sm);
53
+ transition:
54
+ color var(--duration-fast) var(--ease-default),
55
+ background-color var(--duration-fast) var(--ease-default);
56
+ }
57
+
58
+ .ui-breadcrumb__link:hover {
59
+ color: var(--breadcrumb-link-hover, var(--text-primary));
60
+ text-decoration: underline;
61
+ }
62
+
63
+ .ui-breadcrumb__link:focus-visible {
64
+ outline: 2px solid var(--focus-ring);
65
+ outline-offset: 2px;
66
+ }
67
+ </style>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <ol class="ui-breadcrumb__list">
6
+ <slot />
7
+ </ol>
8
+ </template>
9
+
10
+ <style scoped>
11
+ .ui-breadcrumb__list {
12
+ display: flex;
13
+ flex-wrap: wrap;
14
+ align-items: center;
15
+ gap: var(--space-1);
16
+ margin: 0;
17
+ padding: 0;
18
+ list-style: none;
19
+ }
20
+ </style>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <span
6
+ role="link"
7
+ aria-disabled="true"
8
+ aria-current="page"
9
+ class="ui-breadcrumb__page"
10
+ >
11
+ <slot />
12
+ </span>
13
+ </template>
14
+
15
+ <style scoped>
16
+ .ui-breadcrumb__page {
17
+ display: inline-flex;
18
+ align-items: center;
19
+ gap: var(--space-1);
20
+ padding: var(--space-1);
21
+ margin: calc(var(--space-1) * -1);
22
+ color: var(--breadcrumb-current, var(--text-primary));
23
+ font-weight: var(--font-medium);
24
+ }
25
+ </style>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { provide, useSlots } from 'vue'
3
+ import { BreadcrumbKey } from './keys'
4
+ import type { Component, VNode } from 'vue'
5
+
6
+ export interface BreadcrumbRootProps {
7
+ /** Custom separator component or string */
8
+ separator?: Component | string | (() => VNode)
9
+ }
10
+
11
+ const props = withDefaults(defineProps<BreadcrumbRootProps>(), {
12
+ separator: '/'
13
+ })
14
+
15
+ defineSlots<{
16
+ default?(): unknown
17
+ separator?(): unknown
18
+ }>()
19
+
20
+ const slots = useSlots()
21
+
22
+ provide(BreadcrumbKey, {
23
+ separator: (slots as Record<string, unknown>).separator
24
+ ? () => (slots as { separator: () => VNode[] }).separator()
25
+ : props.separator
26
+ })
27
+ </script>
28
+
29
+ <template>
30
+ <nav aria-label="Breadcrumb" class="ui-breadcrumb">
31
+ <slot />
32
+ </nav>
33
+ </template>
34
+
35
+ <style scoped>
36
+ .ui-breadcrumb {
37
+ font-family: var(--font-sans);
38
+ font-size: var(--text-sm);
39
+ line-height: var(--leading-normal);
40
+ }
41
+ </style>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import { inject, useSlots, h, isVNode } from 'vue'
3
+ import type { Component, VNode } from 'vue'
4
+ import { BreadcrumbKey } from './keys'
5
+
6
+ defineSlots<{
7
+ default(): unknown
8
+ }>()
9
+
10
+ const breadcrumb = inject(BreadcrumbKey)
11
+ const slots = useSlots() as { default?: () => VNode[] }
12
+
13
+ function renderSeparator(): VNode | string {
14
+ if (slots.default) {
15
+ return slots.default()[0]
16
+ }
17
+
18
+ if (!breadcrumb) {
19
+ return '/'
20
+ }
21
+
22
+ const sep = breadcrumb.separator
23
+
24
+ if (typeof sep === 'string') {
25
+ return sep
26
+ }
27
+
28
+ if (typeof sep === 'function') {
29
+ const result = (sep as () => VNode | VNode[])()
30
+ if (isVNode(result)) {
31
+ return result
32
+ }
33
+ if (Array.isArray(result) && result.length > 0) {
34
+ return result[0]
35
+ }
36
+ }
37
+
38
+ return h(sep as Component)
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <li
44
+ role="presentation"
45
+ aria-hidden="true"
46
+ class="ui-breadcrumb__separator"
47
+ >
48
+ <component :is="() => renderSeparator()" />
49
+ </li>
50
+ </template>
51
+
52
+ <style scoped>
53
+ .ui-breadcrumb__separator {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ color: var(--breadcrumb-separator, var(--text-tertiary));
57
+ }
58
+
59
+ .ui-breadcrumb__separator :deep(svg) {
60
+ width: 16px;
61
+ height: 16px;
62
+ }
63
+ </style>
@@ -0,0 +1,13 @@
1
+ export { default as BreadcrumbRoot } from './BreadcrumbRoot.vue'
2
+ export { default as BreadcrumbList } from './BreadcrumbList.vue'
3
+ export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
4
+ export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
5
+ export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
6
+ export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'
7
+ export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
8
+
9
+ export type { BreadcrumbRootProps } from './BreadcrumbRoot.vue'
10
+ export type { BreadcrumbLinkProps } from './BreadcrumbLink.vue'
11
+ export type { BreadcrumbEllipsisProps, BreadcrumbEllipsisItem } from './BreadcrumbEllipsis.vue'
12
+ export type { BreadcrumbContext } from './keys'
13
+ export { BreadcrumbKey } from './keys'
@@ -0,0 +1,7 @@
1
+ import type { InjectionKey, Component, VNode } from 'vue'
2
+
3
+ export interface BreadcrumbContext {
4
+ separator: Component | string | (() => VNode)
5
+ }
6
+
7
+ export const BreadcrumbKey: InjectionKey<BreadcrumbContext> = Symbol('breadcrumb')