@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,52 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject } from 'vue'
3
+ import { StepperKey, StepperItemKey } from './keys'
4
+
5
+ const stepper = inject(StepperKey)
6
+ const stepperItem = inject(StepperItemKey)
7
+
8
+ if (!stepper || !stepperItem) {
9
+ throw new Error('StepperSeparator must be used within StepperItem')
10
+ }
11
+
12
+ const isCompleted = computed(() => stepperItem.state.value === 'completed')
13
+
14
+ const separatorClasses = computed(() => [
15
+ 'ui-stepper__separator',
16
+ `ui-stepper__separator--${stepper.orientation.value}`,
17
+ {
18
+ 'ui-stepper__separator--completed': isCompleted.value
19
+ }
20
+ ])
21
+ </script>
22
+
23
+ <template>
24
+ <div :class="separatorClasses" aria-hidden="true" />
25
+ </template>
26
+
27
+ <style scoped>
28
+ .ui-stepper__separator {
29
+ background: var(--border-default);
30
+ transition: background-color 0.2s ease;
31
+ }
32
+
33
+ .ui-stepper__separator--completed {
34
+ background: var(--status-success);
35
+ }
36
+
37
+ .ui-stepper__separator--horizontal {
38
+ position: absolute;
39
+ top: calc(1rem + var(--space-2));
40
+ left: calc(50% + 1rem + var(--space-1));
41
+ right: calc(-50% + 1rem + var(--space-1));
42
+ height: 2px;
43
+ }
44
+
45
+ .ui-stepper__separator--vertical {
46
+ width: 2px;
47
+ position: absolute;
48
+ left: calc(1rem + var(--space-2) - 1px);
49
+ top: calc(2rem + var(--space-4));
50
+ bottom: 0;
51
+ }
52
+ </style>
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ import { computed, inject, h, type Component } from 'vue'
3
+ import { StepperKey, StepperItemKey } from './keys'
4
+ import { useInternalIcon } from '../../config/icons'
5
+
6
+ export interface StepperTriggerProps {
7
+ /** Custom icon to display instead of step number */
8
+ icon?: Component
9
+ }
10
+
11
+ defineProps<StepperTriggerProps>()
12
+
13
+ const stepper = inject(StepperKey)
14
+ const stepperItem = inject(StepperItemKey)
15
+
16
+ if (!stepper || !stepperItem) {
17
+ throw new Error('StepperTrigger must be used within StepperItem')
18
+ }
19
+
20
+ const CheckIcon = useInternalIcon('check')
21
+
22
+ const triggerClasses = computed(() => [
23
+ 'ui-stepper__trigger',
24
+ `ui-stepper__trigger--${stepperItem.state.value}`
25
+ ])
26
+
27
+ const indicatorClasses = computed(() => [
28
+ 'ui-stepper__indicator',
29
+ `ui-stepper__indicator--${stepperItem.state.value}`
30
+ ])
31
+ </script>
32
+
33
+ <template>
34
+ <button
35
+ :id="stepperItem.triggerId"
36
+ :class="triggerClasses"
37
+ type="button"
38
+ role="tab"
39
+ :aria-selected="stepperItem.state.value === 'current'"
40
+ :aria-controls="stepperItem.contentId"
41
+ :aria-disabled="stepperItem.isDisabled.value"
42
+ :disabled="stepperItem.isDisabled.value"
43
+ :tabindex="stepperItem.state.value === 'current' ? 0 : -1"
44
+ @click="stepperItem.goToStep"
45
+ >
46
+ <span :class="indicatorClasses">
47
+ <slot name="indicator">
48
+ <template v-if="stepperItem.state.value === 'completed'">
49
+ <component :is="CheckIcon" class="ui-stepper__check-icon" />
50
+ </template>
51
+ <template v-else-if="icon">
52
+ <component :is="icon" class="ui-stepper__custom-icon" />
53
+ </template>
54
+ <template v-else>
55
+ {{ stepperItem.index + 1 }}
56
+ </template>
57
+ </slot>
58
+ </span>
59
+ <span class="ui-stepper__label">
60
+ <slot />
61
+ </span>
62
+ </button>
63
+ </template>
64
+
65
+ <style scoped>
66
+ .ui-stepper__trigger {
67
+ display: flex;
68
+ flex-direction: column;
69
+ align-items: center;
70
+ gap: var(--space-2);
71
+ padding: var(--space-2);
72
+ background: transparent;
73
+ border: none;
74
+ cursor: pointer;
75
+ font-family: inherit;
76
+ transition: opacity 0.2s ease;
77
+ }
78
+
79
+ .ui-stepper__trigger:disabled {
80
+ cursor: not-allowed;
81
+ }
82
+
83
+ .ui-stepper__trigger:focus-visible {
84
+ outline: 2px solid var(--ring-color);
85
+ outline-offset: 2px;
86
+ border-radius: var(--radius-md);
87
+ }
88
+
89
+ .ui-stepper__indicator {
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ width: 2rem;
94
+ height: 2rem;
95
+ border-radius: var(--radius-full);
96
+ font-size: var(--text-sm);
97
+ font-weight: var(--font-medium);
98
+ transition: all 0.2s ease;
99
+ }
100
+
101
+ .ui-stepper__indicator--pending {
102
+ background: var(--bg-tertiary);
103
+ color: var(--text-tertiary);
104
+ border: 2px solid var(--border-default);
105
+ }
106
+
107
+ .ui-stepper__indicator--current {
108
+ background: var(--action-primary);
109
+ color: var(--action-primary-text);
110
+ border: 2px solid var(--action-primary);
111
+ }
112
+
113
+ .ui-stepper__indicator--completed {
114
+ background: var(--status-success);
115
+ color: var(--color-white);
116
+ border: 2px solid var(--status-success);
117
+ }
118
+
119
+ .ui-stepper__indicator--error {
120
+ background: var(--status-error);
121
+ color: var(--color-white);
122
+ border: 2px solid var(--status-error);
123
+ }
124
+
125
+ .ui-stepper__check-icon,
126
+ .ui-stepper__custom-icon {
127
+ width: 1rem;
128
+ height: 1rem;
129
+ }
130
+
131
+ .ui-stepper__label {
132
+ font-size: var(--text-sm);
133
+ color: var(--text-primary);
134
+ text-align: center;
135
+ }
136
+
137
+ .ui-stepper__trigger--pending .ui-stepper__label {
138
+ color: var(--text-tertiary);
139
+ }
140
+
141
+ .ui-stepper__trigger--current .ui-stepper__label {
142
+ font-weight: var(--font-medium);
143
+ }
144
+ </style>
@@ -0,0 +1,11 @@
1
+ export { default as StepperRoot } from './StepperRoot.vue'
2
+ export { default as StepperItem } from './StepperItem.vue'
3
+ export { default as StepperTrigger } from './StepperTrigger.vue'
4
+ export { default as StepperContent } from './StepperContent.vue'
5
+ export { default as StepperSeparator } from './StepperSeparator.vue'
6
+
7
+ export type { StepperRootProps } from './StepperRoot.vue'
8
+ export type { StepperItemProps } from './StepperItem.vue'
9
+ export type { StepperTriggerProps } from './StepperTrigger.vue'
10
+ export type { StepperContext, StepperItemContext, StepState, StepperOrientation } from './keys'
11
+ export { StepperKey, StepperItemKey } from './keys'
@@ -0,0 +1,27 @@
1
+ import type { InjectionKey, Ref, ComputedRef } from 'vue'
2
+
3
+ export type StepState = 'completed' | 'current' | 'pending' | 'error'
4
+ export type StepperOrientation = 'horizontal' | 'vertical'
5
+
6
+ export interface StepperContext {
7
+ orientation: Ref<StepperOrientation>
8
+ linear: Ref<boolean>
9
+ currentStep: Ref<number>
10
+ goToStep: (step: number) => void
11
+ getStepState: (index: number) => StepState
12
+ canNavigateTo: (index: number) => boolean
13
+ registerStep: (index: number) => void
14
+ stepCount: Ref<number>
15
+ }
16
+
17
+ export interface StepperItemContext {
18
+ index: number
19
+ state: ComputedRef<StepState>
20
+ isDisabled: ComputedRef<boolean>
21
+ triggerId: string
22
+ contentId: string
23
+ goToStep: () => void
24
+ }
25
+
26
+ export const StepperKey: InjectionKey<StepperContext> = Symbol('stepper')
27
+ export const StepperItemKey: InjectionKey<StepperItemContext> = Symbol('stepper-item')
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Switch from './Switch.vue'
4
+
5
+ describe('Switch', () => {
6
+ describe('Rendering', () => {
7
+ it('renders as a button', () => {
8
+ const wrapper = mount(Switch)
9
+ expect(wrapper.find('button').exists()).toBe(true)
10
+ })
11
+
12
+ it('has role="switch"', () => {
13
+ const wrapper = mount(Switch)
14
+ expect(wrapper.find('button').attributes('role')).toBe('switch')
15
+ })
16
+
17
+ it('has type="button" to prevent form submission', () => {
18
+ const wrapper = mount(Switch)
19
+ expect(wrapper.find('button').attributes('type')).toBe('button')
20
+ })
21
+
22
+ it('renders track and thumb', () => {
23
+ const wrapper = mount(Switch)
24
+ expect(wrapper.find('.ui-switch__track').exists()).toBe(true)
25
+ expect(wrapper.find('.ui-switch__thumb').exists()).toBe(true)
26
+ })
27
+ })
28
+
29
+ describe('v-model', () => {
30
+ it('reflects modelValue as aria-checked', () => {
31
+ const wrapper = mount(Switch, {
32
+ props: { modelValue: false }
33
+ })
34
+ expect(wrapper.find('button').attributes('aria-checked')).toBe('false')
35
+ })
36
+
37
+ it('shows checked state when modelValue is true', () => {
38
+ const wrapper = mount(Switch, {
39
+ props: { modelValue: true }
40
+ })
41
+ expect(wrapper.find('button').attributes('aria-checked')).toBe('true')
42
+ expect(wrapper.find('.ui-switch').classes()).toContain('ui-switch--checked')
43
+ })
44
+
45
+ it('emits update:modelValue on click', async () => {
46
+ const wrapper = mount(Switch, {
47
+ props: { modelValue: false }
48
+ })
49
+ await wrapper.find('button').trigger('click')
50
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
51
+ })
52
+
53
+ it('emits change event on click', async () => {
54
+ const wrapper = mount(Switch, {
55
+ props: { modelValue: false }
56
+ })
57
+ await wrapper.find('button').trigger('click')
58
+ expect(wrapper.emitted('change')?.[0]).toEqual([true])
59
+ })
60
+
61
+ it('toggles from true to false', async () => {
62
+ const wrapper = mount(Switch, {
63
+ props: { modelValue: true }
64
+ })
65
+ await wrapper.find('button').trigger('click')
66
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
67
+ })
68
+ })
69
+
70
+ describe('Size variants', () => {
71
+ const sizes = ['sm', 'md', 'lg'] as const
72
+
73
+ sizes.forEach(size => {
74
+ it(`applies ${size} size class`, () => {
75
+ const wrapper = mount(Switch, {
76
+ props: { size }
77
+ })
78
+ expect(wrapper.find('.ui-switch').classes()).toContain(`ui-switch--${size}`)
79
+ })
80
+ })
81
+ })
82
+
83
+ describe('Disabled state', () => {
84
+ it('applies disabled attribute', () => {
85
+ const wrapper = mount(Switch, {
86
+ props: { disabled: true }
87
+ })
88
+ expect(wrapper.find('button').attributes('disabled')).toBeDefined()
89
+ })
90
+
91
+ it('applies disabled class', () => {
92
+ const wrapper = mount(Switch, {
93
+ props: { disabled: true }
94
+ })
95
+ expect(wrapper.find('.ui-switch').classes()).toContain('ui-switch--disabled')
96
+ })
97
+
98
+ it('does not emit events when disabled', async () => {
99
+ const wrapper = mount(Switch, {
100
+ props: { disabled: true, modelValue: false }
101
+ })
102
+ await wrapper.find('button').trigger('click')
103
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
104
+ })
105
+ })
106
+
107
+ describe('Loading state', () => {
108
+ it('renders spinner when loading', () => {
109
+ const wrapper = mount(Switch, {
110
+ props: { loading: true }
111
+ })
112
+ expect(wrapper.findComponent({ name: 'Spinner' }).exists()).toBe(true)
113
+ })
114
+
115
+ it('applies loading class', () => {
116
+ const wrapper = mount(Switch, {
117
+ props: { loading: true }
118
+ })
119
+ expect(wrapper.find('.ui-switch').classes()).toContain('ui-switch--loading')
120
+ })
121
+
122
+ it('is disabled when loading', () => {
123
+ const wrapper = mount(Switch, {
124
+ props: { loading: true }
125
+ })
126
+ expect(wrapper.find('button').attributes('disabled')).toBeDefined()
127
+ })
128
+
129
+ it('does not emit events when loading', async () => {
130
+ const wrapper = mount(Switch, {
131
+ props: { loading: true, modelValue: false }
132
+ })
133
+ await wrapper.find('button').trigger('click')
134
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
135
+ })
136
+ })
137
+
138
+ describe('Keyboard interaction', () => {
139
+ it('toggles on Enter key', async () => {
140
+ const wrapper = mount(Switch, {
141
+ props: { modelValue: false }
142
+ })
143
+ await wrapper.find('button').trigger('keydown', { key: 'Enter' })
144
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
145
+ })
146
+
147
+ it('does not toggle on Enter when disabled', async () => {
148
+ const wrapper = mount(Switch, {
149
+ props: { disabled: true, modelValue: false }
150
+ })
151
+ await wrapper.find('button').trigger('keydown', { key: 'Enter' })
152
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
153
+ })
154
+ })
155
+
156
+ describe('Accessibility', () => {
157
+ it('has aria-label when label prop provided', () => {
158
+ const wrapper = mount(Switch, {
159
+ props: { label: 'Enable notifications' }
160
+ })
161
+ expect(wrapper.find('button').attributes('aria-label')).toBe('Enable notifications')
162
+ })
163
+
164
+ it('auto-generates unique id', () => {
165
+ const wrapper = mount(Switch)
166
+ expect(wrapper.find('button').attributes('id')).toMatch(/^switch-/)
167
+ })
168
+
169
+ it('uses provided id', () => {
170
+ const wrapper = mount(Switch, {
171
+ props: { id: 'custom-switch' }
172
+ })
173
+ expect(wrapper.find('button').attributes('id')).toBe('custom-switch')
174
+ })
175
+ })
176
+
177
+ describe('Form submission', () => {
178
+ it('does not render hidden input by default', () => {
179
+ const wrapper = mount(Switch)
180
+ expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
181
+ })
182
+
183
+ it('renders hidden input when name prop provided', () => {
184
+ const wrapper = mount(Switch, {
185
+ props: { name: 'notifications' }
186
+ })
187
+ const input = wrapper.find('input[type="checkbox"]')
188
+ expect(input.exists()).toBe(true)
189
+ expect(input.attributes('name')).toBe('notifications')
190
+ })
191
+
192
+ it('hidden input reflects checked state', () => {
193
+ const wrapper = mount(Switch, {
194
+ props: { name: 'notifications', modelValue: true }
195
+ })
196
+ const input = wrapper.find('input[type="checkbox"]')
197
+ expect((input.element as HTMLInputElement).checked).toBe(true)
198
+ })
199
+
200
+ it('hidden input is aria-hidden', () => {
201
+ const wrapper = mount(Switch, {
202
+ props: { name: 'notifications' }
203
+ })
204
+ expect(wrapper.find('input').attributes('aria-hidden')).toBe('true')
205
+ })
206
+
207
+ it('hidden input has tabindex=-1', () => {
208
+ const wrapper = mount(Switch, {
209
+ props: { name: 'notifications' }
210
+ })
211
+ expect(wrapper.find('input').attributes('tabindex')).toBe('-1')
212
+ })
213
+ })
214
+ })
@@ -0,0 +1,235 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import Spinner from '../Spinner/Spinner.vue'
4
+ import { useId } from '../../composables'
5
+
6
+ export interface SwitchProps {
7
+ /** Checked state (v-model) */
8
+ modelValue?: boolean
9
+ /** Switch size */
10
+ size?: 'sm' | 'md' | 'lg'
11
+ /** Disabled state */
12
+ disabled?: boolean
13
+ /** Loading state - shows spinner in thumb */
14
+ loading?: boolean
15
+ /** Accessible label (required for a11y if no visible label) */
16
+ label?: string
17
+ /** HTML name for form submission */
18
+ name?: string
19
+ /** ID for label association */
20
+ id?: string
21
+ }
22
+
23
+ const props = withDefaults(defineProps<SwitchProps>(), {
24
+ modelValue: false,
25
+ size: 'md',
26
+ disabled: false,
27
+ loading: false
28
+ })
29
+
30
+ const emit = defineEmits<{
31
+ (e: 'update:modelValue', value: boolean): void
32
+ (e: 'change', value: boolean): void
33
+ }>()
34
+
35
+ const uid = useId('switch')
36
+ const switchId = computed(() => props.id || uid)
37
+
38
+ const isDisabled = computed(() => props.disabled || props.loading)
39
+
40
+ function toggle() {
41
+ if (isDisabled.value) return
42
+ const newValue = !props.modelValue
43
+ emit('update:modelValue', newValue)
44
+ emit('change', newValue)
45
+ }
46
+
47
+ function handleKeydown(e: KeyboardEvent) {
48
+ if (e.key === 'Enter') {
49
+ e.preventDefault()
50
+ toggle()
51
+ }
52
+ }
53
+
54
+ const spinnerSizeMap: Record<string, string> = {
55
+ sm: '0.625rem',
56
+ md: '0.75rem',
57
+ lg: '0.875rem'
58
+ }
59
+ const spinnerSize = computed(() => spinnerSizeMap[props.size])
60
+ </script>
61
+
62
+ <template>
63
+ <button
64
+ :id="switchId"
65
+ type="button"
66
+ role="switch"
67
+ :aria-checked="modelValue"
68
+ :aria-label="label"
69
+ :disabled="isDisabled"
70
+ class="ui-switch"
71
+ :class="[
72
+ `ui-switch--${size}`,
73
+ {
74
+ 'ui-switch--checked': modelValue,
75
+ 'ui-switch--disabled': isDisabled,
76
+ 'ui-switch--loading': loading
77
+ }
78
+ ]"
79
+ @click="toggle"
80
+ @keydown="handleKeydown"
81
+ >
82
+ <span class="ui-switch__track" aria-hidden="true">
83
+ <span class="ui-switch__thumb">
84
+ <Spinner
85
+ v-if="loading"
86
+ :size="spinnerSize"
87
+ class="ui-switch__spinner"
88
+ />
89
+ </span>
90
+ </span>
91
+
92
+ <input
93
+ v-if="name"
94
+ type="checkbox"
95
+ :name="name"
96
+ :checked="modelValue"
97
+ :disabled="disabled"
98
+ class="ui-switch__input"
99
+ tabindex="-1"
100
+ aria-hidden="true"
101
+ />
102
+ </button>
103
+ </template>
104
+
105
+ <style scoped>
106
+ .ui-switch {
107
+ position: relative;
108
+ display: inline-flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ padding: 0;
112
+ border: none;
113
+ background: transparent;
114
+ cursor: pointer;
115
+ vertical-align: middle;
116
+ -webkit-tap-highlight-color: transparent;
117
+ }
118
+
119
+ .ui-switch::after {
120
+ content: '';
121
+ position: absolute;
122
+ top: -8px;
123
+ bottom: -8px;
124
+ left: -8px;
125
+ right: -8px;
126
+ }
127
+
128
+ .ui-switch__track {
129
+ position: relative;
130
+ display: flex;
131
+ align-items: center;
132
+ border-radius: var(--radius-full);
133
+ background-color: var(--switch-track-off);
134
+ transition: background-color var(--duration-fast) var(--ease-default);
135
+ }
136
+
137
+ .ui-switch--checked .ui-switch__track {
138
+ background-color: var(--switch-track-on);
139
+ }
140
+
141
+ .ui-switch--disabled .ui-switch__track {
142
+ opacity: 0.5;
143
+ }
144
+
145
+ .ui-switch__thumb {
146
+ position: absolute;
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ border-radius: var(--radius-full);
151
+ background-color: var(--switch-thumb);
152
+ box-shadow:
153
+ 0 1px 3px 0 oklch(0 0 0 / 0.1),
154
+ 0 1px 2px -1px oklch(0 0 0 / 0.1);
155
+ transition: transform var(--duration-normal) var(--ease-out-back);
156
+ will-change: transform;
157
+ }
158
+
159
+ .ui-switch--sm .ui-switch__track {
160
+ width: 2rem;
161
+ height: 1.125rem;
162
+ }
163
+
164
+ .ui-switch--sm .ui-switch__thumb {
165
+ width: 0.875rem;
166
+ height: 0.875rem;
167
+ left: 2px;
168
+ transform: translateX(0);
169
+ }
170
+
171
+ .ui-switch--sm.ui-switch--checked .ui-switch__thumb {
172
+ transform: translateX(0.875rem);
173
+ }
174
+
175
+ .ui-switch--md .ui-switch__track {
176
+ width: 2.5rem;
177
+ height: 1.375rem;
178
+ }
179
+
180
+ .ui-switch--md .ui-switch__thumb {
181
+ width: 1.125rem;
182
+ height: 1.125rem;
183
+ left: 2px;
184
+ transform: translateX(0);
185
+ }
186
+
187
+ .ui-switch--md.ui-switch--checked .ui-switch__thumb {
188
+ transform: translateX(1.125rem);
189
+ }
190
+
191
+ .ui-switch--lg .ui-switch__track {
192
+ width: 3rem;
193
+ height: 1.625rem;
194
+ }
195
+
196
+ .ui-switch--lg .ui-switch__thumb {
197
+ width: 1.375rem;
198
+ height: 1.375rem;
199
+ left: 2px;
200
+ transform: translateX(0);
201
+ }
202
+
203
+ .ui-switch--lg.ui-switch--checked .ui-switch__thumb {
204
+ transform: translateX(1.375rem);
205
+ }
206
+
207
+ .ui-switch--disabled {
208
+ cursor: not-allowed;
209
+ }
210
+
211
+ .ui-switch--loading {
212
+ cursor: wait;
213
+ }
214
+
215
+ .ui-switch:focus-visible .ui-switch__track {
216
+ outline: 2px solid var(--ring-color);
217
+ outline-offset: 2px;
218
+ }
219
+
220
+ .ui-switch__spinner {
221
+ color: var(--switch-spinner);
222
+ }
223
+
224
+ .ui-switch__input {
225
+ position: absolute;
226
+ width: 1px;
227
+ height: 1px;
228
+ padding: 0;
229
+ margin: -1px;
230
+ overflow: hidden;
231
+ clip: rect(0, 0, 0, 0);
232
+ white-space: nowrap;
233
+ border: 0;
234
+ }
235
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Switch } from './Switch.vue'
2
+ export type { SwitchProps } from './Switch.vue'