@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,130 @@
1
+ <script setup lang="ts">
2
+ import { provide, computed, toRef } from 'vue'
3
+
4
+ export interface ToggleGroupProps {
5
+ /** Selected value(s) - single value for 'single' type, array for 'multiple' */
6
+ modelValue?: string | number | (string | number)[]
7
+ /** Selection type */
8
+ type?: 'single' | 'multiple'
9
+ /** Size for all buttons in group */
10
+ size?: 'sm' | 'md' | 'lg'
11
+ /** Disabled state for all buttons */
12
+ disabled?: boolean
13
+ /** Accessible label for the group */
14
+ label?: string
15
+ /** Orientation for keyboard navigation */
16
+ orientation?: 'horizontal' | 'vertical'
17
+ }
18
+
19
+ const props = withDefaults(defineProps<ToggleGroupProps>(), {
20
+ type: 'single',
21
+ size: 'md',
22
+ disabled: false,
23
+ orientation: 'horizontal'
24
+ })
25
+
26
+ const emit = defineEmits<{
27
+ (e: 'update:modelValue', value: string | number | (string | number)[] | undefined): void
28
+ (e: 'change', value: string | number | (string | number)[] | undefined): void
29
+ }>()
30
+
31
+ function toggle(value: string | number) {
32
+ if (props.type === 'single') {
33
+ const newValue = props.modelValue === value ? undefined : value
34
+ emit('update:modelValue', newValue)
35
+ emit('change', newValue)
36
+ } else {
37
+ const currentArray = Array.isArray(props.modelValue) ? props.modelValue : []
38
+ const newArray = currentArray.includes(value)
39
+ ? currentArray.filter(v => v !== value)
40
+ : [...currentArray, value]
41
+ emit('update:modelValue', newArray)
42
+ emit('change', newArray)
43
+ }
44
+ }
45
+
46
+ provide('toggleGroup', {
47
+ modelValue: toRef(props, 'modelValue'),
48
+ toggle,
49
+ type: props.type,
50
+ size: props.size,
51
+ disabled: props.disabled
52
+ })
53
+
54
+ const role = computed(() => props.type === 'single' ? 'radiogroup' : 'group')
55
+ </script>
56
+
57
+ <template>
58
+ <div
59
+ :role="role"
60
+ :aria-label="label"
61
+ :aria-orientation="orientation"
62
+ :aria-disabled="disabled || undefined"
63
+ class="ui-toggle-group"
64
+ :class="[
65
+ `ui-toggle-group--${orientation}`,
66
+ { 'ui-toggle-group--disabled': disabled }
67
+ ]"
68
+ >
69
+ <slot />
70
+ </div>
71
+ </template>
72
+
73
+ <style scoped>
74
+ .ui-toggle-group {
75
+ display: inline-flex;
76
+ gap: 0;
77
+ }
78
+
79
+ .ui-toggle-group--horizontal {
80
+ flex-direction: row;
81
+ }
82
+
83
+ .ui-toggle-group--vertical {
84
+ flex-direction: column;
85
+ }
86
+
87
+ .ui-toggle-group--horizontal :slotted(.ui-toggle-button) {
88
+ border-radius: 0;
89
+ }
90
+
91
+ .ui-toggle-group--horizontal :slotted(.ui-toggle-button:first-child) {
92
+ border-top-left-radius: var(--radius-md);
93
+ border-bottom-left-radius: var(--radius-md);
94
+ }
95
+
96
+ .ui-toggle-group--horizontal :slotted(.ui-toggle-button:last-child) {
97
+ border-top-right-radius: var(--radius-md);
98
+ border-bottom-right-radius: var(--radius-md);
99
+ }
100
+
101
+ .ui-toggle-group--horizontal :slotted(.ui-toggle-button:not(:first-child)) {
102
+ margin-left: -1px;
103
+ }
104
+
105
+ .ui-toggle-group--vertical :slotted(.ui-toggle-button) {
106
+ border-radius: 0;
107
+ }
108
+
109
+ .ui-toggle-group--vertical :slotted(.ui-toggle-button:first-child) {
110
+ border-top-left-radius: var(--radius-md);
111
+ border-top-right-radius: var(--radius-md);
112
+ }
113
+
114
+ .ui-toggle-group--vertical :slotted(.ui-toggle-button:last-child) {
115
+ border-bottom-left-radius: var(--radius-md);
116
+ border-bottom-right-radius: var(--radius-md);
117
+ }
118
+
119
+ .ui-toggle-group--vertical :slotted(.ui-toggle-button:not(:first-child)) {
120
+ margin-top: -1px;
121
+ }
122
+
123
+ .ui-toggle-group :slotted(.ui-toggle-button--pressed) {
124
+ z-index: 1;
125
+ }
126
+
127
+ .ui-toggle-group--disabled {
128
+ opacity: 0.5;
129
+ }
130
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as ToggleGroup } from './ToggleGroup.vue'
2
+ export type { ToggleGroupProps } from './ToggleGroup.vue'
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mount, config } from '@vue/test-utils'
3
+ import Tooltip from './Tooltip.vue'
4
+
5
+ // Disable teleport for testing
6
+ config.global.stubs = {
7
+ teleport: true
8
+ }
9
+
10
+ describe('Tooltip', () => {
11
+ beforeEach(() => {
12
+ vi.useFakeTimers()
13
+ })
14
+
15
+ afterEach(() => {
16
+ vi.useRealTimers()
17
+ })
18
+
19
+ describe('Rendering', () => {
20
+ it('renders trigger slot content', () => {
21
+ const wrapper = mount(Tooltip, {
22
+ props: { text: 'Help text' },
23
+ slots: { default: '<button>Hover me</button>' }
24
+ })
25
+ expect(wrapper.find('button').text()).toBe('Hover me')
26
+ })
27
+
28
+ it('does not show tooltip initially', () => {
29
+ const wrapper = mount(Tooltip, {
30
+ props: { text: 'Help text' },
31
+ slots: { default: '<button>Hover me</button>' }
32
+ })
33
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(false)
34
+ })
35
+
36
+ it('shows tooltip on mouseenter after delay', async () => {
37
+ const wrapper = mount(Tooltip, {
38
+ props: { text: 'Help text', delay: 100 },
39
+ slots: { default: '<button>Hover me</button>' }
40
+ })
41
+
42
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
43
+
44
+ // Not visible yet (within delay)
45
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(false)
46
+
47
+ // Advance past delay
48
+ vi.advanceTimersByTime(150)
49
+ await wrapper.vm.$nextTick()
50
+
51
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(true)
52
+ expect(wrapper.find('[role="tooltip"]').text()).toBe('Help text')
53
+ })
54
+
55
+ it('hides tooltip on mouseleave', async () => {
56
+ const wrapper = mount(Tooltip, {
57
+ props: { text: 'Help text', delay: 0 },
58
+ slots: { default: '<button>Hover me</button>' }
59
+ })
60
+
61
+ // Show tooltip
62
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
63
+ vi.advanceTimersByTime(50)
64
+ await wrapper.vm.$nextTick()
65
+
66
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(true)
67
+
68
+ // Hide tooltip
69
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseleave')
70
+ vi.advanceTimersByTime(100)
71
+ await wrapper.vm.$nextTick()
72
+
73
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe('Focus interactions', () => {
78
+ it('shows tooltip on focusin', async () => {
79
+ const wrapper = mount(Tooltip, {
80
+ props: { text: 'Help text', delay: 0 },
81
+ slots: { default: '<button>Focus me</button>' }
82
+ })
83
+
84
+ await wrapper.find('.ui-tooltip-trigger').trigger('focusin')
85
+ vi.advanceTimersByTime(50)
86
+ await wrapper.vm.$nextTick()
87
+
88
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(true)
89
+ })
90
+
91
+ it('hides tooltip on focusout', async () => {
92
+ const wrapper = mount(Tooltip, {
93
+ props: { text: 'Help text', delay: 0 },
94
+ slots: { default: '<button>Focus me</button>' }
95
+ })
96
+
97
+ await wrapper.find('.ui-tooltip-trigger').trigger('focusin')
98
+ vi.advanceTimersByTime(50)
99
+ await wrapper.vm.$nextTick()
100
+
101
+ await wrapper.find('.ui-tooltip-trigger').trigger('focusout')
102
+ vi.advanceTimersByTime(100)
103
+ await wrapper.vm.$nextTick()
104
+
105
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(false)
106
+ })
107
+ })
108
+
109
+ describe('Keyboard interactions', () => {
110
+ it('hides tooltip on Escape key', async () => {
111
+ const wrapper = mount(Tooltip, {
112
+ props: { text: 'Help text', delay: 0 },
113
+ slots: { default: '<button>Press Escape</button>' }
114
+ })
115
+
116
+ // Show tooltip
117
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
118
+ vi.advanceTimersByTime(50)
119
+ await wrapper.vm.$nextTick()
120
+
121
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(true)
122
+
123
+ // Press Escape
124
+ await wrapper.find('.ui-tooltip-trigger').trigger('keydown', { key: 'Escape' })
125
+ vi.advanceTimersByTime(100)
126
+ await wrapper.vm.$nextTick()
127
+
128
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(false)
129
+ })
130
+ })
131
+
132
+ describe('Props', () => {
133
+ it('respects custom delay', async () => {
134
+ const wrapper = mount(Tooltip, {
135
+ props: { text: 'Help text', delay: 500 },
136
+ slots: { default: '<button>Hover me</button>' }
137
+ })
138
+
139
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
140
+
141
+ // Not visible at 400ms
142
+ vi.advanceTimersByTime(400)
143
+ await wrapper.vm.$nextTick()
144
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(false)
145
+
146
+ // Visible at 550ms
147
+ vi.advanceTimersByTime(150)
148
+ await wrapper.vm.$nextTick()
149
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(true)
150
+ })
151
+
152
+ it('does not show when disabled', async () => {
153
+ const wrapper = mount(Tooltip, {
154
+ props: { text: 'Help text', delay: 0, disabled: true },
155
+ slots: { default: '<button>Hover me</button>' }
156
+ })
157
+
158
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
159
+ vi.advanceTimersByTime(100)
160
+ await wrapper.vm.$nextTick()
161
+
162
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(false)
163
+ })
164
+ })
165
+
166
+ describe('Accessibility', () => {
167
+ it('has role="tooltip" on content', async () => {
168
+ const wrapper = mount(Tooltip, {
169
+ props: { text: 'Help text', delay: 0 },
170
+ slots: { default: '<button>Hover me</button>' }
171
+ })
172
+
173
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
174
+ vi.advanceTimersByTime(50)
175
+ await wrapper.vm.$nextTick()
176
+
177
+ expect(wrapper.find('[role="tooltip"]').exists()).toBe(true)
178
+ })
179
+
180
+ it('sets aria-describedby when visible', async () => {
181
+ const wrapper = mount(Tooltip, {
182
+ props: { text: 'Help text', delay: 0 },
183
+ slots: { default: '<button>Hover me</button>' }
184
+ })
185
+
186
+ // Initially no aria-describedby
187
+ expect(wrapper.find('.ui-tooltip-trigger').attributes('aria-describedby')).toBeUndefined()
188
+
189
+ // Show tooltip
190
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
191
+ vi.advanceTimersByTime(50)
192
+ await wrapper.vm.$nextTick()
193
+
194
+ // Now has aria-describedby matching tooltip id
195
+ const describedBy = wrapper.find('.ui-tooltip-trigger').attributes('aria-describedby')
196
+ expect(describedBy).toBeDefined()
197
+ expect(describedBy).toMatch(/^tooltip-/)
198
+ })
199
+
200
+ it('tooltip id matches aria-describedby', async () => {
201
+ const wrapper = mount(Tooltip, {
202
+ props: { text: 'Help text', delay: 0 },
203
+ slots: { default: '<button>Hover me</button>' }
204
+ })
205
+
206
+ await wrapper.find('.ui-tooltip-trigger').trigger('mouseenter')
207
+ vi.advanceTimersByTime(50)
208
+ await wrapper.vm.$nextTick()
209
+
210
+ const describedBy = wrapper.find('.ui-tooltip-trigger').attributes('aria-describedby')
211
+ const tooltipId = wrapper.find('[role="tooltip"]').attributes('id')
212
+ expect(describedBy).toBe(tooltipId)
213
+ })
214
+ })
215
+
216
+ describe('Placement', () => {
217
+ it('defaults to top placement', () => {
218
+ const wrapper = mount(Tooltip, {
219
+ props: { text: 'Help text' },
220
+ slots: { default: '<button>Hover me</button>' }
221
+ })
222
+ // Placement is internal, but we can check the component accepts the prop
223
+ expect(wrapper.props('placement')).toBe('top')
224
+ })
225
+
226
+ it('accepts different placements', () => {
227
+ const placements = ['top', 'bottom', 'left', 'right'] as const
228
+
229
+ placements.forEach(placement => {
230
+ const wrapper = mount(Tooltip, {
231
+ props: { text: 'Help text', placement },
232
+ slots: { default: '<button>Hover me</button>' }
233
+ })
234
+ expect(wrapper.props('placement')).toBe(placement)
235
+ })
236
+ })
237
+ })
238
+ })
@@ -0,0 +1,217 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onUnmounted } from 'vue'
3
+ import { useId, useRelativePosition, calculatePosition, type Placement } from '../../composables'
4
+
5
+ export interface TooltipProps {
6
+ /** Tooltip text content */
7
+ text: string
8
+ /** Preferred placement (may flip on collision) */
9
+ placement?: Placement
10
+ /** Delay before showing (ms) - prevents flicker on rapid mouseover */
11
+ delay?: number
12
+ /** Gap between trigger and tooltip */
13
+ offset?: number
14
+ /** Disable the tooltip */
15
+ disabled?: boolean
16
+ }
17
+
18
+ const props = withDefaults(defineProps<TooltipProps>(), {
19
+ placement: 'top',
20
+ delay: 200,
21
+ offset: 8,
22
+ disabled: false
23
+ })
24
+
25
+ const tooltipId = useId('tooltip')
26
+
27
+ const triggerRef = ref<HTMLElement | null>(null)
28
+ const contentRef = ref<HTMLElement | null>(null)
29
+ const isVisible = ref(false)
30
+ let showTimer: ReturnType<typeof setTimeout> | null = null
31
+ let hideTimer: ReturnType<typeof setTimeout> | null = null
32
+
33
+ const { coords, updatePosition, getPlacement } = useRelativePosition(
34
+ triggerRef,
35
+ contentRef,
36
+ () => props.placement,
37
+ props.offset
38
+ )
39
+
40
+ function onEnter(el: Element) {
41
+ if (!triggerRef.value) return
42
+ const result = calculatePosition(
43
+ triggerRef.value,
44
+ el as HTMLElement,
45
+ getPlacement(),
46
+ props.offset
47
+ )
48
+ coords.value = result
49
+ }
50
+
51
+ const tooltipStyle = computed(() => ({
52
+ top: `${coords.value.top}px`,
53
+ left: `${coords.value.left}px`
54
+ }))
55
+
56
+ const arrowClass = computed(() => `ui-tooltip__arrow--${coords.value.actualPlacement}`)
57
+
58
+ function show() {
59
+ if (props.disabled) return
60
+
61
+ if (hideTimer) {
62
+ clearTimeout(hideTimer)
63
+ hideTimer = null
64
+ }
65
+
66
+ if (showTimer) clearTimeout(showTimer)
67
+ showTimer = setTimeout(() => {
68
+ isVisible.value = true
69
+ }, props.delay)
70
+ }
71
+
72
+ function hide() {
73
+ if (showTimer) {
74
+ clearTimeout(showTimer)
75
+ showTimer = null
76
+ }
77
+
78
+ hideTimer = setTimeout(() => {
79
+ isVisible.value = false
80
+ }, 50)
81
+ }
82
+
83
+ function onKeydown(e: KeyboardEvent) {
84
+ if (e.key === 'Escape' && isVisible.value) {
85
+ hide()
86
+ }
87
+ }
88
+
89
+ onUnmounted(() => {
90
+ if (showTimer) clearTimeout(showTimer)
91
+ if (hideTimer) clearTimeout(hideTimer)
92
+ })
93
+ </script>
94
+
95
+ <template>
96
+ <span
97
+ ref="triggerRef"
98
+ class="ui-tooltip-trigger"
99
+ :aria-describedby="isVisible ? tooltipId : undefined"
100
+ @mouseenter="show"
101
+ @mouseleave="hide"
102
+ @focusin="show"
103
+ @focusout="hide"
104
+ @keydown="onKeydown"
105
+ >
106
+ <slot />
107
+ </span>
108
+
109
+ <Teleport to="body">
110
+ <Transition :name="`ui-tooltip-${coords.actualPlacement}`" @enter="onEnter">
111
+ <div
112
+ v-if="isVisible && !disabled"
113
+ :id="tooltipId"
114
+ ref="contentRef"
115
+ role="tooltip"
116
+ class="ui-tooltip"
117
+ :style="tooltipStyle"
118
+ >
119
+ {{ text }}
120
+ <span class="ui-tooltip__arrow" :class="arrowClass" />
121
+ </div>
122
+ </Transition>
123
+ </Teleport>
124
+ </template>
125
+
126
+ <style scoped>
127
+ .ui-tooltip-trigger {
128
+ display: inline-block;
129
+ }
130
+
131
+ .ui-tooltip {
132
+ position: fixed;
133
+ z-index: var(--z-tooltip);
134
+ max-width: 250px;
135
+ padding: var(--space-1) var(--space-2);
136
+ background-color: var(--tooltip-bg);
137
+ color: var(--tooltip-text);
138
+ font-family: var(--font-sans);
139
+ font-size: var(--text-xs);
140
+ line-height: 1.4;
141
+ border-radius: var(--radius-sm);
142
+ box-shadow: var(--shadow-md);
143
+ pointer-events: none;
144
+ white-space: normal;
145
+ word-wrap: break-word;
146
+ }
147
+
148
+ .ui-tooltip__arrow {
149
+ position: absolute;
150
+ width: 8px;
151
+ height: 8px;
152
+ background: var(--tooltip-bg);
153
+ transform: rotate(45deg);
154
+ }
155
+
156
+ .ui-tooltip__arrow--top {
157
+ bottom: -4px;
158
+ left: 50%;
159
+ margin-left: -4px;
160
+ }
161
+
162
+ .ui-tooltip__arrow--bottom {
163
+ top: -4px;
164
+ left: 50%;
165
+ margin-left: -4px;
166
+ }
167
+
168
+ .ui-tooltip__arrow--left {
169
+ right: -4px;
170
+ top: 50%;
171
+ margin-top: -4px;
172
+ }
173
+
174
+ .ui-tooltip__arrow--right {
175
+ left: -4px;
176
+ top: 50%;
177
+ margin-top: -4px;
178
+ }
179
+
180
+ .ui-tooltip-top-enter-active,
181
+ .ui-tooltip-top-leave-active,
182
+ .ui-tooltip-bottom-enter-active,
183
+ .ui-tooltip-bottom-leave-active,
184
+ .ui-tooltip-left-enter-active,
185
+ .ui-tooltip-left-leave-active,
186
+ .ui-tooltip-right-enter-active,
187
+ .ui-tooltip-right-leave-active {
188
+ transition:
189
+ opacity var(--duration-fast) var(--ease-out-expo),
190
+ transform var(--duration-fast) var(--ease-out-expo);
191
+ will-change: transform, opacity;
192
+ }
193
+
194
+ .ui-tooltip-top-enter-from,
195
+ .ui-tooltip-top-leave-to {
196
+ opacity: 0;
197
+ transform: translateY(4px) scale(0.96);
198
+ }
199
+
200
+ .ui-tooltip-bottom-enter-from,
201
+ .ui-tooltip-bottom-leave-to {
202
+ opacity: 0;
203
+ transform: translateY(-4px) scale(0.96);
204
+ }
205
+
206
+ .ui-tooltip-left-enter-from,
207
+ .ui-tooltip-left-leave-to {
208
+ opacity: 0;
209
+ transform: translateX(4px) scale(0.96);
210
+ }
211
+
212
+ .ui-tooltip-right-enter-from,
213
+ .ui-tooltip-right-leave-to {
214
+ opacity: 0;
215
+ transform: translateX(-4px) scale(0.96);
216
+ }
217
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as Tooltip } from './Tooltip.vue'
2
+ export type { TooltipProps } from './Tooltip.vue'