@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,2 @@
1
+ export { default as FileUpload } from './FileUpload.vue'
2
+ export type { FileUploadProps, UploadFile } from './FileUpload.vue'
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Heading from './Heading.vue'
4
+
5
+ describe('Heading', () => {
6
+ describe('Rendering', () => {
7
+ it('renders as h2 by default', () => {
8
+ const wrapper = mount(Heading, {
9
+ slots: { default: 'Hello' }
10
+ })
11
+ expect(wrapper.element.tagName.toLowerCase()).toBe('h2')
12
+ })
13
+
14
+ it('renders slot content', () => {
15
+ const wrapper = mount(Heading, {
16
+ slots: { default: 'Page Title' }
17
+ })
18
+ expect(wrapper.text()).toBe('Page Title')
19
+ })
20
+
21
+ it('renders as custom tag via "as" prop', () => {
22
+ const wrapper = mount(Heading, {
23
+ props: { as: 'h1' },
24
+ slots: { default: 'Main Title' }
25
+ })
26
+ expect(wrapper.element.tagName.toLowerCase()).toBe('h1')
27
+ })
28
+
29
+ it('can render as non-heading elements', () => {
30
+ const wrapper = mount(Heading, {
31
+ props: { as: 'div' },
32
+ slots: { default: 'Visual heading' }
33
+ })
34
+ expect(wrapper.element.tagName.toLowerCase()).toBe('div')
35
+ })
36
+ })
37
+
38
+ describe('Sizes', () => {
39
+ const sizes = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md'] as const
40
+
41
+ sizes.forEach(size => {
42
+ it(`applies ${size} size class`, () => {
43
+ const wrapper = mount(Heading, {
44
+ props: { size },
45
+ slots: { default: 'Heading' }
46
+ })
47
+ expect(wrapper.classes()).toContain(`ui-heading--${size}`)
48
+ })
49
+ })
50
+
51
+ it('defaults to 2xl size', () => {
52
+ const wrapper = mount(Heading, {
53
+ slots: { default: 'Heading' }
54
+ })
55
+ expect(wrapper.classes()).toContain('ui-heading--2xl')
56
+ })
57
+ })
58
+
59
+ describe('Alignment', () => {
60
+ const alignments = ['left', 'center', 'right'] as const
61
+
62
+ alignments.forEach(align => {
63
+ it(`applies ${align} alignment class`, () => {
64
+ const wrapper = mount(Heading, {
65
+ props: { align },
66
+ slots: { default: 'Heading' }
67
+ })
68
+ expect(wrapper.classes()).toContain(`ui-heading--${align}`)
69
+ })
70
+ })
71
+
72
+ it('defaults to left alignment', () => {
73
+ const wrapper = mount(Heading, {
74
+ slots: { default: 'Heading' }
75
+ })
76
+ expect(wrapper.classes()).toContain('ui-heading--left')
77
+ })
78
+ })
79
+
80
+ describe('Truncation', () => {
81
+ it('applies truncate class when truncate prop is true', () => {
82
+ const wrapper = mount(Heading, {
83
+ props: { truncate: true },
84
+ slots: { default: 'Very long heading text' }
85
+ })
86
+ expect(wrapper.classes()).toContain('ui-heading--truncate')
87
+ })
88
+
89
+ it('does not apply truncate class by default', () => {
90
+ const wrapper = mount(Heading, {
91
+ slots: { default: 'Heading' }
92
+ })
93
+ expect(wrapper.classes()).not.toContain('ui-heading--truncate')
94
+ })
95
+ })
96
+
97
+ describe('Polymorphism', () => {
98
+ it('allows visual h1 with semantic h2', () => {
99
+ const wrapper = mount(Heading, {
100
+ props: { as: 'h2', size: '4xl' },
101
+ slots: { default: 'Looks big, semantically h2' }
102
+ })
103
+ expect(wrapper.element.tagName.toLowerCase()).toBe('h2')
104
+ expect(wrapper.classes()).toContain('ui-heading--4xl')
105
+ })
106
+ })
107
+ })
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' | 'span'
5
+ type HeadingSize = '4xl' | '3xl' | '2xl' | 'xl' | 'lg' | 'md'
6
+
7
+ export interface HeadingProps {
8
+ /** Semantic HTML tag */
9
+ as?: HeadingTag
10
+ /** Visual hierarchy size */
11
+ size?: HeadingSize
12
+ /** Text alignment */
13
+ align?: 'left' | 'center' | 'right'
14
+ /** Prevent wrapping with ellipsis */
15
+ truncate?: boolean
16
+ }
17
+
18
+ const props = withDefaults(defineProps<HeadingProps>(), {
19
+ as: 'h2',
20
+ size: '2xl',
21
+ align: 'left'
22
+ })
23
+
24
+ const classes = computed(() => [
25
+ 'ui-heading',
26
+ `ui-heading--${props.size}`,
27
+ `ui-heading--${props.align}`,
28
+ { 'ui-heading--truncate': props.truncate }
29
+ ])
30
+ </script>
31
+
32
+ <template>
33
+ <component :is="as" :class="classes">
34
+ <slot />
35
+ </component>
36
+ </template>
37
+
38
+ <style scoped>
39
+ .ui-heading {
40
+ font-family: var(--font-sans);
41
+ font-weight: 700;
42
+ line-height: 1.2;
43
+ letter-spacing: -0.02em;
44
+ color: var(--text-primary);
45
+ margin: 0;
46
+ }
47
+
48
+ /* Sizes */
49
+ .ui-heading--4xl { font-size: var(--heading-4xl); }
50
+ .ui-heading--3xl { font-size: var(--heading-3xl); }
51
+ .ui-heading--2xl { font-size: var(--heading-2xl); }
52
+ .ui-heading--xl { font-size: var(--heading-xl); }
53
+ .ui-heading--lg { font-size: var(--heading-lg); }
54
+ .ui-heading--md { font-size: var(--heading-md); }
55
+
56
+ /* Alignment */
57
+ .ui-heading--left { text-align: left; }
58
+ .ui-heading--center { text-align: center; }
59
+ .ui-heading--right { text-align: right; }
60
+
61
+ /* Truncation */
62
+ .ui-heading--truncate {
63
+ overflow: hidden;
64
+ text-overflow: ellipsis;
65
+ white-space: nowrap;
66
+ }
67
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Heading } from './Heading.vue'
2
+ export type { HeadingProps } from './Heading.vue'
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h, defineComponent, markRaw } from 'vue'
4
+ import Icon from './Icon.vue'
5
+
6
+ // Mock Vue component icon for testing
7
+ const MockVueIcon = markRaw(defineComponent({
8
+ name: 'MockVueIcon',
9
+ render() {
10
+ return h('svg', { 'data-testid': 'mock-svg' }, [
11
+ h('path', { d: 'M0 0h24v24H0z' })
12
+ ])
13
+ }
14
+ }))
15
+
16
+ // Mock HugeIcons data array format
17
+ const MockHugeIcon = [
18
+ ['path', { d: 'M12 2v20', stroke: 'currentColor', strokeWidth: '1.5', key: '0' }],
19
+ ['circle', { cx: '12', cy: '12', r: '4', stroke: 'currentColor', strokeWidth: '1.5', key: '1' }]
20
+ ] as [string, Record<string, unknown>][]
21
+
22
+ describe('Icon', () => {
23
+ describe('Vue Component Icons', () => {
24
+ it('renders the passed Vue component', () => {
25
+ const wrapper = mount(Icon, {
26
+ props: { icon: MockVueIcon }
27
+ })
28
+
29
+ expect(wrapper.find('[data-testid="mock-svg"]').exists()).toBe(true)
30
+ })
31
+
32
+ it('applies default size (md)', () => {
33
+ const wrapper = mount(Icon, {
34
+ props: { icon: MockVueIcon }
35
+ })
36
+
37
+ const style = wrapper.attributes('style')
38
+ expect(style).toContain('--icon-size: var(--icon-md)')
39
+ })
40
+
41
+ it('applies predefined sizes correctly', () => {
42
+ const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
43
+
44
+ for (const size of sizes) {
45
+ const wrapper = mount(Icon, {
46
+ props: { icon: MockVueIcon, size }
47
+ })
48
+
49
+ expect(wrapper.attributes('style')).toContain(`--icon-size: var(--icon-${size})`)
50
+ }
51
+ })
52
+
53
+ it('accepts custom size values', () => {
54
+ const wrapper = mount(Icon, {
55
+ props: { icon: MockVueIcon, size: '3rem' }
56
+ })
57
+
58
+ expect(wrapper.attributes('style')).toContain('--icon-size: 3rem')
59
+ })
60
+
61
+ it('has aria-hidden="true" when no label provided', () => {
62
+ const wrapper = mount(Icon, {
63
+ props: { icon: MockVueIcon }
64
+ })
65
+
66
+ expect(wrapper.attributes('aria-hidden')).toBe('true')
67
+ })
68
+
69
+ it('has accessible label when label prop provided', () => {
70
+ const wrapper = mount(Icon, {
71
+ props: { icon: MockVueIcon, label: 'Close dialog' }
72
+ })
73
+
74
+ expect(wrapper.attributes('aria-label')).toBe('Close dialog')
75
+ expect(wrapper.attributes('aria-hidden')).toBe('false')
76
+ })
77
+
78
+ it('has role="img" for semantic meaning', () => {
79
+ const wrapper = mount(Icon, {
80
+ props: { icon: MockVueIcon }
81
+ })
82
+
83
+ expect(wrapper.attributes('role')).toBe('img')
84
+ })
85
+
86
+ it('applies ui-icon class for styling', () => {
87
+ const wrapper = mount(Icon, {
88
+ props: { icon: MockVueIcon }
89
+ })
90
+
91
+ expect(wrapper.classes()).toContain('ui-icon')
92
+ })
93
+ })
94
+
95
+ describe('HugeIcons Data Array Format', () => {
96
+ it('renders HugeIcons data array as SVG', () => {
97
+ const wrapper = mount(Icon, {
98
+ props: { icon: MockHugeIcon }
99
+ })
100
+
101
+ expect(wrapper.element.tagName.toLowerCase()).toBe('svg')
102
+ expect(wrapper.find('path').exists()).toBe(true)
103
+ expect(wrapper.find('circle').exists()).toBe(true)
104
+ })
105
+
106
+ it('applies size to HugeIcons', () => {
107
+ const wrapper = mount(Icon, {
108
+ props: { icon: MockHugeIcon, size: 'lg' }
109
+ })
110
+
111
+ expect(wrapper.attributes('style')).toContain('--icon-size: var(--icon-lg)')
112
+ })
113
+
114
+ it('applies custom size to HugeIcons', () => {
115
+ const wrapper = mount(Icon, {
116
+ props: { icon: MockHugeIcon, size: '48px' }
117
+ })
118
+
119
+ expect(wrapper.attributes('style')).toContain('--icon-size: 48px')
120
+ })
121
+
122
+ it('has proper SVG attributes', () => {
123
+ const wrapper = mount(Icon, {
124
+ props: { icon: MockHugeIcon }
125
+ })
126
+
127
+ expect(wrapper.attributes('xmlns')).toBe('http://www.w3.org/2000/svg')
128
+ expect(wrapper.attributes('viewBox')).toBe('0 0 24 24')
129
+ expect(wrapper.attributes('fill')).toBe('none')
130
+ })
131
+
132
+ it('has aria-hidden for decorative HugeIcons', () => {
133
+ const wrapper = mount(Icon, {
134
+ props: { icon: MockHugeIcon }
135
+ })
136
+
137
+ expect(wrapper.attributes('aria-hidden')).toBe('true')
138
+ })
139
+
140
+ it('has accessible label when provided', () => {
141
+ const wrapper = mount(Icon, {
142
+ props: { icon: MockHugeIcon, label: 'Sun icon' }
143
+ })
144
+
145
+ expect(wrapper.attributes('aria-label')).toBe('Sun icon')
146
+ expect(wrapper.attributes('aria-hidden')).toBe('false')
147
+ })
148
+
149
+ it('applies ui-icon class', () => {
150
+ const wrapper = mount(Icon, {
151
+ props: { icon: MockHugeIcon }
152
+ })
153
+
154
+ expect(wrapper.classes()).toContain('ui-icon')
155
+ })
156
+ })
157
+ })
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ import { computed, h, type Component, type VNode } from 'vue'
3
+
4
+ type HugeIconData = [string, Record<string, unknown>][]
5
+
6
+ export type IconInput = Component | HugeIconData
7
+
8
+ export interface IconProps {
9
+ /** The icon - Vue component OR HugeIcons data array */
10
+ icon: IconInput
11
+ /** Predefined size or custom value */
12
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | string
13
+ /** Stroke width (for stroke-based icons like Lucide) */
14
+ strokeWidth?: number | string
15
+ /** Accessible label - required for standalone icons */
16
+ label?: string
17
+ }
18
+
19
+ const props = withDefaults(defineProps<IconProps>(), {
20
+ size: 'md',
21
+ strokeWidth: 1.5
22
+ })
23
+
24
+ const sizeMap: Record<string, string> = {
25
+ xs: 'var(--icon-xs)',
26
+ sm: 'var(--icon-sm)',
27
+ md: 'var(--icon-md)',
28
+ lg: 'var(--icon-lg)',
29
+ xl: 'var(--icon-xl)'
30
+ }
31
+
32
+ const resolvedSize = computed(() => sizeMap[props.size] ?? props.size)
33
+ const ariaHidden = computed(() => !props.label)
34
+
35
+ const isHugeIconData = computed(() => {
36
+ return Array.isArray(props.icon) &&
37
+ props.icon.length > 0 &&
38
+ Array.isArray(props.icon[0])
39
+ })
40
+
41
+ function renderHugeIcon(): VNode {
42
+ const data = props.icon as HugeIconData
43
+ const children = data.map(([tag, attrs]) => {
44
+ if (props.strokeWidth && attrs['stroke-width']) {
45
+ return h(tag, { ...attrs, 'stroke-width': props.strokeWidth })
46
+ }
47
+ return h(tag, attrs)
48
+ })
49
+
50
+ return h('svg', {
51
+ xmlns: 'http://www.w3.org/2000/svg',
52
+ viewBox: '0 0 24 24',
53
+ fill: 'none',
54
+ class: 'ui-icon',
55
+ style: { '--icon-size': resolvedSize.value },
56
+ 'aria-label': props.label,
57
+ 'aria-hidden': ariaHidden.value,
58
+ role: 'img'
59
+ }, children)
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <component
65
+ v-if="!isHugeIconData"
66
+ :is="icon"
67
+ class="ui-icon"
68
+ :style="{ '--icon-size': resolvedSize }"
69
+ :stroke-width="strokeWidth"
70
+ :aria-label="label"
71
+ :aria-hidden="ariaHidden"
72
+ role="img"
73
+ />
74
+ <component v-else :is="renderHugeIcon" />
75
+ </template>
76
+
77
+ <style scoped>
78
+ .ui-icon {
79
+ width: var(--icon-size);
80
+ height: var(--icon-size);
81
+ flex-shrink: 0;
82
+ color: currentColor;
83
+ display: inline-block;
84
+ vertical-align: middle;
85
+ }
86
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Icon } from './Icon.vue'
2
+ export type { IconProps, IconInput } from './Icon.vue'
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent, h } from 'vue'
4
+ import Input from './Input.vue'
5
+
6
+ // Mock icon component for testing
7
+ const MockIcon = defineComponent({
8
+ name: 'MockIcon',
9
+ render() {
10
+ return h('svg', { class: 'mock-icon' })
11
+ }
12
+ })
13
+
14
+ describe('Input', () => {
15
+ describe('Rendering', () => {
16
+ it('renders input element', () => {
17
+ const wrapper = mount(Input)
18
+ expect(wrapper.find('input').exists()).toBe(true)
19
+ })
20
+
21
+ it('renders with label', () => {
22
+ const wrapper = mount(Input, {
23
+ props: { label: 'Email' }
24
+ })
25
+ expect(wrapper.find('label').text()).toBe('Email')
26
+ })
27
+
28
+ it('renders with placeholder', () => {
29
+ const wrapper = mount(Input, {
30
+ props: { placeholder: 'Enter email' }
31
+ })
32
+ expect(wrapper.find('input').attributes('placeholder')).toBe('Enter email')
33
+ })
34
+
35
+ it('renders with hint text', () => {
36
+ const wrapper = mount(Input, {
37
+ props: { hint: 'We will never share your email' }
38
+ })
39
+ expect(wrapper.find('.ui-input-field__message--hint').text()).toBe('We will never share your email')
40
+ })
41
+
42
+ it('renders with error message', () => {
43
+ const wrapper = mount(Input, {
44
+ props: { error: 'Email is required' }
45
+ })
46
+ expect(wrapper.find('.ui-input-field__message--error').text()).toBe('Email is required')
47
+ })
48
+
49
+ it('error takes precedence over hint', () => {
50
+ const wrapper = mount(Input, {
51
+ props: { hint: 'Hint text', error: 'Error text' }
52
+ })
53
+ expect(wrapper.find('.ui-input-field__message--error').exists()).toBe(true)
54
+ expect(wrapper.find('.ui-input-field__message--hint').exists()).toBe(false)
55
+ })
56
+
57
+ it('renders required indicator', () => {
58
+ const wrapper = mount(Input, {
59
+ props: { label: 'Email', required: true }
60
+ })
61
+ expect(wrapper.find('.ui-input-field__required').exists()).toBe(true)
62
+ expect(wrapper.find('input').attributes('required')).toBeDefined()
63
+ })
64
+ })
65
+
66
+ describe('v-model', () => {
67
+ it('binds modelValue to input', () => {
68
+ const wrapper = mount(Input, {
69
+ props: { modelValue: 'test@example.com' }
70
+ })
71
+ expect(wrapper.find('input').element.value).toBe('test@example.com')
72
+ })
73
+
74
+ it('emits update:modelValue on input', async () => {
75
+ const wrapper = mount(Input)
76
+ await wrapper.find('input').setValue('hello')
77
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['hello'])
78
+ })
79
+
80
+ it('handles number type correctly', async () => {
81
+ const wrapper = mount(Input, {
82
+ props: { type: 'number' }
83
+ })
84
+ await wrapper.find('input').setValue('42')
85
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([42])
86
+ })
87
+ })
88
+
89
+ describe('Events', () => {
90
+ it('emits focus event', async () => {
91
+ const wrapper = mount(Input)
92
+ await wrapper.find('input').trigger('focus')
93
+ expect(wrapper.emitted('focus')).toBeTruthy()
94
+ })
95
+
96
+ it('emits blur event', async () => {
97
+ const wrapper = mount(Input)
98
+ await wrapper.find('input').trigger('blur')
99
+ expect(wrapper.emitted('blur')).toBeTruthy()
100
+ })
101
+ })
102
+
103
+ describe('Size variants', () => {
104
+ const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
105
+
106
+ sizes.forEach(size => {
107
+ it(`applies ${size} size class`, () => {
108
+ const wrapper = mount(Input, {
109
+ props: { size }
110
+ })
111
+ expect(wrapper.find('.ui-input-wrapper').classes()).toContain(`ui-input-wrapper--${size}`)
112
+ })
113
+ })
114
+ })
115
+
116
+ describe('States', () => {
117
+ it('applies disabled state', () => {
118
+ const wrapper = mount(Input, {
119
+ props: { disabled: true }
120
+ })
121
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
122
+ expect(wrapper.find('.ui-input-wrapper').classes()).toContain('ui-input-wrapper--disabled')
123
+ })
124
+
125
+ it('applies readonly state', () => {
126
+ const wrapper = mount(Input, {
127
+ props: { readonly: true }
128
+ })
129
+ expect(wrapper.find('input').attributes('readonly')).toBeDefined()
130
+ expect(wrapper.find('.ui-input-wrapper').classes()).toContain('ui-input-wrapper--readonly')
131
+ })
132
+
133
+ it('applies error state', () => {
134
+ const wrapper = mount(Input, {
135
+ props: { error: 'Invalid input' }
136
+ })
137
+ expect(wrapper.find('.ui-input-wrapper').classes()).toContain('ui-input-wrapper--error')
138
+ })
139
+
140
+ it('applies block state', () => {
141
+ const wrapper = mount(Input, {
142
+ props: { block: true }
143
+ })
144
+ expect(wrapper.find('.ui-input-field').classes()).toContain('ui-input-field--block')
145
+ })
146
+ })
147
+
148
+ describe('Input types', () => {
149
+ const types = ['text', 'email', 'password', 'search', 'tel', 'url', 'number'] as const
150
+
151
+ types.forEach(type => {
152
+ it(`renders ${type} input type`, () => {
153
+ const wrapper = mount(Input, {
154
+ props: { type }
155
+ })
156
+ expect(wrapper.find('input').attributes('type')).toBe(type)
157
+ })
158
+ })
159
+ })
160
+
161
+ describe('Icons and loading', () => {
162
+ it('renders left icon', () => {
163
+ const wrapper = mount(Input, {
164
+ props: { iconLeft: MockIcon }
165
+ })
166
+ expect(wrapper.find('.ui-input-wrapper__addon--left').exists()).toBe(true)
167
+ })
168
+
169
+ it('renders right icon', () => {
170
+ const wrapper = mount(Input, {
171
+ props: { iconRight: MockIcon }
172
+ })
173
+ expect(wrapper.find('.ui-input-wrapper__addon--right').exists()).toBe(true)
174
+ })
175
+
176
+ it('renders loading spinner', () => {
177
+ const wrapper = mount(Input, {
178
+ props: { loading: true }
179
+ })
180
+ expect(wrapper.find('.ui-input-wrapper__addon--right').exists()).toBe(true)
181
+ expect(wrapper.findComponent({ name: 'Spinner' }).exists()).toBe(true)
182
+ })
183
+
184
+ it('loading spinner takes precedence over right icon', () => {
185
+ const wrapper = mount(Input, {
186
+ props: { loading: true, iconRight: MockIcon }
187
+ })
188
+ expect(wrapper.findComponent({ name: 'Spinner' }).exists()).toBe(true)
189
+ })
190
+ })
191
+
192
+ describe('Slots', () => {
193
+ it('renders left slot', () => {
194
+ const wrapper = mount(Input, {
195
+ slots: { left: '<span class="custom-left">$</span>' }
196
+ })
197
+ expect(wrapper.find('.custom-left').text()).toBe('$')
198
+ })
199
+
200
+ it('renders right slot', () => {
201
+ const wrapper = mount(Input, {
202
+ slots: { right: '<span class="custom-right">%</span>' }
203
+ })
204
+ expect(wrapper.find('.custom-right').text()).toBe('%')
205
+ })
206
+ })
207
+
208
+ describe('Accessibility', () => {
209
+ it('auto-generates unique id', () => {
210
+ const wrapper = mount(Input)
211
+ const input = wrapper.find('input')
212
+ expect(input.attributes('id')).toMatch(/^input-/)
213
+ })
214
+
215
+ it('uses provided id', () => {
216
+ const wrapper = mount(Input, {
217
+ props: { id: 'custom-id' }
218
+ })
219
+ expect(wrapper.find('input').attributes('id')).toBe('custom-id')
220
+ })
221
+
222
+ it('links label to input via for attribute', () => {
223
+ const wrapper = mount(Input, {
224
+ props: { label: 'Email', id: 'email-input' }
225
+ })
226
+ expect(wrapper.find('label').attributes('for')).toBe('email-input')
227
+ })
228
+
229
+ it('sets aria-describedby to hint id', () => {
230
+ const wrapper = mount(Input, {
231
+ props: { hint: 'Helpful hint', id: 'my-input' }
232
+ })
233
+ expect(wrapper.find('input').attributes('aria-describedby')).toBe('my-input-hint')
234
+ })
235
+
236
+ it('sets aria-describedby to error id when error exists', () => {
237
+ const wrapper = mount(Input, {
238
+ props: { error: 'Error message', id: 'my-input' }
239
+ })
240
+ expect(wrapper.find('input').attributes('aria-describedby')).toBe('my-input-error')
241
+ })
242
+
243
+ it('sets aria-invalid when error exists', () => {
244
+ const wrapper = mount(Input, {
245
+ props: { error: 'Error message' }
246
+ })
247
+ expect(wrapper.find('input').attributes('aria-invalid')).toBe('true')
248
+ })
249
+
250
+ it('error message has role="alert"', () => {
251
+ const wrapper = mount(Input, {
252
+ props: { error: 'Error message' }
253
+ })
254
+ expect(wrapper.find('.ui-input-field__message--error').attributes('role')).toBe('alert')
255
+ })
256
+ })
257
+
258
+ describe('HTML attributes', () => {
259
+ it('passes name attribute', () => {
260
+ const wrapper = mount(Input, {
261
+ props: { name: 'email' }
262
+ })
263
+ expect(wrapper.find('input').attributes('name')).toBe('email')
264
+ })
265
+
266
+ it('passes autocomplete attribute', () => {
267
+ const wrapper = mount(Input, {
268
+ props: { autocomplete: 'email' }
269
+ })
270
+ expect(wrapper.find('input').attributes('autocomplete')).toBe('email')
271
+ })
272
+ })
273
+ })