@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,363 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import Tabs from './Tabs.vue'
5
+
6
+ const defaultItems = [
7
+ { label: 'Tab 1', value: 'tab1' },
8
+ { label: 'Tab 2', value: 'tab2' },
9
+ { label: 'Tab 3', value: 'tab3' }
10
+ ]
11
+
12
+ // Mock ResizeObserver
13
+ class ResizeObserverMock {
14
+ observe = vi.fn()
15
+ unobserve = vi.fn()
16
+ disconnect = vi.fn()
17
+ }
18
+
19
+ beforeEach(() => {
20
+ global.ResizeObserver = ResizeObserverMock as any
21
+ })
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks()
25
+ })
26
+
27
+ describe('Tabs', () => {
28
+ describe('Rendering', () => {
29
+ it('renders tab list with correct role', () => {
30
+ const wrapper = mount(Tabs, {
31
+ props: { modelValue: 'tab1', items: defaultItems }
32
+ })
33
+ expect(wrapper.find('[role="tablist"]').exists()).toBe(true)
34
+ })
35
+
36
+ it('renders all tab buttons', () => {
37
+ const wrapper = mount(Tabs, {
38
+ props: { modelValue: 'tab1', items: defaultItems }
39
+ })
40
+ const tabs = wrapper.findAll('[role="tab"]')
41
+ expect(tabs).toHaveLength(3)
42
+ })
43
+
44
+ it('renders tab labels', () => {
45
+ const wrapper = mount(Tabs, {
46
+ props: { modelValue: 'tab1', items: defaultItems }
47
+ })
48
+ expect(wrapper.text()).toContain('Tab 1')
49
+ expect(wrapper.text()).toContain('Tab 2')
50
+ expect(wrapper.text()).toContain('Tab 3')
51
+ })
52
+
53
+ it('renders magic line indicator', () => {
54
+ const wrapper = mount(Tabs, {
55
+ props: { modelValue: 'tab1', items: defaultItems }
56
+ })
57
+ expect(wrapper.find('.ui-tabs__indicator').exists()).toBe(true)
58
+ })
59
+
60
+ it('renders tab panels', () => {
61
+ const wrapper = mount(Tabs, {
62
+ props: { modelValue: 'tab1', items: defaultItems }
63
+ })
64
+ const panels = wrapper.findAll('[role="tabpanel"]')
65
+ expect(panels).toHaveLength(3)
66
+ })
67
+ })
68
+
69
+ describe('Active state', () => {
70
+ it('marks active tab with aria-selected true', () => {
71
+ const wrapper = mount(Tabs, {
72
+ props: { modelValue: 'tab2', items: defaultItems }
73
+ })
74
+ const tabs = wrapper.findAll('[role="tab"]')
75
+ expect(tabs[0].attributes('aria-selected')).toBe('false')
76
+ expect(tabs[1].attributes('aria-selected')).toBe('true')
77
+ expect(tabs[2].attributes('aria-selected')).toBe('false')
78
+ })
79
+
80
+ it('applies active class to active tab', () => {
81
+ const wrapper = mount(Tabs, {
82
+ props: { modelValue: 'tab2', items: defaultItems }
83
+ })
84
+ const tabs = wrapper.findAll('[role="tab"]')
85
+ expect(tabs[1].classes()).toContain('ui-tabs__tab--active')
86
+ })
87
+
88
+ it('shows only active panel', () => {
89
+ const wrapper = mount(Tabs, {
90
+ props: { modelValue: 'tab2', items: defaultItems },
91
+ slots: {
92
+ tab1: '<div>Content 1</div>',
93
+ tab2: '<div>Content 2</div>',
94
+ tab3: '<div>Content 3</div>'
95
+ }
96
+ })
97
+ const panels = wrapper.findAll('[role="tabpanel"]')
98
+ // v-show uses display:none, check via style attribute
99
+ expect(panels[0].attributes('style')).toContain('display: none')
100
+ expect(panels[1].attributes('style')).toBeUndefined()
101
+ expect(panels[2].attributes('style')).toContain('display: none')
102
+ })
103
+ })
104
+
105
+ describe('Tab selection', () => {
106
+ it('emits update:modelValue when tab clicked', async () => {
107
+ const wrapper = mount(Tabs, {
108
+ props: { modelValue: 'tab1', items: defaultItems }
109
+ })
110
+ const tabs = wrapper.findAll('[role="tab"]')
111
+ await tabs[1].trigger('click')
112
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab2']])
113
+ })
114
+
115
+ it('does not emit when disabled tab clicked', async () => {
116
+ const items = [
117
+ { label: 'Tab 1', value: 'tab1' },
118
+ { label: 'Tab 2', value: 'tab2', disabled: true }
119
+ ]
120
+ const wrapper = mount(Tabs, {
121
+ props: { modelValue: 'tab1', items }
122
+ })
123
+ const tabs = wrapper.findAll('[role="tab"]')
124
+ await tabs[1].trigger('click')
125
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
126
+ })
127
+ })
128
+
129
+ describe('Disabled tabs', () => {
130
+ it('applies disabled attribute to disabled tabs', () => {
131
+ const items = [
132
+ { label: 'Tab 1', value: 'tab1' },
133
+ { label: 'Tab 2', value: 'tab2', disabled: true }
134
+ ]
135
+ const wrapper = mount(Tabs, {
136
+ props: { modelValue: 'tab1', items }
137
+ })
138
+ const tabs = wrapper.findAll('[role="tab"]')
139
+ expect(tabs[0].attributes('disabled')).toBeUndefined()
140
+ expect(tabs[1].attributes('disabled')).toBeDefined()
141
+ })
142
+ })
143
+
144
+ describe('Keyboard navigation', () => {
145
+ it('moves focus right with ArrowRight', async () => {
146
+ const wrapper = mount(Tabs, {
147
+ props: { modelValue: 'tab1', items: defaultItems },
148
+ attachTo: document.body
149
+ })
150
+ const tabs = wrapper.findAll('[role="tab"]')
151
+ await tabs[0].trigger('keydown', { key: 'ArrowRight' })
152
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab2']])
153
+ wrapper.unmount()
154
+ })
155
+
156
+ it('moves focus left with ArrowLeft', async () => {
157
+ const wrapper = mount(Tabs, {
158
+ props: { modelValue: 'tab2', items: defaultItems },
159
+ attachTo: document.body
160
+ })
161
+ const tabs = wrapper.findAll('[role="tab"]')
162
+ await tabs[1].trigger('keydown', { key: 'ArrowLeft' })
163
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab1']])
164
+ wrapper.unmount()
165
+ })
166
+
167
+ it('wraps around from last to first', async () => {
168
+ const wrapper = mount(Tabs, {
169
+ props: { modelValue: 'tab3', items: defaultItems },
170
+ attachTo: document.body
171
+ })
172
+ const tabs = wrapper.findAll('[role="tab"]')
173
+ await tabs[2].trigger('keydown', { key: 'ArrowRight' })
174
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab1']])
175
+ wrapper.unmount()
176
+ })
177
+
178
+ it('wraps around from first to last', async () => {
179
+ const wrapper = mount(Tabs, {
180
+ props: { modelValue: 'tab1', items: defaultItems },
181
+ attachTo: document.body
182
+ })
183
+ const tabs = wrapper.findAll('[role="tab"]')
184
+ await tabs[0].trigger('keydown', { key: 'ArrowLeft' })
185
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab3']])
186
+ wrapper.unmount()
187
+ })
188
+
189
+ it('skips disabled tabs', async () => {
190
+ const items = [
191
+ { label: 'Tab 1', value: 'tab1' },
192
+ { label: 'Tab 2', value: 'tab2', disabled: true },
193
+ { label: 'Tab 3', value: 'tab3' }
194
+ ]
195
+ const wrapper = mount(Tabs, {
196
+ props: { modelValue: 'tab1', items },
197
+ attachTo: document.body
198
+ })
199
+ const tabs = wrapper.findAll('[role="tab"]')
200
+ await tabs[0].trigger('keydown', { key: 'ArrowRight' })
201
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab3']])
202
+ wrapper.unmount()
203
+ })
204
+
205
+ it('goes to first tab with Home key', async () => {
206
+ const wrapper = mount(Tabs, {
207
+ props: { modelValue: 'tab3', items: defaultItems },
208
+ attachTo: document.body
209
+ })
210
+ const tabs = wrapper.findAll('[role="tab"]')
211
+ await tabs[2].trigger('keydown', { key: 'Home' })
212
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab1']])
213
+ wrapper.unmount()
214
+ })
215
+
216
+ it('goes to last tab with End key', async () => {
217
+ const wrapper = mount(Tabs, {
218
+ props: { modelValue: 'tab1', items: defaultItems },
219
+ attachTo: document.body
220
+ })
221
+ const tabs = wrapper.findAll('[role="tab"]')
222
+ await tabs[0].trigger('keydown', { key: 'End' })
223
+ expect(wrapper.emitted('update:modelValue')).toEqual([['tab3']])
224
+ wrapper.unmount()
225
+ })
226
+ })
227
+
228
+ describe('Roving tabindex', () => {
229
+ it('only active tab has tabindex 0', () => {
230
+ const wrapper = mount(Tabs, {
231
+ props: { modelValue: 'tab2', items: defaultItems }
232
+ })
233
+ const tabs = wrapper.findAll('[role="tab"]')
234
+ expect(tabs[0].attributes('tabindex')).toBe('-1')
235
+ expect(tabs[1].attributes('tabindex')).toBe('0')
236
+ expect(tabs[2].attributes('tabindex')).toBe('-1')
237
+ })
238
+ })
239
+
240
+ describe('Variants', () => {
241
+ it('applies line variant class by default', () => {
242
+ const wrapper = mount(Tabs, {
243
+ props: { modelValue: 'tab1', items: defaultItems }
244
+ })
245
+ expect(wrapper.find('.ui-tabs__list--line').exists()).toBe(true)
246
+ })
247
+
248
+ it('applies pill variant class', () => {
249
+ const wrapper = mount(Tabs, {
250
+ props: { modelValue: 'tab1', items: defaultItems, variant: 'pill' }
251
+ })
252
+ expect(wrapper.find('.ui-tabs__list--pill').exists()).toBe(true)
253
+ })
254
+ })
255
+
256
+ describe('Block mode', () => {
257
+ it('applies block class when block prop is true', () => {
258
+ const wrapper = mount(Tabs, {
259
+ props: { modelValue: 'tab1', items: defaultItems, block: true }
260
+ })
261
+ expect(wrapper.find('.ui-tabs__list--block').exists()).toBe(true)
262
+ })
263
+ })
264
+
265
+ describe('Slots', () => {
266
+ it('renders named slot content for active tab', () => {
267
+ const wrapper = mount(Tabs, {
268
+ props: { modelValue: 'tab1', items: defaultItems },
269
+ slots: {
270
+ tab1: '<div class="custom-content">Custom Content</div>'
271
+ }
272
+ })
273
+ expect(wrapper.find('.custom-content').exists()).toBe(true)
274
+ })
275
+
276
+ it('slot name matches tab value', () => {
277
+ const items = [
278
+ { label: 'Account', value: 'account' },
279
+ { label: 'Settings', value: 'settings' }
280
+ ]
281
+ const wrapper = mount(Tabs, {
282
+ props: { modelValue: 'settings', items },
283
+ slots: {
284
+ account: '<div class="account-content">Account</div>',
285
+ settings: '<div class="settings-content">Settings</div>'
286
+ }
287
+ })
288
+ // Active panel has no display:none style, inactive has display:none
289
+ const settingsPanel = wrapper.find('#tabpanel-settings')
290
+ const accountPanel = wrapper.find('#tabpanel-account')
291
+ expect(settingsPanel.attributes('style')).toBeUndefined()
292
+ expect(accountPanel.attributes('style')).toContain('display: none')
293
+ })
294
+ })
295
+
296
+ describe('Accessibility', () => {
297
+ it('tabs have role="tab"', () => {
298
+ const wrapper = mount(Tabs, {
299
+ props: { modelValue: 'tab1', items: defaultItems }
300
+ })
301
+ wrapper.findAll('button').forEach(btn => {
302
+ expect(btn.attributes('role')).toBe('tab')
303
+ })
304
+ })
305
+
306
+ it('panels have role="tabpanel"', () => {
307
+ const wrapper = mount(Tabs, {
308
+ props: { modelValue: 'tab1', items: defaultItems }
309
+ })
310
+ wrapper.findAll('.ui-tabs__panel').forEach(panel => {
311
+ expect(panel.attributes('role')).toBe('tabpanel')
312
+ })
313
+ })
314
+
315
+ it('tabs have aria-controls linking to panels', () => {
316
+ const wrapper = mount(Tabs, {
317
+ props: { modelValue: 'tab1', items: defaultItems }
318
+ })
319
+ const tabs = wrapper.findAll('[role="tab"]')
320
+ expect(tabs[0].attributes('aria-controls')).toBe('tabpanel-tab1')
321
+ expect(tabs[1].attributes('aria-controls')).toBe('tabpanel-tab2')
322
+ })
323
+
324
+ it('panels have matching ids', () => {
325
+ const wrapper = mount(Tabs, {
326
+ props: { modelValue: 'tab1', items: defaultItems }
327
+ })
328
+ const panels = wrapper.findAll('[role="tabpanel"]')
329
+ expect(panels[0].attributes('id')).toBe('tabpanel-tab1')
330
+ expect(panels[1].attributes('id')).toBe('tabpanel-tab2')
331
+ })
332
+
333
+ it('panels are focusable with tabindex', () => {
334
+ const wrapper = mount(Tabs, {
335
+ props: { modelValue: 'tab1', items: defaultItems }
336
+ })
337
+ const panels = wrapper.findAll('[role="tabpanel"]')
338
+ panels.forEach(panel => {
339
+ expect(panel.attributes('tabindex')).toBe('0')
340
+ })
341
+ })
342
+
343
+ it('indicator is hidden from accessibility tree', () => {
344
+ const wrapper = mount(Tabs, {
345
+ props: { modelValue: 'tab1', items: defaultItems }
346
+ })
347
+ expect(wrapper.find('.ui-tabs__indicator').attributes('aria-hidden')).toBe('true')
348
+ })
349
+ })
350
+
351
+ describe('Icons', () => {
352
+ it('renders icon when provided', () => {
353
+ const MockIcon = { template: '<svg class="mock-icon"></svg>' }
354
+ const items = [
355
+ { label: 'Tab 1', value: 'tab1', icon: MockIcon }
356
+ ]
357
+ const wrapper = mount(Tabs, {
358
+ props: { modelValue: 'tab1', items }
359
+ })
360
+ expect(wrapper.find('.ui-tabs__icon').exists()).toBe(true)
361
+ })
362
+ })
363
+ })
@@ -0,0 +1,318 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
3
+ import Icon from '../Icon/Icon.vue'
4
+ import type { IconInput } from '../Icon/Icon.vue'
5
+
6
+ export interface TabItem {
7
+ /** Display label */
8
+ label: string
9
+ /** Unique value (used for v-model and slot name) */
10
+ value: string | number
11
+ /** Disable this tab */
12
+ disabled?: boolean
13
+ /** Optional icon */
14
+ icon?: IconInput
15
+ }
16
+
17
+ export interface TabsProps {
18
+ /** Currently active tab value (v-model) */
19
+ modelValue: string | number
20
+ /** Tab items configuration */
21
+ items: TabItem[]
22
+ /** Visual variant */
23
+ variant?: 'line' | 'pill'
24
+ /** Full width tabs */
25
+ block?: boolean
26
+ }
27
+
28
+ const props = withDefaults(defineProps<TabsProps>(), {
29
+ variant: 'line',
30
+ block: false
31
+ })
32
+
33
+ const emit = defineEmits<{
34
+ (e: 'update:modelValue', value: string | number): void
35
+ }>()
36
+
37
+ const tabListRef = ref<HTMLElement | null>(null)
38
+ const tabRefs = ref<HTMLButtonElement[]>([])
39
+
40
+ const lineStyle = ref({ width: '0px', transform: 'translateX(0px)' })
41
+
42
+ let resizeObserver: ResizeObserver | null = null
43
+
44
+ /**
45
+ * Calculate and update the magic line position
46
+ */
47
+ function updateLine() {
48
+ const activeIndex = props.items.findIndex(item => item.value === props.modelValue)
49
+ if (activeIndex === -1 || !tabRefs.value[activeIndex]) return
50
+
51
+ const activeTab = tabRefs.value[activeIndex]
52
+ const width = activeTab.offsetWidth
53
+ const left = activeTab.offsetLeft
54
+
55
+ lineStyle.value = {
56
+ width: `${width}px`,
57
+ transform: `translateX(${left}px)`
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Select a tab
63
+ */
64
+ function selectTab(item: TabItem) {
65
+ if (item.disabled) return
66
+ emit('update:modelValue', item.value)
67
+ }
68
+
69
+ /**
70
+ * Handle keyboard navigation (roving tabindex)
71
+ */
72
+ function handleKeydown(e: KeyboardEvent, index: number) {
73
+ const isRight = e.key === 'ArrowRight'
74
+ const isLeft = e.key === 'ArrowLeft'
75
+ const isHome = e.key === 'Home'
76
+ const isEnd = e.key === 'End'
77
+
78
+ if (!isRight && !isLeft && !isHome && !isEnd) return
79
+
80
+ e.preventDefault()
81
+
82
+ let nextIndex: number
83
+
84
+ if (isHome) {
85
+ nextIndex = props.items.findIndex(item => !item.disabled)
86
+ } else if (isEnd) {
87
+ nextIndex = props.items.length - 1 - [...props.items].reverse().findIndex(item => !item.disabled)
88
+ } else {
89
+ const direction = isRight ? 1 : -1
90
+ nextIndex = index
91
+
92
+ do {
93
+ nextIndex = (nextIndex + direction + props.items.length) % props.items.length
94
+ } while (props.items[nextIndex].disabled && nextIndex !== index)
95
+ }
96
+
97
+ const nextItem = props.items[nextIndex]
98
+ if (!nextItem.disabled) {
99
+ tabRefs.value[nextIndex]?.focus()
100
+ emit('update:modelValue', nextItem.value)
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Set tab ref at index
106
+ */
107
+ function setTabRef(el: HTMLButtonElement | null, index: number) {
108
+ if (el) {
109
+ tabRefs.value[index] = el
110
+ }
111
+ }
112
+
113
+ watch(() => props.modelValue, async () => {
114
+ await nextTick()
115
+ updateLine()
116
+ })
117
+
118
+ onMounted(() => {
119
+ updateLine()
120
+
121
+ resizeObserver = new ResizeObserver(() => {
122
+ updateLine()
123
+ })
124
+
125
+ if (tabListRef.value) {
126
+ resizeObserver.observe(tabListRef.value)
127
+ }
128
+
129
+ window.addEventListener('resize', updateLine)
130
+ })
131
+
132
+ onBeforeUnmount(() => {
133
+ resizeObserver?.disconnect()
134
+ window.removeEventListener('resize', updateLine)
135
+ })
136
+ </script>
137
+
138
+ <template>
139
+ <div class="ui-tabs">
140
+ <div
141
+ ref="tabListRef"
142
+ class="ui-tabs__list"
143
+ :class="[
144
+ `ui-tabs__list--${variant}`,
145
+ { 'ui-tabs__list--block': block }
146
+ ]"
147
+ role="tablist"
148
+ >
149
+ <button
150
+ v-for="(item, index) in items"
151
+ :key="item.value"
152
+ :ref="(el) => setTabRef(el as HTMLButtonElement, index)"
153
+ type="button"
154
+ role="tab"
155
+ class="ui-tabs__tab"
156
+ :class="{ 'ui-tabs__tab--active': modelValue === item.value }"
157
+ :aria-selected="modelValue === item.value"
158
+ :aria-controls="`tabpanel-${item.value}`"
159
+ :disabled="item.disabled"
160
+ :tabindex="modelValue === item.value ? 0 : -1"
161
+ @click="selectTab(item)"
162
+ @keydown="handleKeydown($event, index)"
163
+ >
164
+ <Icon
165
+ v-if="item.icon"
166
+ :icon="item.icon"
167
+ size="sm"
168
+ class="ui-tabs__icon"
169
+ />
170
+ {{ item.label }}
171
+ </button>
172
+
173
+ <div
174
+ class="ui-tabs__indicator"
175
+ :style="lineStyle"
176
+ aria-hidden="true"
177
+ />
178
+ </div>
179
+
180
+ <div class="ui-tabs__panels">
181
+ <div
182
+ v-for="item in items"
183
+ :key="item.value"
184
+ v-show="modelValue === item.value"
185
+ :id="`tabpanel-${item.value}`"
186
+ role="tabpanel"
187
+ class="ui-tabs__panel"
188
+ :tabindex="0"
189
+ >
190
+ <slot :name="item.value" />
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </template>
195
+
196
+ <style scoped>
197
+ .ui-tabs {
198
+ width: 100%;
199
+ }
200
+
201
+ .ui-tabs__list {
202
+ position: relative;
203
+ display: flex;
204
+ gap: var(--space-1);
205
+ border-bottom: 1px solid var(--tabs-border);
206
+ }
207
+
208
+ .ui-tabs__list--block {
209
+ width: 100%;
210
+ }
211
+
212
+ .ui-tabs__list--block .ui-tabs__tab {
213
+ flex: 1;
214
+ justify-content: center;
215
+ }
216
+
217
+ .ui-tabs__tab {
218
+ position: relative;
219
+ display: inline-flex;
220
+ align-items: center;
221
+ gap: var(--space-2);
222
+ padding: var(--space-3) var(--space-4);
223
+ background: none;
224
+ border: none;
225
+ font-family: var(--font-sans);
226
+ font-size: var(--text-sm);
227
+ font-weight: var(--font-medium);
228
+ color: var(--tabs-text);
229
+ cursor: pointer;
230
+ white-space: nowrap;
231
+ transition: color var(--duration-fast) var(--ease-default);
232
+ z-index: 2;
233
+ }
234
+
235
+ .ui-tabs__tab:hover:not(:disabled) {
236
+ color: var(--tabs-text-hover);
237
+ }
238
+
239
+ .ui-tabs__tab--active {
240
+ color: var(--tabs-text-active);
241
+ }
242
+
243
+ .ui-tabs__tab:disabled {
244
+ opacity: 0.5;
245
+ cursor: not-allowed;
246
+ }
247
+
248
+ .ui-tabs__tab:focus-visible {
249
+ outline: 2px solid var(--ring-color);
250
+ outline-offset: -2px;
251
+ border-radius: var(--radius-sm);
252
+ }
253
+
254
+ .ui-tabs__icon {
255
+ flex-shrink: 0;
256
+ }
257
+
258
+ .ui-tabs__indicator {
259
+ position: absolute;
260
+ bottom: -1px;
261
+ left: 0;
262
+ height: 2px;
263
+ background-color: var(--tabs-indicator);
264
+ border-radius: 1px;
265
+ transition:
266
+ width var(--duration-normal) var(--ease-out-expo),
267
+ transform var(--duration-normal) var(--ease-out-expo);
268
+ z-index: 1;
269
+ pointer-events: none;
270
+ }
271
+
272
+ .ui-tabs__list--pill {
273
+ border-bottom: none;
274
+ background-color: var(--tabs-pill-bg);
275
+ padding: var(--space-1);
276
+ border-radius: var(--radius-lg);
277
+ gap: var(--space-1);
278
+ }
279
+
280
+ .ui-tabs__list--pill .ui-tabs__tab {
281
+ padding: var(--space-2) var(--space-4);
282
+ border-radius: var(--radius-md);
283
+ color: var(--tabs-text);
284
+ }
285
+
286
+ .ui-tabs__list--pill .ui-tabs__tab--active {
287
+ color: var(--tabs-text-active);
288
+ }
289
+
290
+ .ui-tabs__list--pill .ui-tabs__indicator {
291
+ top: var(--space-1);
292
+ bottom: var(--space-1);
293
+ height: auto;
294
+ background-color: var(--tabs-pill-indicator);
295
+ border-radius: var(--radius-md);
296
+ box-shadow: var(--shadow-sm);
297
+ }
298
+
299
+ .ui-tabs__panels {
300
+ padding-top: var(--space-4);
301
+ }
302
+
303
+ .ui-tabs__panel {
304
+ outline: none;
305
+ animation: tabs-fade-in var(--duration-normal) var(--ease-default);
306
+ }
307
+
308
+ @keyframes tabs-fade-in {
309
+ from {
310
+ opacity: 0;
311
+ transform: translateY(4px);
312
+ }
313
+ to {
314
+ opacity: 1;
315
+ transform: translateY(0);
316
+ }
317
+ }
318
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Tabs } from './Tabs.vue'
2
+ export type { TabsProps, TabItem } from './Tabs.vue'