@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/index.d.ts +4981 -0
- package/dist/spire-ui.css +1 -0
- package/dist/spire-ui.es.js +18403 -0
- package/dist/spire-ui.umd.js +45 -0
- package/package.json +83 -0
- package/src/components/Accordion/Accordion.test.ts +218 -0
- package/src/components/Accordion/AccordionContent.vue +112 -0
- package/src/components/Accordion/AccordionItem.vue +87 -0
- package/src/components/Accordion/AccordionRoot.vue +111 -0
- package/src/components/Accordion/AccordionTrigger.vue +125 -0
- package/src/components/Accordion/index.ts +11 -0
- package/src/components/Accordion/keys.ts +23 -0
- package/src/components/Avatar/Avatar.test.ts +181 -0
- package/src/components/Avatar/Avatar.vue +150 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.test.ts +141 -0
- package/src/components/Badge/Badge.vue +133 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
- package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
- package/src/components/BadgeContainer/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
- package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
- package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
- package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
- package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
- package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
- package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
- package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
- package/src/components/Breadcrumb/index.ts +13 -0
- package/src/components/Breadcrumb/keys.ts +7 -0
- package/src/components/Button/Button.test.ts +231 -0
- package/src/components/Button/Button.vue +349 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Callout/Callout.test.ts +260 -0
- package/src/components/Callout/Callout.vue +341 -0
- package/src/components/Callout/index.ts +2 -0
- package/src/components/Card/Card.test.ts +565 -0
- package/src/components/Card/Card.vue +209 -0
- package/src/components/Card/CardContent.vue +57 -0
- package/src/components/Card/CardFooter.vue +72 -0
- package/src/components/Card/CardHeader.vue +111 -0
- package/src/components/Card/CardImage.vue +124 -0
- package/src/components/Card/index.ts +14 -0
- package/src/components/Chart/BarChart.vue +208 -0
- package/src/components/Chart/BaseChart.vue +444 -0
- package/src/components/Chart/Chart.test.ts +359 -0
- package/src/components/Chart/DonutChart.vue +283 -0
- package/src/components/Chart/LineChart.vue +211 -0
- package/src/components/Chart/index.ts +20 -0
- package/src/components/Chart/useChartTheme.ts +192 -0
- package/src/components/Checkbox/Checkbox.test.ts +209 -0
- package/src/components/Checkbox/Checkbox.vue +285 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
- package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
- package/src/components/ChoiceChip/index.ts +2 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
- package/src/components/ChoiceChipGroup/index.ts +2 -0
- package/src/components/ColorPicker/ColorArea.vue +159 -0
- package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
- package/src/components/ColorPicker/ColorPicker.vue +339 -0
- package/src/components/ColorPicker/ColorSlider.vue +191 -0
- package/src/components/ColorPicker/index.ts +7 -0
- package/src/components/Combobox/Combobox.test.ts +891 -0
- package/src/components/Combobox/Combobox.vue +934 -0
- package/src/components/Combobox/index.ts +2 -0
- package/src/components/DataTable/DataTable.test.ts +1221 -0
- package/src/components/DataTable/DataTable.vue +1415 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/DatePicker/DatePicker.test.ts +625 -0
- package/src/components/DatePicker/DatePicker.vue +1586 -0
- package/src/components/DatePicker/index.ts +2 -0
- package/src/components/Drawer/Drawer.test.ts +336 -0
- package/src/components/Drawer/Drawer.vue +466 -0
- package/src/components/Drawer/index.ts +2 -0
- package/src/components/Dropdown/Dropdown.test.ts +607 -0
- package/src/components/Dropdown/Dropdown.vue +807 -0
- package/src/components/Dropdown/DropdownItem.vue +227 -0
- package/src/components/Dropdown/DropdownSeparator.vue +14 -0
- package/src/components/Dropdown/DropdownSub.vue +104 -0
- package/src/components/Dropdown/DropdownSubContent.vue +187 -0
- package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
- package/src/components/Dropdown/index.ts +14 -0
- package/src/components/EmptyState/EmptyState.test.ts +180 -0
- package/src/components/EmptyState/EmptyState.vue +137 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/FileUpload/FileUpload.test.ts +1151 -0
- package/src/components/FileUpload/FileUpload.vue +1042 -0
- package/src/components/FileUpload/index.ts +2 -0
- package/src/components/Heading/Heading.test.ts +107 -0
- package/src/components/Heading/Heading.vue +67 -0
- package/src/components/Heading/index.ts +2 -0
- package/src/components/Icon/Icon.test.ts +157 -0
- package/src/components/Icon/Icon.vue +86 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Input/Input.test.ts +273 -0
- package/src/components/Input/Input.vue +388 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Layout/Container.vue +67 -0
- package/src/components/Layout/Grid.vue +159 -0
- package/src/components/Layout/GridItem.vue +154 -0
- package/src/components/Layout/Layout.test.ts +202 -0
- package/src/components/Layout/Stack.vue +128 -0
- package/src/components/Layout/index.ts +9 -0
- package/src/components/Layout/keys.ts +7 -0
- package/src/components/Modal/Modal.test.ts +311 -0
- package/src/components/Modal/Modal.vue +336 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Pagination/Pagination.test.ts +303 -0
- package/src/components/Pagination/Pagination.vue +212 -0
- package/src/components/Pagination/index.ts +3 -0
- package/src/components/Pagination/utils.ts +86 -0
- package/src/components/Popover/Popover.test.ts +285 -0
- package/src/components/Popover/Popover.vue +441 -0
- package/src/components/Popover/index.ts +2 -0
- package/src/components/Progress/Progress.test.ts +361 -0
- package/src/components/Progress/Progress.vue +363 -0
- package/src/components/Progress/index.ts +7 -0
- package/src/components/Radio/Radio.test.ts +216 -0
- package/src/components/Radio/Radio.vue +214 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Rating/Rating.test.ts +319 -0
- package/src/components/Rating/Rating.vue +247 -0
- package/src/components/Rating/index.ts +2 -0
- package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
- package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
- package/src/components/SegmentedControl/index.ts +2 -0
- package/src/components/Select/Select.test.ts +589 -0
- package/src/components/Select/Select.vue +666 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.test.ts +301 -0
- package/src/components/Sidebar/SidebarGroup.vue +103 -0
- package/src/components/Sidebar/SidebarItem.vue +196 -0
- package/src/components/Sidebar/SidebarLayout.vue +42 -0
- package/src/components/Sidebar/SidebarRoot.vue +122 -0
- package/src/components/Sidebar/index.ts +11 -0
- package/src/components/Sidebar/keys.ts +14 -0
- package/src/components/Skeleton/Skeleton.test.ts +130 -0
- package/src/components/Skeleton/Skeleton.vue +104 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.test.ts +416 -0
- package/src/components/Slider/Slider.vue +435 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Slider/utils.ts +91 -0
- package/src/components/Spinner/Spinner.test.ts +79 -0
- package/src/components/Spinner/Spinner.vue +159 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/SpireProvider/SpireProvider.vue +71 -0
- package/src/components/SpireProvider/index.ts +11 -0
- package/src/components/Stepper/Stepper.test.ts +221 -0
- package/src/components/Stepper/StepperContent.vue +51 -0
- package/src/components/Stepper/StepperItem.vue +89 -0
- package/src/components/Stepper/StepperRoot.vue +101 -0
- package/src/components/Stepper/StepperSeparator.vue +52 -0
- package/src/components/Stepper/StepperTrigger.vue +144 -0
- package/src/components/Stepper/index.ts +11 -0
- package/src/components/Stepper/keys.ts +27 -0
- package/src/components/Switch/Switch.test.ts +214 -0
- package/src/components/Switch/Switch.vue +235 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Tabs/Tabs.test.ts +363 -0
- package/src/components/Tabs/Tabs.vue +318 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Text/Text.test.ts +154 -0
- package/src/components/Text/Text.vue +100 -0
- package/src/components/Text/index.ts +2 -0
- package/src/components/Textarea/Textarea.test.ts +432 -0
- package/src/components/Textarea/Textarea.vue +411 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/TimePicker/TimePicker.test.ts +352 -0
- package/src/components/TimePicker/TimePicker.vue +569 -0
- package/src/components/TimePicker/index.ts +2 -0
- package/src/components/Timeline/Timeline.test.ts +193 -0
- package/src/components/Timeline/Timeline.vue +111 -0
- package/src/components/Timeline/TimelineItem.vue +167 -0
- package/src/components/Timeline/index.ts +13 -0
- package/src/components/Timeline/keys.ts +21 -0
- package/src/components/Toast/ToastItem.test.ts +289 -0
- package/src/components/Toast/ToastItem.vue +370 -0
- package/src/components/Toast/ToastProvider.test.ts +158 -0
- package/src/components/Toast/ToastProvider.vue +181 -0
- package/src/components/Toast/index.ts +83 -0
- package/src/components/Toast/toastState.test.ts +165 -0
- package/src/components/Toast/toastState.ts +161 -0
- package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
- package/src/components/ToggleButton/ToggleButton.vue +197 -0
- package/src/components/ToggleButton/index.ts +2 -0
- package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
- package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/Tooltip/Tooltip.test.ts +238 -0
- package/src/components/Tooltip/Tooltip.vue +217 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/components/TreeView/TreeView.test.ts +357 -0
- package/src/components/TreeView/TreeView.vue +251 -0
- package/src/components/TreeView/TreeViewItem.vue +288 -0
- package/src/components/TreeView/index.ts +11 -0
- package/src/components/TreeView/keys.ts +35 -0
- package/src/composables/index.ts +12 -0
- package/src/composables/useClickOutside.ts +36 -0
- package/src/composables/useClipboard.ts +35 -0
- package/src/composables/useEventListener.ts +48 -0
- package/src/composables/useFocusTrap.ts +58 -0
- package/src/composables/useHoverReveal.ts +98 -0
- package/src/composables/useId.ts +10 -0
- package/src/composables/useMagnetic.ts +171 -0
- package/src/composables/useRelativePosition.ts +127 -0
- package/src/composables/useRipple.ts +146 -0
- package/src/composables/useScrollLock.ts +25 -0
- package/src/composables/useSpireConfig.ts +27 -0
- package/src/composables/useStagger.ts +224 -0
- package/src/config/icons.test.ts +115 -0
- package/src/config/icons.ts +170 -0
- package/src/index.ts +361 -0
- package/src/styles/depth.css +129 -0
- package/src/styles/effects.css +169 -0
- package/src/styles/fallback.css +152 -0
- package/src/styles/main.css +25 -0
- package/src/styles/mood.css +211 -0
- package/src/styles/motion.css +159 -0
- package/src/styles/reset.css +97 -0
- package/src/styles/theme.css +708 -0
- package/src/styles/tokens.css +183 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/color.ts +277 -0
- package/src/utils/date.test.ts +522 -0
- package/src/utils/date.ts +380 -0
- package/src/utils/index.ts +23 -0
- package/src/utils/object.test.ts +80 -0
- package/src/utils/object.ts +25 -0
- package/src/utils/string.test.ts +64 -0
- package/src/utils/string.ts +32 -0
- 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,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
|
+
})
|