@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,208 @@
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 BarChartData = ChartData<'bar'>
9
+ type BarChartOptions = ChartOptions<'bar'>
10
+
11
+ export interface BarChartSeries {
12
+ name: string
13
+ data: number[]
14
+ }
15
+
16
+ export interface BarChartProps {
17
+ /** X-axis labels */
18
+ labels: string[]
19
+ /** Data series */
20
+ series: BarChartSeries[]
21
+ /** Chart height */
22
+ height?: string | number
23
+ /** Horizontal bar orientation */
24
+ horizontal?: boolean
25
+ /** Stack bars on top of each other */
26
+ stacked?: boolean
27
+ /** Bar border radius */
28
+ borderRadius?: number
29
+ /** Show grid lines */
30
+ showGrid?: boolean
31
+ /** Show X axis */
32
+ showXAxis?: boolean
33
+ /** Show Y axis */
34
+ showYAxis?: boolean
35
+ /** Accessible label */
36
+ ariaLabel?: string
37
+ /** Show built-in legend */
38
+ showLegend?: boolean
39
+ }
40
+
41
+ const props = withDefaults(defineProps<BarChartProps>(), {
42
+ height: 300,
43
+ horizontal: false,
44
+ stacked: false,
45
+ borderRadius: 4,
46
+ showGrid: true,
47
+ showXAxis: true,
48
+ showYAxis: true,
49
+ showLegend: true
50
+ })
51
+
52
+ defineSlots<{
53
+ legend?: (props: { items: LegendItem[]; toggle: (index: number) => void }) => unknown
54
+ tooltip?: (props: { data: TooltipData | null }) => unknown
55
+ }>()
56
+
57
+ const emit = defineEmits<{
58
+ pointClick: [data: PointClickData]
59
+ }>()
60
+
61
+ const { theme, getColor } = useChartTheme()
62
+
63
+ const chartData = computed(() => ({
64
+ labels: props.labels,
65
+ datasets: props.series.map((series, index) => ({
66
+ label: series.name,
67
+ data: series.data,
68
+ backgroundColor: getColor(index),
69
+ borderColor: 'transparent',
70
+ borderWidth: 0,
71
+ borderRadius: props.borderRadius,
72
+ borderSkipped: false as const
73
+ }))
74
+ }) as BarChartData)
75
+
76
+ const chartOptions = computed(() => ({
77
+ indexAxis: props.horizontal ? 'y' as const : 'x' as const,
78
+ scales: {
79
+ x: {
80
+ display: props.showXAxis,
81
+ stacked: props.stacked,
82
+ grid: {
83
+ display: props.horizontal ? props.showGrid : false,
84
+ color: theme.value.colors.gridLines
85
+ },
86
+ border: {
87
+ display: false
88
+ },
89
+ ticks: {
90
+ color: theme.value.colors.textMuted,
91
+ font: {
92
+ family: theme.value.fontFamily,
93
+ size: 12
94
+ }
95
+ }
96
+ },
97
+ y: {
98
+ display: props.showYAxis,
99
+ stacked: props.stacked,
100
+ grid: {
101
+ display: props.horizontal ? false : props.showGrid,
102
+ color: theme.value.colors.gridLines
103
+ },
104
+ border: {
105
+ display: false
106
+ },
107
+ ticks: {
108
+ color: theme.value.colors.textMuted,
109
+ font: {
110
+ family: theme.value.fontFamily,
111
+ size: 12
112
+ }
113
+ }
114
+ }
115
+ },
116
+ interaction: {
117
+ mode: 'index' as const,
118
+ axis: props.horizontal ? 'y' as const : 'x' as const,
119
+ intersect: false
120
+ }
121
+ }) as BarChartOptions)
122
+ </script>
123
+
124
+ <template>
125
+ <BaseChart
126
+ type="bar"
127
+ :data="chartData"
128
+ :options="chartOptions"
129
+ :height="height"
130
+ :aria-label="ariaLabel"
131
+ @point-click="emit('pointClick', $event)"
132
+ >
133
+ <template v-if="showLegend || $slots.legend" #legend="{ items, toggle }">
134
+ <slot name="legend" :items="items" :toggle="toggle">
135
+ <div class="ui-bar-chart__legend">
136
+ <button
137
+ v-for="(item, index) in items"
138
+ :key="index"
139
+ type="button"
140
+ class="ui-bar-chart__legend-item"
141
+ :class="{ 'ui-bar-chart__legend-item--hidden': item.hidden }"
142
+ @click="toggle(index)"
143
+ >
144
+ <span
145
+ class="ui-bar-chart__legend-color"
146
+ :style="{ backgroundColor: item.color }"
147
+ />
148
+ <span class="ui-bar-chart__legend-label">{{ item.label }}</span>
149
+ </button>
150
+ </div>
151
+ </slot>
152
+ </template>
153
+ <template #tooltip="{ data }">
154
+ <slot name="tooltip" :data="data" />
155
+ </template>
156
+ </BaseChart>
157
+ </template>
158
+
159
+ <style scoped>
160
+ .ui-bar-chart__legend {
161
+ display: flex;
162
+ flex-wrap: wrap;
163
+ gap: var(--space-3);
164
+ margin-bottom: var(--space-4);
165
+ }
166
+
167
+ .ui-bar-chart__legend-item {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: var(--space-2);
171
+ padding: var(--space-1) var(--space-2);
172
+ background: transparent;
173
+ border: none;
174
+ border-radius: var(--radius-sm);
175
+ cursor: pointer;
176
+ font-family: var(--font-sans);
177
+ font-size: var(--text-sm);
178
+ color: var(--chart-legend-text, var(--text-secondary));
179
+ transition: opacity var(--duration-fast) var(--ease-default);
180
+ }
181
+
182
+ .ui-bar-chart__legend-item:hover {
183
+ background-color: var(--color-stone-100);
184
+ }
185
+
186
+ [data-theme='dark'] .ui-bar-chart__legend-item:hover {
187
+ background-color: var(--color-stone-800);
188
+ }
189
+
190
+ .ui-bar-chart__legend-item--hidden {
191
+ opacity: 0.4;
192
+ }
193
+
194
+ .ui-bar-chart__legend-item--hidden .ui-bar-chart__legend-color {
195
+ opacity: 0.3;
196
+ }
197
+
198
+ .ui-bar-chart__legend-color {
199
+ width: 12px;
200
+ height: 12px;
201
+ border-radius: var(--radius-sm);
202
+ flex-shrink: 0;
203
+ }
204
+
205
+ .ui-bar-chart__legend-label {
206
+ white-space: nowrap;
207
+ }
208
+ </style>
@@ -0,0 +1,444 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ ref,
4
+ shallowRef,
5
+ watch,
6
+ onMounted,
7
+ onUnmounted,
8
+ computed,
9
+ nextTick
10
+ } from 'vue'
11
+ import {
12
+ Chart,
13
+ registerables,
14
+ type ChartType,
15
+ type ChartData,
16
+ type ChartOptions,
17
+ type TooltipModel
18
+ } from 'chart.js'
19
+ import { useChartTheme } from './useChartTheme'
20
+
21
+ Chart.register(...registerables)
22
+
23
+ export interface ChartDataset {
24
+ label?: string
25
+ data: number[]
26
+ backgroundColor?: string | string[]
27
+ borderColor?: string | string[]
28
+ borderWidth?: number
29
+ fill?: boolean | string
30
+ tension?: number
31
+ }
32
+
33
+ export interface TooltipData {
34
+ title: string
35
+ items: Array<{
36
+ label: string
37
+ value: string
38
+ color: string
39
+ }>
40
+ x: number
41
+ y: number
42
+ }
43
+
44
+ export interface LegendItem {
45
+ label: string
46
+ color: string
47
+ hidden: boolean
48
+ datasetIndex: number
49
+ }
50
+
51
+ export interface PointClickData {
52
+ datasetIndex: number
53
+ index: number
54
+ label: string
55
+ value: number | string
56
+ datasetLabel: string
57
+ }
58
+
59
+ export interface BaseChartProps {
60
+ /** Chart type (line, bar, doughnut, pie, etc.) */
61
+ type: ChartType
62
+ /** Chart.js data configuration */
63
+ data: ChartData | Record<string, unknown>
64
+ /** Chart.js options configuration */
65
+ options?: ChartOptions | Record<string, unknown>
66
+ /** Chart height */
67
+ height?: string | number
68
+ /** Accessible label for screen readers */
69
+ ariaLabel?: string
70
+ }
71
+
72
+ const props = withDefaults(defineProps<BaseChartProps>(), {
73
+ height: 300
74
+ })
75
+
76
+ const emit = defineEmits<{
77
+ legendClick: [item: LegendItem, index: number]
78
+ pointClick: [data: PointClickData]
79
+ }>()
80
+
81
+ defineSlots<{
82
+ legend?: (props: { items: LegendItem[]; toggle: (index: number) => void }) => unknown
83
+ tooltip?: (props: { data: TooltipData | null }) => unknown
84
+ fallback?: () => unknown
85
+ }>()
86
+
87
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
88
+ const containerRef = ref<HTMLDivElement | null>(null)
89
+ const chartInstance = shallowRef<Chart | null>(null)
90
+
91
+ const { theme, themeVersion, getDefaultOptions } = useChartTheme()
92
+
93
+ const tooltipData = ref<TooltipData | null>(null)
94
+ const legendItems = ref<LegendItem[]>([])
95
+
96
+ const heightStyle = computed(() => {
97
+ if (typeof props.height === 'number') return `${props.height}px`
98
+ return props.height
99
+ })
100
+
101
+ function buildLegendItems(): LegendItem[] {
102
+ if (!chartInstance.value) return []
103
+
104
+ const chart = chartInstance.value
105
+ const datasets = chart.data.datasets
106
+
107
+ return datasets.map((dataset, index) => ({
108
+ label: dataset.label || `Dataset ${index + 1}`,
109
+ color: Array.isArray(dataset.backgroundColor)
110
+ ? dataset.backgroundColor[0]
111
+ : (dataset.backgroundColor as string) || (dataset.borderColor as string) || theme.value.palette[index],
112
+ hidden: !chart.isDatasetVisible(index),
113
+ datasetIndex: index
114
+ }))
115
+ }
116
+
117
+ function toggleDataset(index: number) {
118
+ if (!chartInstance.value) return
119
+
120
+ const chart = chartInstance.value
121
+ const isVisible = chart.isDatasetVisible(index)
122
+ chart.setDatasetVisibility(index, !isVisible)
123
+ chart.update()
124
+ legendItems.value = buildLegendItems()
125
+
126
+ emit('legendClick', legendItems.value[index], index)
127
+ }
128
+
129
+ function externalTooltipHandler(context: { chart: Chart; tooltip: TooltipModel<ChartType> }) {
130
+ const { chart, tooltip } = context
131
+
132
+ // Hide tooltip when not active
133
+ if (tooltip.opacity === 0 || !tooltip.dataPoints?.length) {
134
+ tooltipData.value = null
135
+ return
136
+ }
137
+
138
+ const position = chart.canvas.getBoundingClientRect()
139
+ const items = tooltip.dataPoints.map((point) => ({
140
+ label: point.dataset.label || '',
141
+ value: String(point.formattedValue),
142
+ color: (point.dataset.borderColor as string) ||
143
+ (Array.isArray(point.dataset.backgroundColor)
144
+ ? point.dataset.backgroundColor[point.dataIndex]
145
+ : point.dataset.backgroundColor as string) || ''
146
+ }))
147
+
148
+ tooltipData.value = {
149
+ title: tooltip.title?.[0] || '',
150
+ items,
151
+ x: position.left + tooltip.caretX,
152
+ y: position.top + tooltip.caretY
153
+ }
154
+ }
155
+
156
+ function handleChartClick(event: MouseEvent) {
157
+ if (!chartInstance.value || !canvasRef.value) return
158
+
159
+ const chart = chartInstance.value
160
+ const elements = chart.getElementsAtEventForMode(
161
+ event,
162
+ 'nearest',
163
+ { intersect: true },
164
+ false
165
+ )
166
+
167
+ if (elements.length > 0) {
168
+ const element = elements[0]
169
+ const datasetIndex = element.datasetIndex
170
+ const index = element.index
171
+ const dataset = chart.data.datasets[datasetIndex]
172
+ const labels = chart.data.labels || []
173
+ const value = Array.isArray(dataset.data[index])
174
+ ? dataset.data[index]
175
+ : dataset.data[index]
176
+
177
+ emit('pointClick', {
178
+ datasetIndex,
179
+ index,
180
+ label: String(labels[index] || ''),
181
+ value: value as number | string,
182
+ datasetLabel: dataset.label || ''
183
+ })
184
+ }
185
+ }
186
+
187
+ function mergeOptions(): ChartOptions {
188
+ const defaults = getDefaultOptions() as Record<string, unknown>
189
+ const userOptions = (props.options || {}) as Record<string, unknown>
190
+
191
+ const defaultPlugins = (defaults.plugins || {}) as Record<string, unknown>
192
+ const userPlugins = (userOptions.plugins || {}) as Record<string, unknown>
193
+
194
+ const merged: Record<string, unknown> = {
195
+ ...defaults,
196
+ ...userOptions,
197
+ plugins: {
198
+ ...defaultPlugins,
199
+ ...userPlugins,
200
+ tooltip: {
201
+ enabled: false,
202
+ external: externalTooltipHandler,
203
+ mode: 'index',
204
+ intersect: false,
205
+ animation: false,
206
+ ...(userPlugins.tooltip as Record<string, unknown> || {})
207
+ }
208
+ },
209
+ interaction: {
210
+ mode: 'index',
211
+ intersect: false,
212
+ ...(userOptions.interaction as Record<string, unknown> || {})
213
+ }
214
+ }
215
+
216
+ if (props.type !== 'doughnut' && props.type !== 'pie') {
217
+ merged.scales = {
218
+ ...(defaults.scales as Record<string, unknown> || {}),
219
+ ...(userOptions.scales as Record<string, unknown> || {})
220
+ }
221
+ }
222
+
223
+ return merged as ChartOptions
224
+ }
225
+
226
+ function createChart() {
227
+ if (!canvasRef.value) return
228
+
229
+ if (chartInstance.value) {
230
+ chartInstance.value.destroy()
231
+ }
232
+
233
+ chartInstance.value = new Chart(canvasRef.value, {
234
+ type: props.type,
235
+ data: props.data as ChartData,
236
+ options: mergeOptions() as ChartOptions
237
+ })
238
+
239
+ legendItems.value = buildLegendItems()
240
+ }
241
+
242
+ function updateChart() {
243
+ if (!chartInstance.value) return
244
+
245
+ chartInstance.value.data = props.data as ChartData
246
+ chartInstance.value.options = mergeOptions() as ChartOptions
247
+ chartInstance.value.update()
248
+ legendItems.value = buildLegendItems()
249
+ }
250
+
251
+ let resizeObserver: ResizeObserver | null = null
252
+
253
+ onMounted(() => {
254
+ createChart()
255
+
256
+ if (containerRef.value) {
257
+ resizeObserver = new ResizeObserver(() => {
258
+ chartInstance.value?.resize()
259
+ })
260
+ resizeObserver.observe(containerRef.value)
261
+ }
262
+ })
263
+
264
+ onUnmounted(() => {
265
+ resizeObserver?.disconnect()
266
+ chartInstance.value?.destroy()
267
+ chartInstance.value = null
268
+ })
269
+
270
+ watch(() => props.data, updateChart, { deep: true })
271
+ watch(() => props.type, createChart)
272
+ watch(themeVersion, () => {
273
+ nextTick(updateChart)
274
+ })
275
+
276
+ defineExpose({
277
+ chart: chartInstance,
278
+ toggleDataset,
279
+ legendItems
280
+ })
281
+ </script>
282
+
283
+ <template>
284
+ <div class="ui-chart" ref="containerRef">
285
+ <!-- Custom Legend Slot -->
286
+ <slot name="legend" :items="legendItems" :toggle="toggleDataset" />
287
+
288
+ <!-- Chart Container -->
289
+ <div class="ui-chart__canvas-container" :style="{ height: heightStyle }">
290
+ <canvas ref="canvasRef" :aria-label="ariaLabel" role="img" @click="handleChartClick">
291
+ <!-- Accessibility Fallback Table -->
292
+ <slot name="fallback">
293
+ <table class="ui-chart__sr-table">
294
+ <caption>{{ ariaLabel || 'Chart data' }}</caption>
295
+ <thead>
296
+ <tr>
297
+ <th scope="col">Label</th>
298
+ <th
299
+ v-for="(dataset, index) in (data as ChartData).datasets || []"
300
+ :key="index"
301
+ scope="col"
302
+ >
303
+ {{ (dataset as ChartDataset).label || `Series ${index + 1}` }}
304
+ </th>
305
+ </tr>
306
+ </thead>
307
+ <tbody>
308
+ <tr
309
+ v-for="(label, labelIndex) in (data as ChartData).labels || []"
310
+ :key="labelIndex"
311
+ >
312
+ <th scope="row">{{ label }}</th>
313
+ <td
314
+ v-for="(dataset, datasetIndex) in (data as ChartData).datasets || []"
315
+ :key="datasetIndex"
316
+ >
317
+ {{ (dataset as ChartDataset).data?.[labelIndex] }}
318
+ </td>
319
+ </tr>
320
+ </tbody>
321
+ </table>
322
+ </slot>
323
+ </canvas>
324
+ </div>
325
+
326
+ <!-- Custom Tooltip Slot -->
327
+ <Teleport to="body">
328
+ <slot name="tooltip" :data="tooltipData">
329
+ <div
330
+ v-if="tooltipData"
331
+ class="ui-chart__tooltip"
332
+ :style="{
333
+ left: `${tooltipData.x}px`,
334
+ top: `${tooltipData.y}px`
335
+ }"
336
+ >
337
+ <div v-if="tooltipData.title" class="ui-chart__tooltip-title">
338
+ {{ tooltipData.title }}
339
+ </div>
340
+ <div
341
+ v-for="(item, index) in tooltipData.items"
342
+ :key="index"
343
+ class="ui-chart__tooltip-item"
344
+ >
345
+ <span
346
+ class="ui-chart__tooltip-color"
347
+ :style="{ backgroundColor: item.color }"
348
+ />
349
+ <span class="ui-chart__tooltip-label">{{ item.label }}</span>
350
+ <span class="ui-chart__tooltip-value">{{ item.value }}</span>
351
+ </div>
352
+ </div>
353
+ </slot>
354
+ </Teleport>
355
+ </div>
356
+ </template>
357
+
358
+ <style scoped>
359
+ .ui-chart {
360
+ width: 100%;
361
+ font-family: var(--font-sans);
362
+ }
363
+
364
+ .ui-chart__canvas-container {
365
+ position: relative;
366
+ width: 100%;
367
+ }
368
+
369
+ .ui-chart__canvas-container canvas {
370
+ width: 100% !important;
371
+ height: 100% !important;
372
+ }
373
+
374
+ /* Screen reader only table */
375
+ .ui-chart__sr-table {
376
+ position: absolute;
377
+ width: 1px;
378
+ height: 1px;
379
+ padding: 0;
380
+ margin: -1px;
381
+ overflow: hidden;
382
+ clip: rect(0, 0, 0, 0);
383
+ white-space: nowrap;
384
+ border: 0;
385
+ }
386
+ </style>
387
+
388
+ <!-- Tooltip styles must be unscoped because content is teleported to body -->
389
+ <style>
390
+ .ui-chart__tooltip {
391
+ position: fixed;
392
+ z-index: var(--z-tooltip, 100);
393
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
394
+ background-color: var(--chart-tooltip-bg, var(--tooltip-bg, #1c1917));
395
+ color: var(--chart-tooltip-text, var(--tooltip-text, #fafaf9));
396
+ border-radius: var(--radius-md, 0.375rem);
397
+ font-size: var(--text-sm, 0.875rem);
398
+ font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
399
+ pointer-events: none;
400
+ transform: translate(-50%, calc(-100% - 8px));
401
+ box-shadow: var(--shadow-lg, 0 10px 15px -3px rgb(0 0 0 / 0.1));
402
+ min-width: 120px;
403
+ }
404
+
405
+ .ui-chart__tooltip-title {
406
+ font-weight: var(--font-medium, 500);
407
+ margin-bottom: var(--space-1, 0.25rem);
408
+ padding-bottom: var(--space-1, 0.25rem);
409
+ border-bottom: 1px solid var(--chart-tooltip-border, rgba(255, 255, 255, 0.1));
410
+ }
411
+
412
+ .ui-chart__tooltip-item {
413
+ display: flex;
414
+ align-items: center;
415
+ gap: var(--space-2, 0.5rem);
416
+ padding: var(--space-1, 0.25rem) 0;
417
+ }
418
+
419
+ .ui-chart__tooltip-color {
420
+ width: 10px;
421
+ height: 10px;
422
+ border-radius: var(--radius-sm, 0.25rem);
423
+ flex-shrink: 0;
424
+ }
425
+
426
+ .ui-chart__tooltip-label {
427
+ flex: 1;
428
+ opacity: 0.8;
429
+ }
430
+
431
+ .ui-chart__tooltip-value {
432
+ font-weight: var(--font-medium, 500);
433
+ font-variant-numeric: tabular-nums;
434
+ }
435
+
436
+ [data-theme='dark'] .ui-chart__tooltip {
437
+ background-color: var(--chart-tooltip-bg, #fafaf9);
438
+ color: var(--chart-tooltip-text, #1c1917);
439
+ }
440
+
441
+ [data-theme='dark'] .ui-chart__tooltip-title {
442
+ border-bottom-color: var(--chart-tooltip-border, rgba(0, 0, 0, 0.1));
443
+ }
444
+ </style>