@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,125 @@
1
+ <script setup lang="ts">
2
+ import { inject, useSlots } from 'vue'
3
+ import { AccordionItemKey } from './keys'
4
+
5
+ export interface AccordionTriggerProps {
6
+ /** Hide the default chevron indicator */
7
+ hideIndicator?: boolean
8
+ }
9
+
10
+ const props = withDefaults(defineProps<AccordionTriggerProps>(), {
11
+ hideIndicator: false
12
+ })
13
+
14
+ const slots = useSlots()
15
+
16
+ const item = inject(AccordionItemKey)
17
+
18
+ if (!item) {
19
+ throw new Error('AccordionTrigger must be used within AccordionItem')
20
+ }
21
+
22
+ const hasIconSlot = !!slots.icon
23
+ </script>
24
+
25
+ <template>
26
+ <h3 class="ui-accordion__heading">
27
+ <button
28
+ type="button"
29
+ class="ui-accordion__trigger"
30
+ :id="item.triggerId"
31
+ :aria-expanded="item.isOpen.value"
32
+ :aria-controls="item.contentId"
33
+ :disabled="item.disabled.value"
34
+ @click="item.toggle"
35
+ >
36
+ <span class="ui-accordion__trigger-text">
37
+ <slot />
38
+ </span>
39
+
40
+ <!-- Custom icon slot (for plus/minus, etc.) -->
41
+ <span v-if="hasIconSlot" class="ui-accordion__indicator">
42
+ <slot name="icon" :is-open="item.isOpen.value" />
43
+ </span>
44
+
45
+ <!-- Default chevron -->
46
+ <svg
47
+ v-else-if="!hideIndicator"
48
+ class="ui-accordion__chevron"
49
+ :class="{ 'ui-accordion__chevron--open': item.isOpen.value }"
50
+ width="16"
51
+ height="16"
52
+ viewBox="0 0 24 24"
53
+ fill="none"
54
+ stroke="currentColor"
55
+ stroke-width="2"
56
+ stroke-linecap="round"
57
+ stroke-linejoin="round"
58
+ aria-hidden="true"
59
+ >
60
+ <path d="M6 9l6 6 6-6" />
61
+ </svg>
62
+ </button>
63
+ </h3>
64
+ </template>
65
+
66
+ <style scoped>
67
+ .ui-accordion__heading {
68
+ margin: 0;
69
+ font-size: inherit;
70
+ }
71
+
72
+ .ui-accordion__trigger {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ width: 100%;
77
+ padding: var(--space-4) 0;
78
+ background: transparent;
79
+ border: none;
80
+ cursor: pointer;
81
+ font-family: var(--font-sans);
82
+ font-size: var(--text-base);
83
+ font-weight: var(--font-medium);
84
+ color: var(--accordion-trigger, var(--text-primary));
85
+ text-align: left;
86
+ transition: color var(--duration-fast) var(--ease-default);
87
+ }
88
+
89
+ .ui-accordion__trigger:hover {
90
+ color: var(--accordion-trigger-hover, var(--text-primary));
91
+ }
92
+
93
+ .ui-accordion__trigger:focus-visible {
94
+ outline: 2px solid var(--focus-ring);
95
+ outline-offset: 2px;
96
+ border-radius: var(--radius-sm);
97
+ }
98
+
99
+ .ui-accordion__trigger:disabled {
100
+ cursor: not-allowed;
101
+ opacity: 0.5;
102
+ }
103
+
104
+ .ui-accordion__trigger-text {
105
+ flex: 1;
106
+ }
107
+
108
+ .ui-accordion__indicator {
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ flex-shrink: 0;
113
+ color: var(--accordion-chevron, var(--text-secondary));
114
+ }
115
+
116
+ .ui-accordion__chevron {
117
+ flex-shrink: 0;
118
+ color: var(--accordion-chevron, var(--text-secondary));
119
+ transition: transform var(--duration-normal) cubic-bezier(0.16, 1, 0.3, 1);
120
+ }
121
+
122
+ .ui-accordion__chevron--open {
123
+ transform: rotate(180deg);
124
+ }
125
+ </style>
@@ -0,0 +1,11 @@
1
+ export { default as AccordionRoot } from './AccordionRoot.vue'
2
+ export { default as AccordionItem } from './AccordionItem.vue'
3
+ export { default as AccordionTrigger } from './AccordionTrigger.vue'
4
+ export { default as AccordionContent } from './AccordionContent.vue'
5
+
6
+ export type { AccordionRootProps } from './AccordionRoot.vue'
7
+ export type { AccordionItemProps } from './AccordionItem.vue'
8
+ export type { AccordionTriggerProps } from './AccordionTrigger.vue'
9
+ export type { AccordionContentProps } from './AccordionContent.vue'
10
+ export type { AccordionContext, AccordionItemContext, AccordionVariant } from './keys'
11
+ export { AccordionKey, AccordionItemKey } from './keys'
@@ -0,0 +1,23 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+
3
+ export type AccordionVariant = 'contained' | 'split'
4
+
5
+ export interface AccordionContext {
6
+ openItems: Ref<Set<string>>
7
+ toggle: (value: string) => void
8
+ collapsible: Ref<boolean>
9
+ multiple: Ref<boolean>
10
+ variant: Ref<AccordionVariant>
11
+ }
12
+
13
+ export interface AccordionItemContext {
14
+ value: string
15
+ triggerId: string
16
+ contentId: string
17
+ isOpen: Ref<boolean>
18
+ disabled: Ref<boolean>
19
+ toggle: () => void
20
+ }
21
+
22
+ export const AccordionKey: InjectionKey<AccordionContext> = Symbol('accordion')
23
+ export const AccordionItemKey: InjectionKey<AccordionItemContext> = Symbol('accordion-item')
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Avatar from './Avatar.vue'
4
+
5
+ describe('Avatar', () => {
6
+ describe('Fallback hierarchy', () => {
7
+ it('renders image when src is provided', () => {
8
+ const wrapper = mount(Avatar, {
9
+ props: { src: 'https://example.com/avatar.jpg', name: 'John Doe' }
10
+ })
11
+ expect(wrapper.find('img').exists()).toBe(true)
12
+ expect(wrapper.find('.ui-avatar__text').exists()).toBe(false)
13
+ })
14
+
15
+ it('renders initials when no src provided', () => {
16
+ const wrapper = mount(Avatar, {
17
+ props: { name: 'John Doe' }
18
+ })
19
+ expect(wrapper.find('img').exists()).toBe(false)
20
+ expect(wrapper.find('.ui-avatar__text').text()).toBe('JD')
21
+ })
22
+
23
+ it('renders fallback icon when no src or name', () => {
24
+ const wrapper = mount(Avatar)
25
+ expect(wrapper.find('img').exists()).toBe(false)
26
+ expect(wrapper.find('.ui-avatar__text').exists()).toBe(false)
27
+ expect(wrapper.find('.ui-avatar__icon').exists()).toBe(true)
28
+ })
29
+
30
+ it('falls back to initials on image error', async () => {
31
+ const wrapper = mount(Avatar, {
32
+ props: { src: 'https://broken.url/image.jpg', name: 'John Doe' }
33
+ })
34
+
35
+ // Initially shows image
36
+ expect(wrapper.find('img').exists()).toBe(true)
37
+
38
+ // Simulate error
39
+ await wrapper.find('img').trigger('error')
40
+
41
+ // Now shows initials
42
+ expect(wrapper.find('img').exists()).toBe(false)
43
+ expect(wrapper.find('.ui-avatar__text').text()).toBe('JD')
44
+ })
45
+
46
+ it('falls back to icon on image error when no name', async () => {
47
+ const wrapper = mount(Avatar, {
48
+ props: { src: 'https://broken.url/image.jpg' }
49
+ })
50
+
51
+ await wrapper.find('img').trigger('error')
52
+
53
+ expect(wrapper.find('.ui-avatar__icon').exists()).toBe(true)
54
+ })
55
+ })
56
+
57
+ describe('Sizes', () => {
58
+ const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
59
+
60
+ sizes.forEach(size => {
61
+ it(`applies ${size} size class`, () => {
62
+ const wrapper = mount(Avatar, {
63
+ props: { size, name: 'Test' }
64
+ })
65
+ expect(wrapper.classes()).toContain(`ui-avatar--${size}`)
66
+ })
67
+ })
68
+
69
+ it('defaults to md size', () => {
70
+ const wrapper = mount(Avatar, {
71
+ props: { name: 'Test' }
72
+ })
73
+ expect(wrapper.classes()).toContain('ui-avatar--md')
74
+ })
75
+ })
76
+
77
+ describe('Shapes', () => {
78
+ it('applies circle shape by default', () => {
79
+ const wrapper = mount(Avatar, {
80
+ props: { name: 'Test' }
81
+ })
82
+ expect(wrapper.classes()).toContain('ui-avatar--circle')
83
+ })
84
+
85
+ it('applies square shape when specified', () => {
86
+ const wrapper = mount(Avatar, {
87
+ props: { shape: 'square', name: 'Test' }
88
+ })
89
+ expect(wrapper.classes()).toContain('ui-avatar--square')
90
+ })
91
+ })
92
+
93
+ describe('Variants', () => {
94
+ const variants = ['neutral', 'primary', 'soft'] as const
95
+
96
+ variants.forEach(variant => {
97
+ it(`applies ${variant} variant class`, () => {
98
+ const wrapper = mount(Avatar, {
99
+ props: { variant, name: 'Test' }
100
+ })
101
+ expect(wrapper.classes()).toContain(`ui-avatar--${variant}`)
102
+ })
103
+ })
104
+
105
+ it('defaults to soft variant', () => {
106
+ const wrapper = mount(Avatar, {
107
+ props: { name: 'Test' }
108
+ })
109
+ expect(wrapper.classes()).toContain('ui-avatar--soft')
110
+ })
111
+ })
112
+
113
+ describe('Bordered', () => {
114
+ it('applies bordered class when bordered prop is true', () => {
115
+ const wrapper = mount(Avatar, {
116
+ props: { bordered: true, name: 'Test' }
117
+ })
118
+ expect(wrapper.classes()).toContain('ui-avatar--bordered')
119
+ })
120
+
121
+ it('does not apply bordered class by default', () => {
122
+ const wrapper = mount(Avatar, {
123
+ props: { name: 'Test' }
124
+ })
125
+ expect(wrapper.classes()).not.toContain('ui-avatar--bordered')
126
+ })
127
+ })
128
+
129
+ describe('Accessibility', () => {
130
+ it('has role="img"', () => {
131
+ const wrapper = mount(Avatar, {
132
+ props: { name: 'John Doe' }
133
+ })
134
+ expect(wrapper.attributes('role')).toBe('img')
135
+ })
136
+
137
+ it('uses name for aria-label', () => {
138
+ const wrapper = mount(Avatar, {
139
+ props: { name: 'John Doe' }
140
+ })
141
+ expect(wrapper.attributes('aria-label')).toBe('John Doe')
142
+ })
143
+
144
+ it('uses alt for aria-label when provided', () => {
145
+ const wrapper = mount(Avatar, {
146
+ props: { name: 'John Doe', alt: 'Profile picture' }
147
+ })
148
+ expect(wrapper.attributes('aria-label')).toBe('Profile picture')
149
+ })
150
+
151
+ it('falls back to "Avatar" when no name or alt', () => {
152
+ const wrapper = mount(Avatar)
153
+ expect(wrapper.attributes('aria-label')).toBe('Avatar')
154
+ })
155
+
156
+ it('sets alt attribute on image', () => {
157
+ const wrapper = mount(Avatar, {
158
+ props: { src: 'https://example.com/avatar.jpg', name: 'John Doe' }
159
+ })
160
+ expect(wrapper.find('img').attributes('alt')).toBe('John Doe')
161
+ })
162
+ })
163
+
164
+ describe('Image error recovery', () => {
165
+ it('resets error state when src changes', async () => {
166
+ const wrapper = mount(Avatar, {
167
+ props: { src: 'https://broken.url/image.jpg', name: 'John Doe' }
168
+ })
169
+
170
+ // Trigger error
171
+ await wrapper.find('img').trigger('error')
172
+ expect(wrapper.find('.ui-avatar__text').exists()).toBe(true)
173
+
174
+ // Change src
175
+ await wrapper.setProps({ src: 'https://new.url/image.jpg' })
176
+
177
+ // Should show image again (error reset)
178
+ expect(wrapper.find('img').exists()).toBe(true)
179
+ })
180
+ })
181
+ })
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { getInitials } from '../../utils/string'
4
+
5
+ type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
6
+ type AvatarShape = 'circle' | 'square'
7
+ type AvatarVariant = 'neutral' | 'primary' | 'soft'
8
+
9
+ export interface AvatarProps {
10
+ /** Image source URL */
11
+ src?: string
12
+ /** User name - used for alt text AND initials fallback */
13
+ name?: string
14
+ /** Override alt text (defaults to name) */
15
+ alt?: string
16
+ /** Avatar size */
17
+ size?: AvatarSize
18
+ /** Avatar shape */
19
+ shape?: AvatarShape
20
+ /** Background variant when showing initials/icon */
21
+ variant?: AvatarVariant
22
+ /** Add border for avatar groups (cutout effect) */
23
+ bordered?: boolean
24
+ }
25
+
26
+ const props = withDefaults(defineProps<AvatarProps>(), {
27
+ size: 'md',
28
+ shape: 'circle',
29
+ variant: 'soft',
30
+ bordered: false
31
+ })
32
+
33
+ const hasImageError = ref(false)
34
+
35
+ watch(() => props.src, () => {
36
+ hasImageError.value = false
37
+ })
38
+
39
+ function handleError() {
40
+ hasImageError.value = true
41
+ }
42
+
43
+ const initials = computed(() => getInitials(props.name))
44
+
45
+ const showImage = computed(() => props.src && !hasImageError.value)
46
+
47
+ const showInitials = computed(() => !showImage.value && initials.value)
48
+
49
+ const showFallback = computed(() => !showImage.value && !initials.value)
50
+
51
+ const classes = computed(() => [
52
+ 'ui-avatar',
53
+ `ui-avatar--${props.size}`,
54
+ `ui-avatar--${props.shape}`,
55
+ `ui-avatar--${props.variant}`,
56
+ { 'ui-avatar--bordered': props.bordered }
57
+ ])
58
+
59
+ const ariaLabel = computed(() => props.alt || props.name || 'Avatar')
60
+ </script>
61
+
62
+ <template>
63
+ <div :class="classes" role="img" :aria-label="ariaLabel">
64
+ <img
65
+ v-if="showImage"
66
+ :src="src"
67
+ :alt="alt || name"
68
+ class="ui-avatar__img"
69
+ @error="handleError"
70
+ />
71
+
72
+ <span v-else-if="showInitials" class="ui-avatar__text">
73
+ {{ initials }}
74
+ </span>
75
+
76
+ <svg
77
+ v-else
78
+ class="ui-avatar__icon"
79
+ viewBox="0 0 24 24"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ stroke-width="1.5"
83
+ stroke-linecap="round"
84
+ stroke-linejoin="round"
85
+ aria-hidden="true"
86
+ >
87
+ <circle cx="12" cy="8" r="4" />
88
+ <path d="M4 20c0-4 4-6 8-6s8 2 8 6" />
89
+ </svg>
90
+ </div>
91
+ </template>
92
+
93
+ <style scoped>
94
+ .ui-avatar {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ overflow: hidden;
99
+ user-select: none;
100
+ flex-shrink: 0;
101
+ vertical-align: middle;
102
+ font-family: var(--font-sans);
103
+ font-weight: 600;
104
+ line-height: 1;
105
+ }
106
+
107
+ .ui-avatar--circle { border-radius: 50%; }
108
+ .ui-avatar--square { border-radius: var(--radius-md); }
109
+
110
+ .ui-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.625rem; }
111
+ .ui-avatar--sm { width: 2rem; height: 2rem; font-size: 0.75rem; }
112
+ .ui-avatar--md { width: 2.5rem; height: 2.5rem; font-size: 0.875rem; }
113
+ .ui-avatar--lg { width: 3rem; height: 3rem; font-size: 1rem; }
114
+ .ui-avatar--xl { width: 4rem; height: 4rem; font-size: 1.25rem; }
115
+
116
+ .ui-avatar--neutral {
117
+ background-color: var(--bg-tertiary);
118
+ color: var(--text-secondary);
119
+ }
120
+
121
+ .ui-avatar--primary {
122
+ background-color: var(--action-primary);
123
+ color: var(--action-primary-text);
124
+ }
125
+
126
+ .ui-avatar--soft {
127
+ background-color: var(--avatar-soft-bg);
128
+ color: var(--avatar-soft-text);
129
+ }
130
+
131
+ .ui-avatar--bordered {
132
+ box-shadow: 0 0 0 2px var(--bg-primary);
133
+ }
134
+
135
+ .ui-avatar__img {
136
+ width: 100%;
137
+ height: 100%;
138
+ object-fit: cover;
139
+ }
140
+
141
+ .ui-avatar__text {
142
+ text-transform: uppercase;
143
+ }
144
+
145
+ .ui-avatar__icon {
146
+ width: 60%;
147
+ height: 60%;
148
+ opacity: 0.6;
149
+ }
150
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Avatar } from './Avatar.vue'
2
+ export type { AvatarProps } from './Avatar.vue'
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Badge from './Badge.vue'
4
+
5
+ describe('Badge', () => {
6
+ describe('Rendering', () => {
7
+ it('renders slot content', () => {
8
+ const wrapper = mount(Badge, {
9
+ slots: { default: 'New' }
10
+ })
11
+ expect(wrapper.text()).toBe('New')
12
+ })
13
+
14
+ it('renders numeric value', () => {
15
+ const wrapper = mount(Badge, {
16
+ props: { value: 5 }
17
+ })
18
+ expect(wrapper.text()).toBe('5')
19
+ })
20
+
21
+ it('has role="status" for accessibility', () => {
22
+ const wrapper = mount(Badge, {
23
+ slots: { default: 'Active' }
24
+ })
25
+ expect(wrapper.attributes('role')).toBe('status')
26
+ })
27
+ })
28
+
29
+ describe('Variants', () => {
30
+ const variants = ['default', 'success', 'warning', 'danger', 'info'] as const
31
+
32
+ variants.forEach(variant => {
33
+ it(`applies ${variant} variant class`, () => {
34
+ const wrapper = mount(Badge, {
35
+ props: { variant },
36
+ slots: { default: 'Test' }
37
+ })
38
+ expect(wrapper.classes()).toContain(`ui-badge--${variant}`)
39
+ })
40
+ })
41
+
42
+ it('defaults to default variant', () => {
43
+ const wrapper = mount(Badge, {
44
+ slots: { default: 'Test' }
45
+ })
46
+ expect(wrapper.classes()).toContain('ui-badge--default')
47
+ })
48
+ })
49
+
50
+ describe('Dot mode', () => {
51
+ it('renders as dot when dot prop is true', () => {
52
+ const wrapper = mount(Badge, {
53
+ props: { dot: true }
54
+ })
55
+ expect(wrapper.classes()).toContain('ui-badge--dot')
56
+ expect(wrapper.text()).toBe('')
57
+ })
58
+
59
+ it('renders as dot when no content provided', () => {
60
+ const wrapper = mount(Badge)
61
+ expect(wrapper.classes()).toContain('ui-badge--dot')
62
+ })
63
+
64
+ it('applies pulse animation when pulse prop is true', () => {
65
+ const wrapper = mount(Badge, {
66
+ props: { dot: true, pulse: true }
67
+ })
68
+ expect(wrapper.classes()).toContain('ui-badge--pulse')
69
+ })
70
+
71
+ it('does not apply pulse to non-dot badges', () => {
72
+ const wrapper = mount(Badge, {
73
+ props: { pulse: true },
74
+ slots: { default: 'Text' }
75
+ })
76
+ expect(wrapper.classes()).not.toContain('ui-badge--pulse')
77
+ })
78
+ })
79
+
80
+ describe('Numeric constraints', () => {
81
+ it('displays value directly when under max', () => {
82
+ const wrapper = mount(Badge, {
83
+ props: { value: 50 }
84
+ })
85
+ expect(wrapper.text()).toBe('50')
86
+ })
87
+
88
+ it('displays max+ when value exceeds default max (99)', () => {
89
+ const wrapper = mount(Badge, {
90
+ props: { value: 150 }
91
+ })
92
+ expect(wrapper.text()).toBe('99+')
93
+ })
94
+
95
+ it('respects custom max prop', () => {
96
+ const wrapper = mount(Badge, {
97
+ props: { value: 15, max: 10 }
98
+ })
99
+ expect(wrapper.text()).toBe('10+')
100
+ })
101
+
102
+ it('displays exact value at max boundary', () => {
103
+ const wrapper = mount(Badge, {
104
+ props: { value: 99 }
105
+ })
106
+ expect(wrapper.text()).toBe('99')
107
+ })
108
+ })
109
+
110
+ describe('Accessibility', () => {
111
+ it('uses label prop for aria-label', () => {
112
+ const wrapper = mount(Badge, {
113
+ props: { value: 150, label: '150 unread messages' }
114
+ })
115
+ expect(wrapper.attributes('aria-label')).toBe('150 unread messages')
116
+ })
117
+
118
+ it('uses actual value for aria-label when no label provided', () => {
119
+ const wrapper = mount(Badge, {
120
+ props: { value: 150 }
121
+ })
122
+ expect(wrapper.attributes('aria-label')).toBe('150')
123
+ })
124
+
125
+ it('has no aria-label for text-only badges without label prop', () => {
126
+ const wrapper = mount(Badge, {
127
+ slots: { default: 'New' }
128
+ })
129
+ expect(wrapper.attributes('aria-label')).toBeUndefined()
130
+ })
131
+ })
132
+
133
+ describe('Typography', () => {
134
+ it('applies ui-badge base class', () => {
135
+ const wrapper = mount(Badge, {
136
+ slots: { default: 'Test' }
137
+ })
138
+ expect(wrapper.classes()).toContain('ui-badge')
139
+ })
140
+ })
141
+ })