@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,151 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import ChoiceChipGroup from './ChoiceChipGroup.vue'
4
+ import ChoiceChip from '../ChoiceChip/ChoiceChip.vue'
5
+ import { h } from 'vue'
6
+
7
+ describe('ChoiceChipGroup', () => {
8
+ describe('Rendering', () => {
9
+ it('renders as a div', () => {
10
+ const wrapper = mount(ChoiceChipGroup)
11
+ expect(wrapper.element.tagName).toBe('DIV')
12
+ })
13
+
14
+ it('renders slot content', () => {
15
+ const wrapper = mount(ChoiceChipGroup, {
16
+ slots: {
17
+ default: () => [
18
+ h(ChoiceChip, { label: 'A', value: 'a' }),
19
+ h(ChoiceChip, { label: 'B', value: 'b' })
20
+ ]
21
+ }
22
+ })
23
+ expect(wrapper.text()).toContain('A')
24
+ expect(wrapper.text()).toContain('B')
25
+ })
26
+
27
+ it('has role="group"', () => {
28
+ const wrapper = mount(ChoiceChipGroup)
29
+ expect(wrapper.attributes('role')).toBe('group')
30
+ })
31
+ })
32
+
33
+ describe('Multiple selection (default)', () => {
34
+ it('adds value to array on click', async () => {
35
+ const wrapper = mount(ChoiceChipGroup, {
36
+ props: { type: 'multiple', modelValue: [] },
37
+ slots: {
38
+ default: () => [
39
+ h(ChoiceChip, { label: 'Price', value: 'price' }),
40
+ h(ChoiceChip, { label: 'Date', value: 'date' })
41
+ ]
42
+ }
43
+ })
44
+
45
+ await wrapper.findAllComponents(ChoiceChip)[0].find('input').trigger('change')
46
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['price']])
47
+ })
48
+
49
+ it('removes value from array when already selected', async () => {
50
+ const wrapper = mount(ChoiceChipGroup, {
51
+ props: { type: 'multiple', modelValue: ['price', 'date'] },
52
+ slots: {
53
+ default: () => [
54
+ h(ChoiceChip, { label: 'Price', value: 'price' }),
55
+ h(ChoiceChip, { label: 'Date', value: 'date' })
56
+ ]
57
+ }
58
+ })
59
+
60
+ await wrapper.findAllComponents(ChoiceChip)[0].find('input').trigger('change')
61
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['date']])
62
+ })
63
+
64
+ it('allows multiple values to be selected', async () => {
65
+ const wrapper = mount(ChoiceChipGroup, {
66
+ props: { type: 'multiple', modelValue: ['price'] },
67
+ slots: {
68
+ default: () => [
69
+ h(ChoiceChip, { label: 'Price', value: 'price' }),
70
+ h(ChoiceChip, { label: 'Date', value: 'date' })
71
+ ]
72
+ }
73
+ })
74
+
75
+ await wrapper.findAllComponents(ChoiceChip)[1].find('input').trigger('change')
76
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['price', 'date']])
77
+ })
78
+ })
79
+
80
+ describe('Single selection', () => {
81
+ it('emits selected value on click', async () => {
82
+ const wrapper = mount(ChoiceChipGroup, {
83
+ props: { type: 'single', modelValue: undefined },
84
+ slots: {
85
+ default: () => [
86
+ h(ChoiceChip, { label: 'Price', value: 'price' }),
87
+ h(ChoiceChip, { label: 'Date', value: 'date' })
88
+ ]
89
+ }
90
+ })
91
+
92
+ await wrapper.findAllComponents(ChoiceChip)[0].find('input').trigger('change')
93
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['price'])
94
+ })
95
+
96
+ it('emits undefined when same value clicked (toggle off)', async () => {
97
+ const wrapper = mount(ChoiceChipGroup, {
98
+ props: { type: 'single', modelValue: 'price' },
99
+ slots: {
100
+ default: () => [
101
+ h(ChoiceChip, { label: 'Price', value: 'price' }),
102
+ h(ChoiceChip, { label: 'Date', value: 'date' })
103
+ ]
104
+ }
105
+ })
106
+
107
+ await wrapper.findAllComponents(ChoiceChip)[0].find('input').trigger('change')
108
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([undefined])
109
+ })
110
+
111
+ it('switches selection when different value clicked', async () => {
112
+ const wrapper = mount(ChoiceChipGroup, {
113
+ props: { type: 'single', modelValue: 'price' },
114
+ slots: {
115
+ default: () => [
116
+ h(ChoiceChip, { label: 'Price', value: 'price' }),
117
+ h(ChoiceChip, { label: 'Date', value: 'date' })
118
+ ]
119
+ }
120
+ })
121
+
122
+ await wrapper.findAllComponents(ChoiceChip)[1].find('input').trigger('change')
123
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['date'])
124
+ })
125
+ })
126
+
127
+ describe('Disabled state', () => {
128
+ it('applies disabled class', () => {
129
+ const wrapper = mount(ChoiceChipGroup, {
130
+ props: { disabled: true }
131
+ })
132
+ expect(wrapper.classes()).toContain('ui-choice-chip-group--disabled')
133
+ })
134
+
135
+ it('sets aria-disabled', () => {
136
+ const wrapper = mount(ChoiceChipGroup, {
137
+ props: { disabled: true }
138
+ })
139
+ expect(wrapper.attributes('aria-disabled')).toBe('true')
140
+ })
141
+ })
142
+
143
+ describe('Accessibility', () => {
144
+ it('has aria-label when label prop provided', () => {
145
+ const wrapper = mount(ChoiceChipGroup, {
146
+ props: { label: 'Filter by' }
147
+ })
148
+ expect(wrapper.attributes('aria-label')).toBe('Filter by')
149
+ })
150
+ })
151
+ })
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ import { provide, toRef } from 'vue'
3
+
4
+ export interface ChoiceChipGroupProps {
5
+ /** Selected value(s) - single value for 'single' type, array for 'multiple' */
6
+ modelValue?: string | number | (string | number)[]
7
+ /** Selection type */
8
+ type?: 'single' | 'multiple'
9
+ /** Disabled state for all chips */
10
+ disabled?: boolean
11
+ /** Accessible label for the group */
12
+ label?: string
13
+ }
14
+
15
+ const props = withDefaults(defineProps<ChoiceChipGroupProps>(), {
16
+ type: 'multiple',
17
+ disabled: false
18
+ })
19
+
20
+ const emit = defineEmits<{
21
+ (e: 'update:modelValue', value: string | number | (string | number)[] | undefined): void
22
+ (e: 'change', value: string | number | (string | number)[] | undefined): void
23
+ }>()
24
+
25
+ function toggle(value: string | number) {
26
+ if (props.type === 'single') {
27
+ const newValue = props.modelValue === value ? undefined : value
28
+ emit('update:modelValue', newValue)
29
+ emit('change', newValue)
30
+ } else {
31
+ const currentArray = Array.isArray(props.modelValue) ? props.modelValue : []
32
+ const newArray = currentArray.includes(value)
33
+ ? currentArray.filter(v => v !== value)
34
+ : [...currentArray, value]
35
+ emit('update:modelValue', newArray)
36
+ emit('change', newArray)
37
+ }
38
+ }
39
+
40
+ provide('choiceChipGroup', {
41
+ modelValue: toRef(props, 'modelValue'),
42
+ toggle,
43
+ type: props.type,
44
+ disabled: props.disabled
45
+ })
46
+ </script>
47
+
48
+ <template>
49
+ <div
50
+ role="group"
51
+ :aria-label="label"
52
+ :aria-disabled="disabled || undefined"
53
+ class="ui-choice-chip-group"
54
+ :class="{ 'ui-choice-chip-group--disabled': disabled }"
55
+ >
56
+ <slot />
57
+ </div>
58
+ </template>
59
+
60
+ <style scoped>
61
+ .ui-choice-chip-group {
62
+ display: flex;
63
+ flex-wrap: wrap;
64
+ gap: var(--space-2);
65
+ }
66
+
67
+ .ui-choice-chip-group--disabled {
68
+ opacity: 0.5;
69
+ }
70
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as ChoiceChipGroup } from './ChoiceChipGroup.vue'
2
+ export type { ChoiceChipGroupProps } from './ChoiceChipGroup.vue'
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onUnmounted } from 'vue'
3
+ import { clamp } from '../../utils/color'
4
+
5
+ export interface ColorAreaProps {
6
+ /** Hue value (0-360) */
7
+ hue: number
8
+ /** Saturation value (0-1) */
9
+ saturation: number
10
+ /** Value/Brightness (0-1) */
11
+ value: number
12
+ }
13
+
14
+ const props = defineProps<ColorAreaProps>()
15
+
16
+ const emit = defineEmits<{
17
+ 'update:saturation': [value: number]
18
+ 'update:value': [value: number]
19
+ }>()
20
+
21
+ const areaRef = ref<HTMLDivElement | null>(null)
22
+ const isDragging = ref(false)
23
+
24
+ const thumbPosition = computed(() => ({
25
+ left: `${props.saturation * 100}%`,
26
+ top: `${(1 - props.value) * 100}%`
27
+ }))
28
+
29
+ const hueColor = computed(() => `hsl(${props.hue}, 100%, 50%)`)
30
+
31
+ function updateValue(clientX: number, clientY: number) {
32
+ if (!areaRef.value) return
33
+
34
+ const rect = areaRef.value.getBoundingClientRect()
35
+ const x = clientX - rect.left
36
+ const y = clientY - rect.top
37
+
38
+ const saturation = clamp(x / rect.width, 0, 1)
39
+ const value = clamp(1 - y / rect.height, 0, 1)
40
+
41
+ emit('update:saturation', saturation)
42
+ emit('update:value', value)
43
+ }
44
+
45
+ function handleMouseDown(event: MouseEvent) {
46
+ event.preventDefault()
47
+ isDragging.value = true
48
+ updateValue(event.clientX, event.clientY)
49
+
50
+ document.addEventListener('mousemove', handleMouseMove)
51
+ document.addEventListener('mouseup', handleMouseUp)
52
+ }
53
+
54
+ function handleMouseMove(event: MouseEvent) {
55
+ if (!isDragging.value) return
56
+ updateValue(event.clientX, event.clientY)
57
+ }
58
+
59
+ function handleMouseUp() {
60
+ isDragging.value = false
61
+ document.removeEventListener('mousemove', handleMouseMove)
62
+ document.removeEventListener('mouseup', handleMouseUp)
63
+ }
64
+
65
+ function handleTouchStart(event: TouchEvent) {
66
+ event.preventDefault()
67
+ isDragging.value = true
68
+ updateValue(event.touches[0].clientX, event.touches[0].clientY)
69
+
70
+ document.addEventListener('touchmove', handleTouchMove, { passive: false })
71
+ document.addEventListener('touchend', handleTouchEnd)
72
+ }
73
+
74
+ function handleTouchMove(event: TouchEvent) {
75
+ event.preventDefault()
76
+ if (!isDragging.value) return
77
+ updateValue(event.touches[0].clientX, event.touches[0].clientY)
78
+ }
79
+
80
+ function handleTouchEnd() {
81
+ isDragging.value = false
82
+ document.removeEventListener('touchmove', handleTouchMove)
83
+ document.removeEventListener('touchend', handleTouchEnd)
84
+ }
85
+
86
+ onUnmounted(() => {
87
+ document.removeEventListener('mousemove', handleMouseMove)
88
+ document.removeEventListener('mouseup', handleMouseUp)
89
+ document.removeEventListener('touchmove', handleTouchMove)
90
+ document.removeEventListener('touchend', handleTouchEnd)
91
+ })
92
+ </script>
93
+
94
+ <template>
95
+ <div
96
+ ref="areaRef"
97
+ class="ui-color-area"
98
+ :class="{ 'ui-color-area--dragging': isDragging }"
99
+ :style="{ '--hue-color': hueColor }"
100
+ @mousedown="handleMouseDown"
101
+ @touchstart="handleTouchStart"
102
+ >
103
+ <div class="ui-color-area__gradient ui-color-area__gradient--saturation" />
104
+ <div class="ui-color-area__gradient ui-color-area__gradient--value" />
105
+ <div
106
+ class="ui-color-area__thumb"
107
+ :style="thumbPosition"
108
+ />
109
+ </div>
110
+ </template>
111
+
112
+ <style scoped>
113
+ .ui-color-area {
114
+ position: relative;
115
+ width: 100%;
116
+ height: 150px;
117
+ border-radius: var(--radius-md);
118
+ background: var(--hue-color);
119
+ cursor: crosshair;
120
+ user-select: none;
121
+ touch-action: none;
122
+ overflow: hidden;
123
+ }
124
+
125
+ .ui-color-area__gradient {
126
+ position: absolute;
127
+ inset: 0;
128
+ }
129
+
130
+ .ui-color-area__gradient--saturation {
131
+ background: linear-gradient(to right, white, transparent);
132
+ }
133
+
134
+ .ui-color-area__gradient--value {
135
+ background: linear-gradient(to top, black, transparent);
136
+ }
137
+
138
+ .ui-color-area__thumb {
139
+ position: absolute;
140
+ width: 16px;
141
+ height: 16px;
142
+ border-radius: var(--radius-full);
143
+ border: 2px solid var(--bg-primary);
144
+ box-shadow: 0 0 0 1px var(--border-default), var(--shadow-md);
145
+ transform: translate(-50%, -50%);
146
+ pointer-events: none;
147
+ transition: box-shadow var(--duration-fast) var(--ease-default);
148
+ }
149
+
150
+ .ui-color-area:hover .ui-color-area__thumb,
151
+ .ui-color-area--dragging .ui-color-area__thumb {
152
+ box-shadow: 0 0 0 1px var(--border-default), var(--shadow-lg);
153
+ }
154
+
155
+ .ui-color-area:focus-visible {
156
+ outline: 2px solid var(--ring-color);
157
+ outline-offset: 2px;
158
+ }
159
+ </style>
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import ColorPicker from './ColorPicker.vue'
4
+ import ColorArea from './ColorArea.vue'
5
+ import ColorSlider from './ColorSlider.vue'
6
+ import {
7
+ hexToRgb,
8
+ rgbToHex,
9
+ rgbToHsv,
10
+ hsvToRgb,
11
+ hexToHsv,
12
+ hsvToHex,
13
+ oklchToRgb,
14
+ rgbToOklch,
15
+ parseColorString,
16
+ formatColor
17
+ } from '../../utils/color'
18
+
19
+ describe('Color Utilities', () => {
20
+ describe('hexToRgb', () => {
21
+ it('converts 6-digit hex', () => {
22
+ expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
23
+ expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 })
24
+ expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 })
25
+ expect(hexToRgb('#ffffff')).toEqual({ r: 255, g: 255, b: 255 })
26
+ expect(hexToRgb('#000000')).toEqual({ r: 0, g: 0, b: 0 })
27
+ })
28
+
29
+ it('converts 3-digit hex', () => {
30
+ expect(hexToRgb('#f00')).toEqual({ r: 255, g: 0, b: 0 })
31
+ expect(hexToRgb('#0f0')).toEqual({ r: 0, g: 255, b: 0 })
32
+ expect(hexToRgb('#00f')).toEqual({ r: 0, g: 0, b: 255 })
33
+ })
34
+ })
35
+
36
+ describe('rgbToHex', () => {
37
+ it('converts RGB to hex', () => {
38
+ expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000')
39
+ expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe('#00ff00')
40
+ expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe('#0000ff')
41
+ })
42
+ })
43
+
44
+ describe('rgbToHsv / hsvToRgb', () => {
45
+ it('converts RGB to HSV and back', () => {
46
+ const testColors = [
47
+ { r: 255, g: 0, b: 0 },
48
+ { r: 0, g: 255, b: 0 },
49
+ { r: 0, g: 0, b: 255 },
50
+ { r: 255, g: 255, b: 0 },
51
+ { r: 128, g: 128, b: 128 }
52
+ ]
53
+
54
+ testColors.forEach(rgb => {
55
+ const hsv = rgbToHsv(rgb)
56
+ const backToRgb = hsvToRgb(hsv)
57
+ expect(backToRgb.r).toBeCloseTo(rgb.r, 0)
58
+ expect(backToRgb.g).toBeCloseTo(rgb.g, 0)
59
+ expect(backToRgb.b).toBeCloseTo(rgb.b, 0)
60
+ })
61
+ })
62
+
63
+ it('handles grayscale (no hue)', () => {
64
+ const gray = rgbToHsv({ r: 128, g: 128, b: 128 })
65
+ expect(gray.s).toBe(0)
66
+ })
67
+ })
68
+
69
+ describe('hexToHsv / hsvToHex', () => {
70
+ it('converts hex to HSV and back', () => {
71
+ const colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff']
72
+
73
+ colors.forEach(hex => {
74
+ const hsv = hexToHsv(hex)
75
+ const backToHex = hsvToHex(hsv)
76
+ expect(backToHex).toBe(hex)
77
+ })
78
+ })
79
+ })
80
+
81
+ describe('OKLCH conversions', () => {
82
+ it('converts RGB to OKLCH and back', () => {
83
+ const testColors = [
84
+ { r: 255, g: 0, b: 0 },
85
+ { r: 0, g: 255, b: 0 },
86
+ { r: 0, g: 0, b: 255 },
87
+ { r: 255, g: 255, b: 255 },
88
+ { r: 0, g: 0, b: 0 }
89
+ ]
90
+
91
+ testColors.forEach(rgb => {
92
+ const oklch = rgbToOklch(rgb)
93
+ const backToRgb = oklchToRgb(oklch)
94
+ expect(backToRgb.r).toBeCloseTo(rgb.r, 0)
95
+ expect(backToRgb.g).toBeCloseTo(rgb.g, 0)
96
+ expect(backToRgb.b).toBeCloseTo(rgb.b, 0)
97
+ })
98
+ })
99
+ })
100
+
101
+ describe('parseColorString', () => {
102
+ it('parses hex colors', () => {
103
+ const result = parseColorString('#ff0000')
104
+ expect(result).not.toBeNull()
105
+ expect(result!.format).toBe('hex')
106
+ expect(result!.alpha).toBe(1)
107
+ })
108
+
109
+ it('parses hex with alpha', () => {
110
+ const result = parseColorString('#ff000080')
111
+ expect(result).not.toBeNull()
112
+ expect(result!.format).toBe('hex')
113
+ expect(result!.alpha).toBeCloseTo(0.5, 1)
114
+ })
115
+
116
+ it('parses rgb colors', () => {
117
+ const result = parseColorString('rgb(255, 0, 0)')
118
+ expect(result).not.toBeNull()
119
+ expect(result!.format).toBe('rgb')
120
+ })
121
+
122
+ it('parses rgba colors', () => {
123
+ const result = parseColorString('rgba(255, 0, 0, 0.5)')
124
+ expect(result).not.toBeNull()
125
+ expect(result!.format).toBe('rgb')
126
+ expect(result!.alpha).toBe(0.5)
127
+ })
128
+
129
+ it('parses oklch colors', () => {
130
+ const result = parseColorString('oklch(62.8% 0.258 29.2)')
131
+ expect(result).not.toBeNull()
132
+ expect(result!.format).toBe('oklch')
133
+ })
134
+
135
+ it('returns null for invalid colors', () => {
136
+ expect(parseColorString('invalid')).toBeNull()
137
+ expect(parseColorString('')).toBeNull()
138
+ })
139
+ })
140
+
141
+ describe('formatColor', () => {
142
+ it('formats as hex', () => {
143
+ const hsv = { h: 0, s: 1, v: 1 }
144
+ expect(formatColor(hsv, 1, 'hex')).toBe('#ff0000')
145
+ })
146
+
147
+ it('formats hex with alpha', () => {
148
+ const hsv = { h: 0, s: 1, v: 1 }
149
+ expect(formatColor(hsv, 0.5, 'hex')).toBe('#ff000080')
150
+ })
151
+
152
+ it('formats as rgb', () => {
153
+ const hsv = { h: 0, s: 1, v: 1 }
154
+ expect(formatColor(hsv, 1, 'rgb')).toBe('rgb(255, 0, 0)')
155
+ })
156
+
157
+ it('formats as rgba', () => {
158
+ const hsv = { h: 0, s: 1, v: 1 }
159
+ expect(formatColor(hsv, 0.5, 'rgb')).toBe('rgba(255, 0, 0, 0.5)')
160
+ })
161
+
162
+ it('formats as oklch', () => {
163
+ const hsv = { h: 0, s: 1, v: 1 }
164
+ const result = formatColor(hsv, 1, 'oklch')
165
+ expect(result).toMatch(/^oklch\([\d.]+% [\d.]+ [\d.]+\)$/)
166
+ })
167
+ })
168
+ })
169
+
170
+ describe('ColorSlider', () => {
171
+ it('renders hue slider', () => {
172
+ const wrapper = mount(ColorSlider, {
173
+ props: { modelValue: 180, type: 'hue' }
174
+ })
175
+ expect(wrapper.classes()).toContain('ui-color-slider--hue')
176
+ })
177
+
178
+ it('renders alpha slider', () => {
179
+ const wrapper = mount(ColorSlider, {
180
+ props: { modelValue: 0.5, type: 'alpha', color: '#ff0000' }
181
+ })
182
+ expect(wrapper.classes()).toContain('ui-color-slider--alpha')
183
+ expect(wrapper.find('.ui-color-slider__checkerboard').exists()).toBe(true)
184
+ })
185
+
186
+ it('positions thumb based on hue value', () => {
187
+ const wrapper = mount(ColorSlider, {
188
+ props: { modelValue: 180, type: 'hue' }
189
+ })
190
+ const thumb = wrapper.find('.ui-color-slider__thumb')
191
+ expect(thumb.attributes('style')).toContain('left: 50%')
192
+ })
193
+
194
+ it('positions thumb based on alpha value', () => {
195
+ const wrapper = mount(ColorSlider, {
196
+ props: { modelValue: 0.5, type: 'alpha', color: '#ff0000' }
197
+ })
198
+ const thumb = wrapper.find('.ui-color-slider__thumb')
199
+ expect(thumb.attributes('style')).toContain('left: 50%')
200
+ })
201
+ })
202
+
203
+ describe('ColorArea', () => {
204
+ it('renders with hue background', () => {
205
+ const wrapper = mount(ColorArea, {
206
+ props: { hue: 0, saturation: 1, value: 1 }
207
+ })
208
+ expect(wrapper.classes()).toContain('ui-color-area')
209
+ expect(wrapper.attributes('style')).toContain('--hue-color')
210
+ })
211
+
212
+ it('positions thumb based on saturation and value', () => {
213
+ const wrapper = mount(ColorArea, {
214
+ props: { hue: 0, saturation: 0.5, value: 0.5 }
215
+ })
216
+ const thumb = wrapper.find('.ui-color-area__thumb')
217
+ expect(thumb.attributes('style')).toContain('left: 50%')
218
+ expect(thumb.attributes('style')).toContain('top: 50%')
219
+ })
220
+ })
221
+
222
+ describe('ColorPicker', () => {
223
+ it('renders with swatch', () => {
224
+ const wrapper = mount(ColorPicker, {
225
+ props: { modelValue: '#ff0000' }
226
+ })
227
+ expect(wrapper.find('.ui-color-picker__swatch').exists()).toBe(true)
228
+ })
229
+
230
+ it('renders with input by default', () => {
231
+ const wrapper = mount(ColorPicker, {
232
+ props: { modelValue: '#ff0000' }
233
+ })
234
+ expect(wrapper.find('.ui-color-picker__input').exists()).toBe(true)
235
+ })
236
+
237
+ it('hides input when swatchOnly is true', () => {
238
+ const wrapper = mount(ColorPicker, {
239
+ props: { modelValue: '#ff0000', swatchOnly: true }
240
+ })
241
+ expect(wrapper.find('.ui-color-picker__input').exists()).toBe(false)
242
+ })
243
+
244
+ it('disables when disabled prop is true', () => {
245
+ const wrapper = mount(ColorPicker, {
246
+ props: { modelValue: '#ff0000', disabled: true }
247
+ })
248
+ expect(wrapper.find('.ui-color-picker__trigger--disabled').exists()).toBe(true)
249
+ })
250
+ })