@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,359 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import BaseChart from './BaseChart.vue'
5
+ import LineChart from './LineChart.vue'
6
+ import BarChart from './BarChart.vue'
7
+ import DonutChart from './DonutChart.vue'
8
+
9
+ vi.mock('chart.js', () => {
10
+ class MockChart {
11
+ static register = vi.fn()
12
+ destroy = vi.fn()
13
+ update = vi.fn()
14
+ resize = vi.fn()
15
+ data: { datasets: unknown[] }
16
+ options: unknown
17
+ isDatasetVisible = vi.fn().mockReturnValue(true)
18
+ setDatasetVisibility = vi.fn()
19
+
20
+ constructor(
21
+ _canvas: HTMLCanvasElement,
22
+ config: { data?: { datasets?: unknown[] }; options?: unknown }
23
+ ) {
24
+ this.data = config.data && config.data.datasets
25
+ ? config.data as { datasets: unknown[] }
26
+ : { datasets: [] }
27
+ this.options = config.options || {}
28
+ }
29
+ }
30
+
31
+ return {
32
+ Chart: MockChart,
33
+ registerables: []
34
+ }
35
+ })
36
+
37
+ const mockGetComputedStyle = vi.fn().mockReturnValue({
38
+ getPropertyValue: vi.fn().mockReturnValue('')
39
+ })
40
+
41
+ class MockResizeObserver {
42
+ observe = vi.fn()
43
+ disconnect = vi.fn()
44
+ unobserve = vi.fn()
45
+ constructor() {}
46
+ }
47
+
48
+ beforeEach(() => {
49
+ vi.stubGlobal('getComputedStyle', mockGetComputedStyle)
50
+ vi.stubGlobal('ResizeObserver', MockResizeObserver)
51
+ })
52
+
53
+ afterEach(() => {
54
+ vi.unstubAllGlobals()
55
+ })
56
+
57
+ describe('BaseChart', () => {
58
+ const defaultProps = {
59
+ type: 'line' as const,
60
+ data: {
61
+ labels: ['Jan', 'Feb', 'Mar'],
62
+ datasets: [
63
+ { label: 'Sales', data: [10, 20, 30] }
64
+ ]
65
+ }
66
+ }
67
+
68
+ describe('Rendering', () => {
69
+ it('renders canvas element', () => {
70
+ const wrapper = mount(BaseChart, { props: defaultProps })
71
+ expect(wrapper.find('canvas').exists()).toBe(true)
72
+ })
73
+
74
+ it('renders chart container', () => {
75
+ const wrapper = mount(BaseChart, { props: defaultProps })
76
+ expect(wrapper.find('.ui-chart').exists()).toBe(true)
77
+ })
78
+
79
+ it('renders canvas container with height', () => {
80
+ const wrapper = mount(BaseChart, {
81
+ props: { ...defaultProps, height: 400 }
82
+ })
83
+ const container = wrapper.find('.ui-chart__canvas-container')
84
+ expect(container.attributes('style')).toContain('height: 400px')
85
+ })
86
+
87
+ it('accepts string height', () => {
88
+ const wrapper = mount(BaseChart, {
89
+ props: { ...defaultProps, height: '50vh' }
90
+ })
91
+ const container = wrapper.find('.ui-chart__canvas-container')
92
+ expect(container.attributes('style')).toContain('height: 50vh')
93
+ })
94
+ })
95
+
96
+ describe('Accessibility', () => {
97
+ it('has role="img" on canvas', () => {
98
+ const wrapper = mount(BaseChart, { props: defaultProps })
99
+ expect(wrapper.find('canvas').attributes('role')).toBe('img')
100
+ })
101
+
102
+ it('sets aria-label on canvas', () => {
103
+ const wrapper = mount(BaseChart, {
104
+ props: { ...defaultProps, ariaLabel: 'Sales chart' }
105
+ })
106
+ expect(wrapper.find('canvas').attributes('aria-label')).toBe('Sales chart')
107
+ })
108
+
109
+ it('renders screen reader table fallback', () => {
110
+ const wrapper = mount(BaseChart, { props: defaultProps })
111
+ expect(wrapper.find('.ui-chart__sr-table').exists()).toBe(true)
112
+ })
113
+
114
+ it('populates fallback table with data', () => {
115
+ const wrapper = mount(BaseChart, { props: defaultProps })
116
+ const headers = wrapper.findAll('.ui-chart__sr-table th')
117
+ expect(headers.length).toBeGreaterThan(0)
118
+ })
119
+ })
120
+
121
+ describe('Props', () => {
122
+ it('accepts different chart types', async () => {
123
+ const wrapper = mount(BaseChart, {
124
+ props: { ...defaultProps, type: 'bar' }
125
+ })
126
+ expect(wrapper.props('type')).toBe('bar')
127
+
128
+ await wrapper.setProps({ type: 'doughnut' })
129
+ expect(wrapper.props('type')).toBe('doughnut')
130
+ })
131
+
132
+ it('defaults height to 300', () => {
133
+ const wrapper = mount(BaseChart, { props: defaultProps })
134
+ const container = wrapper.find('.ui-chart__canvas-container')
135
+ expect(container.attributes('style')).toContain('height: 300px')
136
+ })
137
+ })
138
+ })
139
+
140
+ describe('LineChart', () => {
141
+ const defaultProps = {
142
+ labels: ['Jan', 'Feb', 'Mar', 'Apr'],
143
+ series: [
144
+ { name: 'Revenue', data: [100, 200, 150, 300] },
145
+ { name: 'Expenses', data: [80, 120, 100, 180] }
146
+ ]
147
+ }
148
+
149
+ describe('Rendering', () => {
150
+ it('renders BaseChart with type line', () => {
151
+ const wrapper = mount(LineChart, { props: defaultProps })
152
+ const baseChart = wrapper.findComponent(BaseChart)
153
+ expect(baseChart.exists()).toBe(true)
154
+ })
155
+
156
+ it('renders legend by default', () => {
157
+ const wrapper = mount(LineChart, { props: defaultProps })
158
+ expect(wrapper.find('.ui-line-chart__legend').exists()).toBe(true)
159
+ })
160
+
161
+ it('renders legend items for each series', async () => {
162
+ const wrapper = mount(LineChart, { props: defaultProps })
163
+ await nextTick()
164
+ const items = wrapper.findAll('.ui-line-chart__legend-item')
165
+ expect(items.length).toBe(2)
166
+ })
167
+
168
+ it('hides legend when showLegend is false', () => {
169
+ const wrapper = mount(LineChart, {
170
+ props: { ...defaultProps, showLegend: false }
171
+ })
172
+ expect(wrapper.find('.ui-line-chart__legend').exists()).toBe(false)
173
+ })
174
+ })
175
+
176
+ describe('Props', () => {
177
+ it('passes height to BaseChart', () => {
178
+ const wrapper = mount(LineChart, {
179
+ props: { ...defaultProps, height: 500 }
180
+ })
181
+ const baseChart = wrapper.findComponent(BaseChart)
182
+ expect(baseChart.props('height')).toBe(500)
183
+ })
184
+
185
+ it('passes ariaLabel to BaseChart', () => {
186
+ const wrapper = mount(LineChart, {
187
+ props: { ...defaultProps, ariaLabel: 'Revenue over time' }
188
+ })
189
+ const baseChart = wrapper.findComponent(BaseChart)
190
+ expect(baseChart.props('ariaLabel')).toBe('Revenue over time')
191
+ })
192
+
193
+ it('accepts fill prop', () => {
194
+ const wrapper = mount(LineChart, {
195
+ props: { ...defaultProps, fill: true }
196
+ })
197
+ expect(wrapper.props('fill')).toBe(true)
198
+ })
199
+
200
+ it('accepts tension prop', () => {
201
+ const wrapper = mount(LineChart, {
202
+ props: { ...defaultProps, tension: 0.2 }
203
+ })
204
+ expect(wrapper.props('tension')).toBe(0.2)
205
+ })
206
+ })
207
+
208
+ describe('Legend interaction', () => {
209
+ it('legend items are clickable', async () => {
210
+ const wrapper = mount(LineChart, { props: defaultProps })
211
+ await nextTick()
212
+ const legendItem = wrapper.find('.ui-line-chart__legend-item')
213
+ expect(legendItem.element.tagName).toBe('BUTTON')
214
+ })
215
+ })
216
+ })
217
+
218
+ describe('BarChart', () => {
219
+ const defaultProps = {
220
+ labels: ['Q1', 'Q2', 'Q3', 'Q4'],
221
+ series: [
222
+ { name: 'Sales', data: [100, 150, 120, 200] }
223
+ ]
224
+ }
225
+
226
+ describe('Rendering', () => {
227
+ it('renders BaseChart with type bar', () => {
228
+ const wrapper = mount(BarChart, { props: defaultProps })
229
+ const baseChart = wrapper.findComponent(BaseChart)
230
+ expect(baseChart.exists()).toBe(true)
231
+ expect(baseChart.props('type')).toBe('bar')
232
+ })
233
+
234
+ it('renders legend by default', () => {
235
+ const wrapper = mount(BarChart, { props: defaultProps })
236
+ expect(wrapper.find('.ui-bar-chart__legend').exists()).toBe(true)
237
+ })
238
+ })
239
+
240
+ describe('Props', () => {
241
+ it('accepts horizontal prop', () => {
242
+ const wrapper = mount(BarChart, {
243
+ props: { ...defaultProps, horizontal: true }
244
+ })
245
+ expect(wrapper.props('horizontal')).toBe(true)
246
+ })
247
+
248
+ it('accepts stacked prop', () => {
249
+ const wrapper = mount(BarChart, {
250
+ props: { ...defaultProps, stacked: true }
251
+ })
252
+ expect(wrapper.props('stacked')).toBe(true)
253
+ })
254
+
255
+ it('accepts borderRadius prop', () => {
256
+ const wrapper = mount(BarChart, {
257
+ props: { ...defaultProps, borderRadius: 8 }
258
+ })
259
+ expect(wrapper.props('borderRadius')).toBe(8)
260
+ })
261
+ })
262
+ })
263
+
264
+ describe('DonutChart', () => {
265
+ const defaultProps = {
266
+ segments: [
267
+ { label: 'Direct', value: 300 },
268
+ { label: 'Referral', value: 200 },
269
+ { label: 'Social', value: 100 }
270
+ ]
271
+ }
272
+
273
+ describe('Rendering', () => {
274
+ it('renders BaseChart with type doughnut', () => {
275
+ const wrapper = mount(DonutChart, { props: defaultProps })
276
+ const baseChart = wrapper.findComponent(BaseChart)
277
+ expect(baseChart.exists()).toBe(true)
278
+ expect(baseChart.props('type')).toBe('doughnut')
279
+ })
280
+
281
+ it('renders legend by default', () => {
282
+ const wrapper = mount(DonutChart, { props: defaultProps })
283
+ expect(wrapper.find('.ui-donut-chart__legend').exists()).toBe(true)
284
+ })
285
+
286
+ it('renders legend items for each segment', () => {
287
+ const wrapper = mount(DonutChart, { props: defaultProps })
288
+ const items = wrapper.findAll('.ui-donut-chart__legend-item')
289
+ expect(items.length).toBe(3)
290
+ })
291
+
292
+ it('displays percentage values in legend', () => {
293
+ const wrapper = mount(DonutChart, { props: defaultProps })
294
+ const values = wrapper.findAll('.ui-donut-chart__legend-value')
295
+ expect(values[0].text()).toBe('50%')
296
+ expect(values[1].text()).toBe('33%')
297
+ expect(values[2].text()).toBe('17%')
298
+ })
299
+ })
300
+
301
+ describe('Center content', () => {
302
+ it('renders center value when provided', () => {
303
+ const wrapper = mount(DonutChart, {
304
+ props: { ...defaultProps, centerValue: '600' }
305
+ })
306
+ expect(wrapper.find('.ui-donut-chart__center-value').text()).toBe('600')
307
+ })
308
+
309
+ it('renders center label when provided', () => {
310
+ const wrapper = mount(DonutChart, {
311
+ props: { ...defaultProps, centerLabel: 'Total' }
312
+ })
313
+ expect(wrapper.find('.ui-donut-chart__center-label').text()).toBe('Total')
314
+ })
315
+
316
+ it('renders center slot content', () => {
317
+ const wrapper = mount(DonutChart, {
318
+ props: defaultProps,
319
+ slots: {
320
+ center: '<div data-testid="custom-center">Custom</div>'
321
+ }
322
+ })
323
+ expect(wrapper.find('[data-testid="custom-center"]').exists()).toBe(true)
324
+ })
325
+ })
326
+
327
+ describe('Props', () => {
328
+ it('accepts cutout prop', () => {
329
+ const wrapper = mount(DonutChart, {
330
+ props: { ...defaultProps, cutout: 50 }
331
+ })
332
+ expect(wrapper.props('cutout')).toBe(50)
333
+ })
334
+
335
+ it('accepts legendPosition prop', async () => {
336
+ const wrapper = mount(DonutChart, {
337
+ props: { ...defaultProps, legendPosition: 'bottom' }
338
+ })
339
+ expect(wrapper.find('.ui-donut-chart--legend-bottom').exists()).toBe(true)
340
+
341
+ await wrapper.setProps({ legendPosition: 'left' })
342
+ expect(wrapper.find('.ui-donut-chart--legend-left').exists()).toBe(true)
343
+ })
344
+
345
+ it('accepts custom segment colors', () => {
346
+ const wrapper = mount(DonutChart, {
347
+ props: {
348
+ segments: [
349
+ { label: 'A', value: 100, color: '#ff0000' },
350
+ { label: 'B', value: 200, color: '#00ff00' }
351
+ ]
352
+ }
353
+ })
354
+ const colors = wrapper.findAll('.ui-donut-chart__legend-color')
355
+ expect(colors[0].attributes('style')).toContain('background-color: #ff0000')
356
+ expect(colors[1].attributes('style')).toContain('background-color: #00ff00')
357
+ })
358
+ })
359
+ })
@@ -0,0 +1,283 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ChartData, ChartOptions } from 'chart.js'
4
+ import BaseChart from './BaseChart.vue'
5
+ import type { LegendItem, TooltipData, PointClickData } from './BaseChart.vue'
6
+ import { useChartTheme } from './useChartTheme'
7
+
8
+ type DonutChartData = ChartData<'doughnut'>
9
+ type DonutChartOptions = ChartOptions<'doughnut'>
10
+
11
+ export interface DonutChartSegment {
12
+ label: string
13
+ value: number
14
+ color?: string
15
+ }
16
+
17
+ export interface SegmentClickData {
18
+ segment: DonutChartSegment
19
+ index: number
20
+ percentage: number
21
+ }
22
+
23
+ export interface DonutChartProps {
24
+ /** Chart segments */
25
+ segments: DonutChartSegment[]
26
+ /** Chart height */
27
+ height?: string | number
28
+ /** Donut cutout percentage (0-100, 0 = pie chart) */
29
+ cutout?: number
30
+ /** Accessible label */
31
+ ariaLabel?: string
32
+ /** Show built-in legend */
33
+ showLegend?: boolean
34
+ /** Legend position */
35
+ legendPosition?: 'top' | 'bottom' | 'left' | 'right'
36
+ /** Center label (for donut charts) */
37
+ centerLabel?: string
38
+ /** Center value (for donut charts) */
39
+ centerValue?: string
40
+ }
41
+
42
+ const props = withDefaults(defineProps<DonutChartProps>(), {
43
+ height: 300,
44
+ cutout: 65,
45
+ showLegend: true,
46
+ legendPosition: 'right'
47
+ })
48
+
49
+ defineSlots<{
50
+ legend?: (props: { items: LegendItem[]; toggle: (index: number) => void }) => unknown
51
+ tooltip?: (props: { data: TooltipData | null }) => unknown
52
+ center?: () => unknown
53
+ }>()
54
+
55
+ const emit = defineEmits<{
56
+ segmentClick: [data: SegmentClickData]
57
+ }>()
58
+
59
+ const { getColor } = useChartTheme()
60
+
61
+ const chartData = computed(() => ({
62
+ labels: props.segments.map((s) => s.label),
63
+ datasets: [
64
+ {
65
+ data: props.segments.map((s) => s.value),
66
+ backgroundColor: props.segments.map((s, i) => s.color || getColor(i)),
67
+ borderWidth: 0,
68
+ hoverOffset: 4
69
+ }
70
+ ]
71
+ }) as DonutChartData)
72
+
73
+ const chartOptions = computed(() => ({
74
+ cutout: `${props.cutout}%`,
75
+ plugins: {
76
+ legend: {
77
+ display: false
78
+ }
79
+ },
80
+ interaction: {
81
+ mode: 'nearest' as const,
82
+ intersect: true
83
+ }
84
+ }) as DonutChartOptions)
85
+
86
+ const total = computed(() =>
87
+ props.segments.reduce((sum, s) => sum + s.value, 0)
88
+ )
89
+
90
+ const legendItems = computed<LegendItem[]>(() =>
91
+ props.segments.map((segment, index) => ({
92
+ label: segment.label,
93
+ color: segment.color || getColor(index),
94
+ hidden: false,
95
+ datasetIndex: index
96
+ }))
97
+ )
98
+
99
+ function formatPercent(value: number): string {
100
+ return `${Math.round((value / total.value) * 100)}%`
101
+ }
102
+
103
+ function handlePointClick(data: PointClickData) {
104
+ const index = data.index
105
+ const segment = props.segments[index]
106
+ if (segment) {
107
+ emit('segmentClick', {
108
+ segment,
109
+ index,
110
+ percentage: Math.round((segment.value / total.value) * 100)
111
+ })
112
+ }
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <div
118
+ class="ui-donut-chart"
119
+ :class="[`ui-donut-chart--legend-${legendPosition}`]"
120
+ >
121
+ <!-- Legend (conditional position) -->
122
+ <slot v-if="showLegend && (legendPosition === 'top' || legendPosition === 'left')" name="legend" :items="legendItems" :toggle="() => {}">
123
+ <div class="ui-donut-chart__legend">
124
+ <div
125
+ v-for="(segment, index) in segments"
126
+ :key="index"
127
+ class="ui-donut-chart__legend-item"
128
+ >
129
+ <span
130
+ class="ui-donut-chart__legend-color"
131
+ :style="{ backgroundColor: segment.color || getColor(index) }"
132
+ />
133
+ <span class="ui-donut-chart__legend-label">{{ segment.label }}</span>
134
+ <span class="ui-donut-chart__legend-value">{{ formatPercent(segment.value) }}</span>
135
+ </div>
136
+ </div>
137
+ </slot>
138
+
139
+ <!-- Chart Container -->
140
+ <div class="ui-donut-chart__container">
141
+ <BaseChart
142
+ type="doughnut"
143
+ :data="chartData"
144
+ :options="chartOptions"
145
+ :height="height"
146
+ :aria-label="ariaLabel"
147
+ @point-click="handlePointClick"
148
+ >
149
+ <template #legend>
150
+ <!-- Disable BaseChart legend, we render our own -->
151
+ </template>
152
+ <template #tooltip="{ data }">
153
+ <slot name="tooltip" :data="data" />
154
+ </template>
155
+ </BaseChart>
156
+
157
+ <!-- Center Content -->
158
+ <div v-if="centerLabel || centerValue || $slots.center" class="ui-donut-chart__center">
159
+ <slot name="center">
160
+ <div v-if="centerValue" class="ui-donut-chart__center-value">
161
+ {{ centerValue }}
162
+ </div>
163
+ <div v-if="centerLabel" class="ui-donut-chart__center-label">
164
+ {{ centerLabel }}
165
+ </div>
166
+ </slot>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Legend (conditional position) -->
171
+ <slot v-if="showLegend && (legendPosition === 'bottom' || legendPosition === 'right')" name="legend" :items="legendItems" :toggle="() => {}">
172
+ <div class="ui-donut-chart__legend">
173
+ <div
174
+ v-for="(segment, index) in segments"
175
+ :key="index"
176
+ class="ui-donut-chart__legend-item"
177
+ >
178
+ <span
179
+ class="ui-donut-chart__legend-color"
180
+ :style="{ backgroundColor: segment.color || getColor(index) }"
181
+ />
182
+ <span class="ui-donut-chart__legend-label">{{ segment.label }}</span>
183
+ <span class="ui-donut-chart__legend-value">{{ formatPercent(segment.value) }}</span>
184
+ </div>
185
+ </div>
186
+ </slot>
187
+ </div>
188
+ </template>
189
+
190
+ <style scoped>
191
+ .ui-donut-chart {
192
+ display: flex;
193
+ font-family: var(--font-sans);
194
+ }
195
+
196
+ .ui-donut-chart--legend-top,
197
+ .ui-donut-chart--legend-bottom {
198
+ flex-direction: column;
199
+ gap: var(--space-4);
200
+ }
201
+
202
+ .ui-donut-chart--legend-left,
203
+ .ui-donut-chart--legend-right {
204
+ flex-direction: row;
205
+ gap: var(--space-6);
206
+ align-items: center;
207
+ }
208
+
209
+ .ui-donut-chart--legend-left {
210
+ flex-direction: row-reverse;
211
+ }
212
+
213
+ .ui-donut-chart__container {
214
+ position: relative;
215
+ flex-shrink: 0;
216
+ }
217
+
218
+ .ui-donut-chart--legend-left .ui-donut-chart__container,
219
+ .ui-donut-chart--legend-right .ui-donut-chart__container {
220
+ flex: 1;
221
+ max-width: 50%;
222
+ }
223
+
224
+ .ui-donut-chart__center {
225
+ position: absolute;
226
+ top: 50%;
227
+ left: 50%;
228
+ transform: translate(-50%, -50%);
229
+ text-align: center;
230
+ pointer-events: none;
231
+ }
232
+
233
+ .ui-donut-chart__center-value {
234
+ font-size: var(--text-2xl);
235
+ font-weight: var(--font-semibold);
236
+ color: var(--chart-text, var(--text-primary));
237
+ line-height: 1;
238
+ }
239
+
240
+ .ui-donut-chart__center-label {
241
+ font-size: var(--text-sm);
242
+ color: var(--chart-text-muted, var(--text-secondary));
243
+ margin-top: var(--space-1);
244
+ }
245
+
246
+ .ui-donut-chart__legend {
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: var(--space-2);
250
+ }
251
+
252
+ .ui-donut-chart--legend-top .ui-donut-chart__legend,
253
+ .ui-donut-chart--legend-bottom .ui-donut-chart__legend {
254
+ flex-direction: row;
255
+ flex-wrap: wrap;
256
+ gap: var(--space-4);
257
+ }
258
+
259
+ .ui-donut-chart__legend-item {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: var(--space-2);
263
+ font-size: var(--text-sm);
264
+ }
265
+
266
+ .ui-donut-chart__legend-color {
267
+ width: 10px;
268
+ height: 10px;
269
+ border-radius: var(--radius-full);
270
+ flex-shrink: 0;
271
+ }
272
+
273
+ .ui-donut-chart__legend-label {
274
+ color: var(--chart-legend-text, var(--text-secondary));
275
+ flex: 1;
276
+ }
277
+
278
+ .ui-donut-chart__legend-value {
279
+ color: var(--chart-text, var(--text-primary));
280
+ font-weight: var(--font-medium);
281
+ font-variant-numeric: tabular-nums;
282
+ }
283
+ </style>