@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,247 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import Icon from '../Icon/Icon.vue'
4
+ import { useInternalIcon } from '../../config/icons'
5
+
6
+ const StarIcon = useInternalIcon('star')
7
+ const StarFilledIcon = useInternalIcon('starFilled')
8
+
9
+ export interface RatingProps {
10
+ /** Current rating value (0 to max) */
11
+ modelValue: number
12
+ /** Maximum rating value */
13
+ max?: number
14
+ /** Read-only mode */
15
+ readonly?: boolean
16
+ /** Enable half-star precision */
17
+ allowHalf?: boolean
18
+ /** Size variant */
19
+ size?: 'sm' | 'md' | 'lg'
20
+ /** Accessible label */
21
+ label?: string
22
+ }
23
+
24
+ const props = withDefaults(defineProps<RatingProps>(), {
25
+ max: 5,
26
+ readonly: false,
27
+ allowHalf: false,
28
+ size: 'md',
29
+ label: 'Rating'
30
+ })
31
+
32
+ const emit = defineEmits<{
33
+ (e: 'update:modelValue', value: number): void
34
+ (e: 'hover', value: number | null): void
35
+ }>()
36
+
37
+ const hoverValue = ref<number | null>(null)
38
+
39
+ const displayValue = computed(() => {
40
+ return hoverValue.value ?? props.modelValue
41
+ })
42
+
43
+ function getStarState(index: number): 'full' | 'half' | 'empty' {
44
+ const value = displayValue.value
45
+ if (value >= index) return 'full'
46
+ if (props.allowHalf && value >= index - 0.5) return 'half'
47
+ return 'empty'
48
+ }
49
+
50
+ function handleMouseMove(event: MouseEvent, index: number) {
51
+ if (props.readonly) return
52
+
53
+ const target = event.currentTarget as HTMLElement
54
+ const rect = target.getBoundingClientRect()
55
+ const offsetX = event.clientX - rect.left
56
+ const isLeftHalf = offsetX < rect.width / 2
57
+
58
+ let value: number
59
+ if (props.allowHalf && isLeftHalf) {
60
+ value = index - 0.5
61
+ } else {
62
+ value = index
63
+ }
64
+
65
+ hoverValue.value = value
66
+ emit('hover', value)
67
+ }
68
+
69
+ function handleMouseLeave() {
70
+ hoverValue.value = null
71
+ emit('hover', null)
72
+ }
73
+
74
+ function handleClick(event: MouseEvent, index: number) {
75
+ if (props.readonly) return
76
+
77
+ const target = event.currentTarget as HTMLElement
78
+ const rect = target.getBoundingClientRect()
79
+ const offsetX = event.clientX - rect.left
80
+ const isLeftHalf = offsetX < rect.width / 2
81
+
82
+ let value: number
83
+ if (props.allowHalf && isLeftHalf) {
84
+ value = index - 0.5
85
+ } else {
86
+ value = index
87
+ }
88
+
89
+ emit('update:modelValue', value)
90
+ }
91
+
92
+ function handleKeyDown(event: KeyboardEvent) {
93
+ if (props.readonly) return
94
+
95
+ const step = props.allowHalf ? 0.5 : 1
96
+ let newValue = props.modelValue
97
+
98
+ switch (event.key) {
99
+ case 'ArrowRight':
100
+ case 'ArrowUp':
101
+ event.preventDefault()
102
+ newValue = Math.min(props.modelValue + step, props.max)
103
+ break
104
+ case 'ArrowLeft':
105
+ case 'ArrowDown':
106
+ event.preventDefault()
107
+ newValue = Math.max(props.modelValue - step, 0)
108
+ break
109
+ case 'Home':
110
+ event.preventDefault()
111
+ newValue = 0
112
+ break
113
+ case 'End':
114
+ event.preventDefault()
115
+ newValue = props.max
116
+ break
117
+ default:
118
+ return
119
+ }
120
+
121
+ if (newValue !== props.modelValue) {
122
+ emit('update:modelValue', newValue)
123
+ }
124
+ }
125
+
126
+ const iconSize = computed(() => {
127
+ switch (props.size) {
128
+ case 'sm': return '16px'
129
+ case 'lg': return '28px'
130
+ default: return '22px'
131
+ }
132
+ })
133
+ </script>
134
+
135
+ <template>
136
+ <div
137
+ class="ui-rating"
138
+ :class="[
139
+ `ui-rating--${size}`,
140
+ { 'ui-rating--readonly': readonly }
141
+ ]"
142
+ role="slider"
143
+ :aria-label="label"
144
+ :aria-valuemin="0"
145
+ :aria-valuemax="max"
146
+ :aria-valuenow="modelValue"
147
+ :aria-readonly="readonly || undefined"
148
+ :tabindex="readonly ? -1 : 0"
149
+ @mouseleave="handleMouseLeave"
150
+ @keydown="handleKeyDown"
151
+ >
152
+ <span
153
+ v-for="index in max"
154
+ :key="index"
155
+ class="ui-rating__star"
156
+ :class="[`ui-rating__star--${getStarState(index)}`]"
157
+ @mousemove="handleMouseMove($event, index)"
158
+ @click="handleClick($event, index)"
159
+ >
160
+ <Icon
161
+ :icon="StarIcon"
162
+ :size="iconSize"
163
+ class="ui-rating__icon ui-rating__icon--empty"
164
+ />
165
+ <Icon
166
+ :icon="StarFilledIcon"
167
+ :size="iconSize"
168
+ class="ui-rating__icon ui-rating__icon--filled"
169
+ />
170
+ </span>
171
+ </div>
172
+ </template>
173
+
174
+ <style scoped>
175
+ .ui-rating {
176
+ display: inline-flex;
177
+ align-items: center;
178
+ gap: var(--space-1);
179
+ user-select: none;
180
+ }
181
+
182
+ .ui-rating:focus-visible {
183
+ outline: 2px solid var(--ring-color);
184
+ outline-offset: 2px;
185
+ border-radius: var(--radius-sm);
186
+ }
187
+
188
+ .ui-rating--readonly {
189
+ pointer-events: none;
190
+ }
191
+
192
+ .ui-rating__star {
193
+ position: relative;
194
+ display: inline-flex;
195
+ cursor: pointer;
196
+ transition: transform var(--duration-fast) var(--ease-default);
197
+ }
198
+
199
+ .ui-rating--readonly .ui-rating__star {
200
+ cursor: default;
201
+ }
202
+
203
+ .ui-rating__star:hover {
204
+ transform: scale(1.1);
205
+ }
206
+
207
+ .ui-rating--readonly .ui-rating__star:hover {
208
+ transform: none;
209
+ }
210
+
211
+ .ui-rating__icon {
212
+ display: block;
213
+ }
214
+
215
+ .ui-rating__icon--empty {
216
+ color: var(--input-border);
217
+ }
218
+
219
+ .ui-rating__icon--filled {
220
+ position: absolute;
221
+ top: 0;
222
+ left: 0;
223
+ color: var(--status-warning);
224
+ clip-path: inset(0 100% 0 0);
225
+ transition: clip-path var(--duration-fast) var(--ease-default);
226
+ }
227
+
228
+ .ui-rating__star--full .ui-rating__icon--filled {
229
+ clip-path: inset(0 0 0 0);
230
+ }
231
+
232
+ .ui-rating__star--half .ui-rating__icon--filled {
233
+ clip-path: inset(0 50% 0 0);
234
+ }
235
+
236
+ .ui-rating__star--empty .ui-rating__icon--filled {
237
+ clip-path: inset(0 100% 0 0);
238
+ }
239
+
240
+ .ui-rating--sm {
241
+ gap: 2px;
242
+ }
243
+
244
+ .ui-rating--lg {
245
+ gap: var(--space-2);
246
+ }
247
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Rating } from './Rating.vue'
2
+ export type { RatingProps } from './Rating.vue'
@@ -0,0 +1,292 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import SegmentedControl from './SegmentedControl.vue'
4
+
5
+ const defaultOptions = [
6
+ { label: 'Day', value: 'day' },
7
+ { label: 'Week', value: 'week' },
8
+ { label: 'Month', value: 'month' }
9
+ ]
10
+
11
+ describe('SegmentedControl', () => {
12
+ describe('Rendering', () => {
13
+ it('renders as a radiogroup', () => {
14
+ const wrapper = mount(SegmentedControl, {
15
+ props: { options: defaultOptions }
16
+ })
17
+ expect(wrapper.find('[role="radiogroup"]').exists()).toBe(true)
18
+ })
19
+
20
+ it('renders all options', () => {
21
+ const wrapper = mount(SegmentedControl, {
22
+ props: { options: defaultOptions }
23
+ })
24
+ const items = wrapper.findAll('[role="radio"]')
25
+ expect(items).toHaveLength(3)
26
+ })
27
+
28
+ it('renders option labels', () => {
29
+ const wrapper = mount(SegmentedControl, {
30
+ props: { options: defaultOptions }
31
+ })
32
+ expect(wrapper.text()).toContain('Day')
33
+ expect(wrapper.text()).toContain('Week')
34
+ expect(wrapper.text()).toContain('Month')
35
+ })
36
+
37
+ it('renders the glider element', () => {
38
+ const wrapper = mount(SegmentedControl, {
39
+ props: { options: defaultOptions, modelValue: 'day' }
40
+ })
41
+ expect(wrapper.find('.ui-segmented__glider').exists()).toBe(true)
42
+ })
43
+ })
44
+
45
+ describe('v-model', () => {
46
+ it('marks selected option with aria-checked', () => {
47
+ const wrapper = mount(SegmentedControl, {
48
+ props: { options: defaultOptions, modelValue: 'week' }
49
+ })
50
+ const items = wrapper.findAll('[role="radio"]')
51
+ expect(items[0].attributes('aria-checked')).toBe('false')
52
+ expect(items[1].attributes('aria-checked')).toBe('true')
53
+ expect(items[2].attributes('aria-checked')).toBe('false')
54
+ })
55
+
56
+ it('applies selected class to active option', () => {
57
+ const wrapper = mount(SegmentedControl, {
58
+ props: { options: defaultOptions, modelValue: 'week' }
59
+ })
60
+ const items = wrapper.findAll('.ui-segmented__item')
61
+ expect(items[1].classes()).toContain('ui-segmented__item--selected')
62
+ })
63
+
64
+ it('emits update:modelValue on click', async () => {
65
+ const wrapper = mount(SegmentedControl, {
66
+ props: { options: defaultOptions, modelValue: 'day' }
67
+ })
68
+ await wrapper.findAll('[role="radio"]')[2].trigger('click')
69
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['month'])
70
+ })
71
+
72
+ it('emits change event on click', async () => {
73
+ const wrapper = mount(SegmentedControl, {
74
+ props: { options: defaultOptions, modelValue: 'day' }
75
+ })
76
+ await wrapper.findAll('[role="radio"]')[1].trigger('click')
77
+ expect(wrapper.emitted('change')?.[0]).toEqual(['week'])
78
+ })
79
+ })
80
+
81
+ describe('Size variants', () => {
82
+ const sizes = ['sm', 'md', 'lg'] as const
83
+
84
+ sizes.forEach(size => {
85
+ it(`applies ${size} size class`, () => {
86
+ const wrapper = mount(SegmentedControl, {
87
+ props: { options: defaultOptions, size }
88
+ })
89
+ expect(wrapper.find('.ui-segmented').classes()).toContain(`ui-segmented--${size}`)
90
+ })
91
+ })
92
+
93
+ it('defaults to md size', () => {
94
+ const wrapper = mount(SegmentedControl, {
95
+ props: { options: defaultOptions }
96
+ })
97
+ expect(wrapper.find('.ui-segmented').classes()).toContain('ui-segmented--md')
98
+ })
99
+ })
100
+
101
+ describe('Disabled state', () => {
102
+ it('applies disabled class when disabled', () => {
103
+ const wrapper = mount(SegmentedControl, {
104
+ props: { options: defaultOptions, disabled: true }
105
+ })
106
+ expect(wrapper.find('.ui-segmented').classes()).toContain('ui-segmented--disabled')
107
+ })
108
+
109
+ it('sets aria-disabled on container', () => {
110
+ const wrapper = mount(SegmentedControl, {
111
+ props: { options: defaultOptions, disabled: true }
112
+ })
113
+ expect(wrapper.find('[role="radiogroup"]').attributes('aria-disabled')).toBe('true')
114
+ })
115
+
116
+ it('disables all buttons when control is disabled', () => {
117
+ const wrapper = mount(SegmentedControl, {
118
+ props: { options: defaultOptions, disabled: true }
119
+ })
120
+ const buttons = wrapper.findAll('button')
121
+ buttons.forEach(btn => {
122
+ expect(btn.attributes('disabled')).toBeDefined()
123
+ })
124
+ })
125
+
126
+ it('does not emit events when disabled', async () => {
127
+ const wrapper = mount(SegmentedControl, {
128
+ props: { options: defaultOptions, modelValue: 'day', disabled: true }
129
+ })
130
+ await wrapper.findAll('[role="radio"]')[1].trigger('click')
131
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
132
+ })
133
+ })
134
+
135
+ describe('Individual option disabled', () => {
136
+ const optionsWithDisabled = [
137
+ { label: 'Day', value: 'day' },
138
+ { label: 'Week', value: 'week', disabled: true },
139
+ { label: 'Month', value: 'month' }
140
+ ]
141
+
142
+ it('disables individual option', () => {
143
+ const wrapper = mount(SegmentedControl, {
144
+ props: { options: optionsWithDisabled }
145
+ })
146
+ const items = wrapper.findAll('button')
147
+ expect(items[0].attributes('disabled')).toBeUndefined()
148
+ expect(items[1].attributes('disabled')).toBeDefined()
149
+ expect(items[2].attributes('disabled')).toBeUndefined()
150
+ })
151
+
152
+ it('applies disabled class to disabled option', () => {
153
+ const wrapper = mount(SegmentedControl, {
154
+ props: { options: optionsWithDisabled }
155
+ })
156
+ const items = wrapper.findAll('.ui-segmented__item')
157
+ expect(items[1].classes()).toContain('ui-segmented__item--disabled')
158
+ })
159
+
160
+ it('does not emit when clicking disabled option', async () => {
161
+ const wrapper = mount(SegmentedControl, {
162
+ props: { options: optionsWithDisabled, modelValue: 'day' }
163
+ })
164
+ await wrapper.findAll('[role="radio"]')[1].trigger('click')
165
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
166
+ })
167
+ })
168
+
169
+ describe('Keyboard navigation', () => {
170
+ it('moves to next option on ArrowRight', async () => {
171
+ const wrapper = mount(SegmentedControl, {
172
+ props: { options: defaultOptions, modelValue: 'day' }
173
+ })
174
+ await wrapper.findAll('[role="radio"]')[0].trigger('keydown', { key: 'ArrowRight' })
175
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['week'])
176
+ })
177
+
178
+ it('moves to previous option on ArrowLeft', async () => {
179
+ const wrapper = mount(SegmentedControl, {
180
+ props: { options: defaultOptions, modelValue: 'week' }
181
+ })
182
+ await wrapper.findAll('[role="radio"]')[1].trigger('keydown', { key: 'ArrowLeft' })
183
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['day'])
184
+ })
185
+
186
+ it('wraps around from last to first on ArrowRight', async () => {
187
+ const wrapper = mount(SegmentedControl, {
188
+ props: { options: defaultOptions, modelValue: 'month' }
189
+ })
190
+ await wrapper.findAll('[role="radio"]')[2].trigger('keydown', { key: 'ArrowRight' })
191
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['day'])
192
+ })
193
+
194
+ it('wraps around from first to last on ArrowLeft', async () => {
195
+ const wrapper = mount(SegmentedControl, {
196
+ props: { options: defaultOptions, modelValue: 'day' }
197
+ })
198
+ await wrapper.findAll('[role="radio"]')[0].trigger('keydown', { key: 'ArrowLeft' })
199
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['month'])
200
+ })
201
+
202
+ it('moves to first option on Home', async () => {
203
+ const wrapper = mount(SegmentedControl, {
204
+ props: { options: defaultOptions, modelValue: 'month' }
205
+ })
206
+ await wrapper.findAll('[role="radio"]')[2].trigger('keydown', { key: 'Home' })
207
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['day'])
208
+ })
209
+
210
+ it('moves to last option on End', async () => {
211
+ const wrapper = mount(SegmentedControl, {
212
+ props: { options: defaultOptions, modelValue: 'day' }
213
+ })
214
+ await wrapper.findAll('[role="radio"]')[0].trigger('keydown', { key: 'End' })
215
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['month'])
216
+ })
217
+ })
218
+
219
+ describe('Accessibility', () => {
220
+ it('has aria-label when label prop provided', () => {
221
+ const wrapper = mount(SegmentedControl, {
222
+ props: { options: defaultOptions, label: 'Select time range' }
223
+ })
224
+ expect(wrapper.find('[role="radiogroup"]').attributes('aria-label')).toBe('Select time range')
225
+ })
226
+
227
+ it('auto-generates unique id', () => {
228
+ const wrapper = mount(SegmentedControl, {
229
+ props: { options: defaultOptions }
230
+ })
231
+ expect(wrapper.find('[role="radiogroup"]').attributes('id')).toMatch(/^segmented-/)
232
+ })
233
+
234
+ it('uses provided id', () => {
235
+ const wrapper = mount(SegmentedControl, {
236
+ props: { options: defaultOptions, id: 'custom-segmented' }
237
+ })
238
+ expect(wrapper.find('[role="radiogroup"]').attributes('id')).toBe('custom-segmented')
239
+ })
240
+
241
+ it('selected item has tabindex 0, others have -1', () => {
242
+ const wrapper = mount(SegmentedControl, {
243
+ props: { options: defaultOptions, modelValue: 'week' }
244
+ })
245
+ const items = wrapper.findAll('[role="radio"]')
246
+ expect(items[0].attributes('tabindex')).toBe('-1')
247
+ expect(items[1].attributes('tabindex')).toBe('0')
248
+ expect(items[2].attributes('tabindex')).toBe('-1')
249
+ })
250
+
251
+ it('glider has aria-hidden', () => {
252
+ const wrapper = mount(SegmentedControl, {
253
+ props: { options: defaultOptions, modelValue: 'day' }
254
+ })
255
+ expect(wrapper.find('.ui-segmented__glider').attributes('aria-hidden')).toBe('true')
256
+ })
257
+ })
258
+
259
+ describe('Form submission', () => {
260
+ it('does not render hidden input by default', () => {
261
+ const wrapper = mount(SegmentedControl, {
262
+ props: { options: defaultOptions }
263
+ })
264
+ expect(wrapper.find('input[type="hidden"]').exists()).toBe(false)
265
+ })
266
+
267
+ it('renders hidden input when name prop provided', () => {
268
+ const wrapper = mount(SegmentedControl, {
269
+ props: { options: defaultOptions, name: 'time_range', modelValue: 'week' }
270
+ })
271
+ const input = wrapper.find('input[type="hidden"]')
272
+ expect(input.exists()).toBe(true)
273
+ expect(input.attributes('name')).toBe('time_range')
274
+ expect(input.attributes('value')).toBe('week')
275
+ })
276
+ })
277
+
278
+ describe('Value types', () => {
279
+ it('works with number values', async () => {
280
+ const numberOptions = [
281
+ { label: '1', value: 1 },
282
+ { label: '5', value: 5 },
283
+ { label: '10', value: 10 }
284
+ ]
285
+ const wrapper = mount(SegmentedControl, {
286
+ props: { options: numberOptions, modelValue: 1 }
287
+ })
288
+ await wrapper.findAll('[role="radio"]')[2].trigger('click')
289
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([10])
290
+ })
291
+ })
292
+ })