@brumaombra/ui-vintage 0.7.13 → 0.8.0

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 (191) hide show
  1. package/COMPONENTS.md +3602 -0
  2. package/README.md +49 -9
  3. package/package.json +29 -8
  4. package/skills/ui-vintage/SKILL.md +42 -0
  5. package/src/components/blog/BlogFAQSection.vue +2 -2
  6. package/src/components/blog/BlogHeaderSection.vue +2 -2
  7. package/src/components/blog/BlogInfoSection.vue +3 -3
  8. package/src/components/blog/BlogProgressBar.vue +7 -6
  9. package/src/components/blog/BlogSectionTitle.vue +1 -1
  10. package/src/components/blog/BlogTagsSection.vue +22 -0
  11. package/src/components/blog/CategoryCard.vue +1 -1
  12. package/src/components/blog/HeaderCarousel.vue +6 -6
  13. package/src/components/blog/PostCard.vue +2 -2
  14. package/src/components/blog/SocialShareSidebar.vue +1 -1
  15. package/src/components/blog/TableOfContents.vue +6 -6
  16. package/src/components/blog/index.ts +1 -0
  17. package/src/components/busy-indicator/busy-indicator-state.ts +3 -2
  18. package/src/components/chip/Chip.vue +9 -5
  19. package/src/components/confirm-dialog/ConfirmDialog.vue +17 -6
  20. package/src/components/confirm-dialog/confirm-dialog-state.ts +4 -3
  21. package/src/components/content/BlogList.vue +3 -3
  22. package/src/components/content/BlogTable.vue +7 -7
  23. package/src/components/content/CodeBlock.vue +4 -4
  24. package/src/components/content/Flow.vue +3 -3
  25. package/src/components/content/ProseHr.vue +2 -2
  26. package/src/components/content/Terminal.vue +4 -4
  27. package/src/components/dashboard-shell/DashboardShell.vue +3 -3
  28. package/src/components/empty-state-card/EmptyStateCard.vue +4 -4
  29. package/src/components/error-page/ErrorPage.vue +16 -14
  30. package/src/components/info-card/InfoCard.vue +1 -1
  31. package/src/components/landing/landing-navbar/LandingNavbar.vue +2 -1
  32. package/src/components/load-more-button/LoadMoreButton.vue +2 -2
  33. package/src/components/loading-state-card/LoadingStateCard.vue +4 -4
  34. package/src/components/message-dialog/MessageDialog.vue +4 -4
  35. package/src/components/message-toast/MessageToast.vue +222 -50
  36. package/src/components/message-toast/index.ts +2 -2
  37. package/src/components/message-toast/message-toast-state.ts +107 -81
  38. package/src/components/page-header/PageHeader.vue +1 -1
  39. package/src/components/progress-component/ProgressComponent.vue +4 -4
  40. package/src/components/single-value-card/SingleValueCard.vue +47 -11
  41. package/src/components/theme-selector/ThemeSelector.vue +20 -36
  42. package/src/components/theme-selector/index.ts +3 -1
  43. package/src/components/theme-selector/theme-transition.ts +86 -0
  44. package/src/components/ui/accordion/Accordion.vue +41 -26
  45. package/src/components/ui/alert-dialog/AlertDialogContent.vue +2 -2
  46. package/src/components/ui/alert-dialog/AlertDialogFooter.vue +1 -1
  47. package/src/components/ui/animated-number/AnimatedNumber.vue +88 -0
  48. package/src/components/ui/animated-number/index.ts +1 -0
  49. package/src/components/ui/avatar/Avatar.vue +66 -0
  50. package/src/components/ui/avatar/AvatarFallback.vue +31 -0
  51. package/src/components/ui/avatar/AvatarGroup.vue +75 -0
  52. package/src/components/ui/avatar/AvatarImage.vue +23 -0
  53. package/src/components/ui/avatar/index.ts +47 -0
  54. package/src/components/ui/badge/Badge.vue +13 -2
  55. package/src/components/ui/breadcrumb/Breadcrumb.vue +4 -1
  56. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +7 -1
  57. package/src/components/ui/busy/Busy.vue +45 -20
  58. package/src/components/ui/button/Button.vue +20 -3
  59. package/src/components/ui/button/index.ts +12 -2
  60. package/src/components/ui/card/Card.vue +21 -8
  61. package/src/components/ui/card/CardText.vue +1 -1
  62. package/src/components/ui/checkbox/Checkbox.vue +48 -0
  63. package/src/components/ui/checkbox/index.ts +1 -0
  64. package/src/components/ui/collapsible/Collapsible.vue +22 -0
  65. package/src/components/ui/collapsible/CollapsibleContent.vue +22 -0
  66. package/src/components/ui/collapsible/CollapsibleTrigger.vue +19 -0
  67. package/src/components/ui/collapsible/index.ts +3 -0
  68. package/src/components/ui/combobox/Combobox.vue +24 -0
  69. package/src/components/ui/combobox/ComboboxAnchor.vue +39 -0
  70. package/src/components/ui/combobox/ComboboxEmpty.vue +21 -0
  71. package/src/components/ui/combobox/ComboboxGroup.vue +26 -0
  72. package/src/components/ui/combobox/ComboboxInput.vue +28 -0
  73. package/src/components/ui/combobox/ComboboxItem.vue +39 -0
  74. package/src/components/ui/combobox/ComboboxList.vue +43 -0
  75. package/src/components/ui/combobox/ComboboxSelect.vue +162 -0
  76. package/src/components/ui/combobox/ComboboxSeparator.vue +16 -0
  77. package/src/components/ui/combobox/ComboboxTrigger.vue +28 -0
  78. package/src/components/ui/combobox/index.ts +11 -0
  79. package/src/components/ui/combobox/types.ts +16 -0
  80. package/src/components/ui/command/CommandDialog.vue +18 -16
  81. package/src/components/ui/command/CommandGroup.vue +1 -1
  82. package/src/components/ui/command/CommandInput.vue +2 -2
  83. package/src/components/ui/command/CommandItem.vue +7 -5
  84. package/src/components/ui/data-table/DataTable.vue +230 -0
  85. package/src/components/ui/data-table/index.ts +20 -0
  86. package/src/components/ui/date-picker/DatePicker.vue +11 -15
  87. package/src/components/ui/date-time-picker/DateTimePicker.vue +2 -7
  88. package/src/components/ui/dialog/DialogContent.vue +9 -4
  89. package/src/components/ui/dialog/DialogOverlay.vue +1 -1
  90. package/src/components/ui/dialog/DialogScrollContent.vue +10 -5
  91. package/src/components/ui/dropdown-menu/DropdownMenu.vue +18 -0
  92. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +34 -0
  93. package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
  94. package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +13 -0
  95. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +34 -0
  96. package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +26 -0
  97. package/src/components/ui/dropdown-menu/DropdownMenuPortal.vue +13 -0
  98. package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +18 -0
  99. package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +32 -0
  100. package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +16 -0
  101. package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +15 -0
  102. package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +18 -0
  103. package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +39 -0
  104. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +36 -0
  105. package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +15 -0
  106. package/src/components/ui/dropdown-menu/classes.ts +24 -0
  107. package/src/components/ui/dropdown-menu/index.ts +15 -0
  108. package/src/components/ui/field/FieldDescription.vue +1 -1
  109. package/src/components/ui/field/FieldError.vue +4 -1
  110. package/src/components/ui/file-dropzone/FileDropzone.vue +250 -0
  111. package/src/components/ui/file-dropzone/index.ts +35 -0
  112. package/src/components/ui/hover-card/HoverCard.vue +21 -0
  113. package/src/components/ui/hover-card/HoverCardContent.vue +33 -0
  114. package/src/components/ui/hover-card/HoverCardTrigger.vue +13 -0
  115. package/src/components/ui/hover-card/index.ts +3 -0
  116. package/src/components/ui/input/Input.vue +5 -5
  117. package/src/components/ui/kbd/Kbd.vue +21 -0
  118. package/src/components/ui/kbd/KbdGroup.vue +72 -0
  119. package/src/components/ui/kbd/index.ts +2 -0
  120. package/src/components/ui/number-field/NumberField.vue +130 -0
  121. package/src/components/ui/number-field/index.ts +1 -0
  122. package/src/components/ui/pagination/Pagination.vue +27 -0
  123. package/src/components/ui/pagination/PaginationContent.vue +20 -0
  124. package/src/components/ui/pagination/PaginationEllipsis.vue +31 -0
  125. package/src/components/ui/pagination/PaginationFirst.vue +32 -0
  126. package/src/components/ui/pagination/PaginationItem.vue +41 -0
  127. package/src/components/ui/pagination/PaginationLast.vue +32 -0
  128. package/src/components/ui/pagination/PaginationNext.vue +32 -0
  129. package/src/components/ui/pagination/PaginationPrevious.vue +32 -0
  130. package/src/components/ui/pagination/SimplePagination.vue +49 -0
  131. package/src/components/ui/pagination/index.ts +9 -0
  132. package/src/components/ui/pin-input/PinInput.vue +48 -0
  133. package/src/components/ui/pin-input/PinInputInput.vue +49 -0
  134. package/src/components/ui/pin-input/context.ts +8 -0
  135. package/src/components/ui/pin-input/index.ts +2 -0
  136. package/src/components/ui/popover/PopoverContent.vue +1 -1
  137. package/src/components/ui/progress/Progress.vue +22 -7
  138. package/src/components/ui/radio-group/RadioGroup.vue +27 -0
  139. package/src/components/ui/radio-group/RadioGroupCard.vue +56 -0
  140. package/src/components/ui/radio-group/RadioGroupItem.vue +40 -0
  141. package/src/components/ui/radio-group/index.ts +3 -0
  142. package/src/components/ui/select/SelectContent.vue +1 -1
  143. package/src/components/ui/select/SelectItem.vue +2 -2
  144. package/src/components/ui/select/SelectItemContent.vue +1 -1
  145. package/src/components/ui/select/SelectTrigger.vue +6 -6
  146. package/src/components/ui/select/SelectValueContent.vue +1 -1
  147. package/src/components/ui/sheet/SheetContent.vue +5 -5
  148. package/src/components/ui/sheet/SheetOverlay.vue +1 -1
  149. package/src/components/ui/sidebar/Sidebar.vue +7 -5
  150. package/src/components/ui/sidebar/SidebarRail.vue +3 -1
  151. package/src/components/ui/sidebar/SidebarTrigger.vue +3 -1
  152. package/src/components/ui/skeleton/Skeleton.vue +4 -4
  153. package/src/components/ui/slider/Slider.vue +8 -6
  154. package/src/components/ui/spinner/Spinner.vue +47 -0
  155. package/src/components/ui/spinner/index.ts +1 -0
  156. package/src/components/ui/stepper/SimpleStepper.vue +56 -0
  157. package/src/components/ui/stepper/Stepper.vue +30 -0
  158. package/src/components/ui/stepper/StepperDescription.vue +20 -0
  159. package/src/components/ui/stepper/StepperIndicator.vue +41 -0
  160. package/src/components/ui/stepper/StepperItem.vue +21 -0
  161. package/src/components/ui/stepper/StepperSeparator.vue +34 -0
  162. package/src/components/ui/stepper/StepperTitle.vue +20 -0
  163. package/src/components/ui/stepper/StepperTrigger.vue +20 -0
  164. package/src/components/ui/stepper/index.ts +17 -0
  165. package/src/components/ui/switch/Switch.vue +8 -6
  166. package/src/components/ui/tabs/TabsContent.vue +1 -1
  167. package/src/components/ui/tabs/TabsList.vue +13 -5
  168. package/src/components/ui/tabs/TabsTrigger.vue +5 -3
  169. package/src/components/ui/tags-input/TagsInput.vue +49 -0
  170. package/src/components/ui/tags-input/TagsInputInput.vue +23 -0
  171. package/src/components/ui/tags-input/TagsInputItem.vue +35 -0
  172. package/src/components/ui/tags-input/TagsInputItemDelete.vue +31 -0
  173. package/src/components/ui/tags-input/TagsInputItemText.vue +21 -0
  174. package/src/components/ui/tags-input/index.ts +5 -0
  175. package/src/components/ui/textarea/Textarea.vue +4 -5
  176. package/src/components/ui/toggle-group/Toggle.vue +33 -0
  177. package/src/components/ui/toggle-group/ToggleGroup.vue +93 -0
  178. package/src/components/ui/toggle-group/ToggleGroupItem.vue +34 -0
  179. package/src/components/ui/toggle-group/index.ts +69 -0
  180. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  181. package/src/i18n/de.json +76 -0
  182. package/src/i18n/en.json +76 -0
  183. package/src/i18n/es.json +76 -0
  184. package/src/i18n/fr.json +76 -0
  185. package/src/i18n/it.json +76 -0
  186. package/src/i18n/ja.json +76 -0
  187. package/src/i18n/pt.json +76 -0
  188. package/src/i18n/ru.json +76 -0
  189. package/src/i18n/zh.json +76 -0
  190. package/src/lib/color-tokens.ts +14 -14
  191. package/src/styles.css +289 -5
@@ -1,75 +1,247 @@
1
1
  <script setup lang="ts">
2
2
  import { AlertCircleIcon, Cancel01Icon, CheckmarkCircle02Icon, InformationCircleIcon } from '@hugeicons/core-free-icons';
3
3
  import { HugeiconsIcon } from '@hugeicons/vue';
4
- import { computed } from 'vue';
4
+ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
5
5
  import { getUiVintageRuntimeMessage } from '../../lib/i18n';
6
- import { Button } from '../ui/button';
7
- import { closeMessageToast, messageToastState } from './message-toast-state';
6
+ import { cn } from '../../lib/utils';
7
+ import { closeMessageToast, messageToastState, pauseMessageToasts, resumeMessageToasts } from './message-toast-state';
8
+ import type { MessageToastItem, MessageToastType } from './message-toast-state';
8
9
 
9
- // Current toast data
10
- const currentToast = computed(() => messageToastState.current);
10
+ const VISIBLE_TOASTS = 3;
11
+ const STACK_OFFSET_PX = 12;
12
+ const STACK_GAP_PX = 10;
13
+ const SWIPE_THRESHOLD_PX = 80;
11
14
 
12
- // Localized dismiss label resolved from bundled library messages
15
+ const expanded = ref(false);
16
+ const heights = reactive(new Map<number, number>());
17
+ const swipe = reactive({ id: null as number | null, startX: 0, deltaX: 0 });
18
+ const leaveDirection = reactive(new Map<number, number>());
19
+
20
+ // Localized labels resolved from bundled library messages
13
21
  const closeAriaLabel = computed(() => getUiVintageRuntimeMessage('uiVintage.buttons.close', 'Close'));
22
+ const regionAriaLabel = computed(() => getUiVintageRuntimeMessage('uiVintage.toast.region', 'Notifications'));
23
+
24
+ // Resolve the icon for each toast type
25
+ const getToastIcon = (toast: MessageToastItem) => {
26
+ if (toast.icon) return toast.icon;
27
+ if (toast.type === 'success') return CheckmarkCircle02Icon;
28
+ return toast.type === 'info' ? InformationCircleIcon : AlertCircleIcon;
29
+ };
30
+
31
+ // Resolve the tinted icon tile classes for each toast type
32
+ const toneClasses: Record<MessageToastType, string> = {
33
+ success: 'border-green-200 bg-green-50 text-green-600 dark:border-green-900/50 dark:bg-green-950/30 dark:text-green-400',
34
+ info: 'border-blue-200 bg-blue-50 text-blue-600 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-400',
35
+ warning: 'border-amber-200 bg-amber-50 text-amber-600 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-400',
36
+ error: 'border-red-200 bg-red-50 text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400'
37
+ };
14
38
 
15
- // Determine toast type for styling and icon selection
16
- const toastType = computed(() => currentToast.value?.type ?? 'success');
39
+ // Progress bar color for each toast type
40
+ const progressClasses: Record<MessageToastType, string> = {
41
+ success: 'bg-green-500',
42
+ info: 'bg-blue-500',
43
+ warning: 'bg-amber-500',
44
+ error: 'bg-red-500'
45
+ };
17
46
 
18
- // Resolved icon based on toast type
19
- const toastIcon = computed(() => {
20
- if (toastType.value === 'success') return CheckmarkCircle02Icon;
21
- return toastType.value === "info" ? InformationCircleIcon : AlertCircleIcon;
47
+ const toasts = computed(() => messageToastState.toasts);
48
+ const frontHeight = computed(() => (toasts.value[0] && heights.get(toasts.value[0].id)) || 0);
49
+
50
+ // Height of the stack container so the hover area always matches what is visible
51
+ const containerHeight = computed(() => {
52
+ const visible = toasts.value.slice(0, VISIBLE_TOASTS);
53
+ if (!visible.length) return 0;
54
+ if (expanded.value) {
55
+ return visible.reduce((total, toast) => total + (heights.get(toast.id) || 0), 0) + STACK_GAP_PX * (visible.length - 1);
56
+ }
57
+ return frontHeight.value + STACK_OFFSET_PX * (visible.length - 1);
22
58
  });
23
59
 
24
- // Resolved icon color classes based on toast type
25
- const toastIconClasses = computed(() => {
26
- if (toastType.value === 'error') return 'text-red-600 dark:text-red-400';
27
- if (toastType.value === 'warning') return 'text-amber-600 dark:text-amber-400';
28
- if (toastType.value === 'info') return 'text-blue-600 dark:text-blue-400';
29
- return 'text-green-600 dark:text-green-400';
60
+ // Compute the stacked position of a toast from its index (0 = newest)
61
+ const getToastStyle = (toast: MessageToastItem, index: number) => {
62
+ const isSwiping = swipe.id === toast.id;
63
+ let offset = 0;
64
+ let scale = 1;
65
+
66
+ // Expanded: toasts sit on top of each other with a gap
67
+ if (expanded.value) {
68
+ for (const previous of toasts.value.slice(0, index)) {
69
+ offset += (heights.get(previous.id) || 0) + STACK_GAP_PX;
70
+ }
71
+ } else {
72
+ // Collapsed: toasts peek out behind the front one
73
+ offset = index * STACK_OFFSET_PX;
74
+ scale = 1 - index * 0.05;
75
+ }
76
+
77
+ return {
78
+ '--uv-toast-leave-x': `${(leaveDirection.get(toast.id) ?? 1) * 110}%`,
79
+ zIndex: String(100 - index),
80
+ translate: `${isSwiping ? swipe.deltaX : 0}px ${-offset}px`,
81
+ scale: String(scale),
82
+ opacity: index >= VISIBLE_TOASTS ? '0' : isSwiping ? String(1 - Math.min(Math.abs(swipe.deltaX) / 240, 0.6)) : '1',
83
+ height: !expanded.value && index > 0 && frontHeight.value ? `${frontHeight.value}px` : undefined,
84
+ pointerEvents: index >= VISIBLE_TOASTS ? 'none' as const : undefined,
85
+ transition: isSwiping ? 'none' : undefined
86
+ };
87
+ };
88
+
89
+ // Measure toast content heights so the stack can lay itself out
90
+ const resizeObserver = typeof ResizeObserver !== 'undefined'
91
+ ? new ResizeObserver((entries) => {
92
+ for (const entry of entries) {
93
+ const id = Number((entry.target as HTMLElement).dataset.toastId);
94
+ heights.set(id, (entry.target as HTMLElement).offsetHeight);
95
+ }
96
+ })
97
+ : null;
98
+
99
+ // Register toast content elements with the observer
100
+ const observeToast = (element: unknown) => {
101
+ if (element instanceof HTMLElement && resizeObserver) {
102
+ heights.set(Number(element.dataset.toastId), element.offsetHeight);
103
+ resizeObserver.observe(element);
104
+ }
105
+ };
106
+
107
+ // Expand the stack and pause timers while the user interacts with it
108
+ const handleEnter = () => {
109
+ expanded.value = true;
110
+ pauseMessageToasts();
111
+ };
112
+
113
+ // Collapse the stack and resume timers
114
+ const handleLeave = () => {
115
+ expanded.value = false;
116
+ resumeMessageToasts();
117
+ };
118
+
119
+ // Begin a horizontal swipe gesture
120
+ const handlePointerDown = (event: PointerEvent, toast: MessageToastItem) => {
121
+ if (!toast.dismissible || event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
122
+ swipe.id = toast.id;
123
+ swipe.startX = event.clientX;
124
+ swipe.deltaX = 0;
125
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
126
+ };
127
+
128
+ // Follow the pointer while swiping
129
+ const handlePointerMove = (event: PointerEvent) => {
130
+ if (swipe.id === null) return;
131
+ swipe.deltaX = event.clientX - swipe.startX;
132
+ };
133
+
134
+ // Dismiss the toast when swiped far enough, otherwise spring back
135
+ const handlePointerUp = () => {
136
+ if (swipe.id === null) return;
137
+ const id = swipe.id;
138
+ const deltaX = swipe.deltaX;
139
+ swipe.id = null;
140
+ swipe.deltaX = 0;
141
+ if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX) {
142
+ leaveDirection.set(id, Math.sign(deltaX));
143
+ closeMessageToast(id);
144
+ }
145
+ };
146
+
147
+ // Run a toast action and dismiss it
148
+ const handleAction = async (toast: MessageToastItem) => {
149
+ closeMessageToast(toast.id);
150
+ await toast.action?.onClick();
151
+ };
152
+
153
+ // Forget measurements of removed toasts
154
+ const handleAfterLeave = (element: Element) => {
155
+ const id = Number((element as HTMLElement).dataset.toastId);
156
+ heights.delete(id);
157
+ leaveDirection.delete(id);
158
+ };
159
+
160
+ // Reset the interaction state once the stack is empty (mouseleave never fires on a collapsed region)
161
+ watch(() => toasts.value.length, (length) => {
162
+ if (length === 0 && expanded.value) {
163
+ handleLeave();
164
+ }
30
165
  });
31
166
 
32
- // Resolved aria-live politeness based on toast type
33
- const toastAriaLive = computed(() => {
34
- if (toastType.value === 'error' || toastType.value === 'warning') return 'assertive';
35
- return 'polite';
167
+ // Stop observing on unmount
168
+ onBeforeUnmount(() => {
169
+ resizeObserver?.disconnect();
36
170
  });
37
171
  </script>
38
172
 
39
173
  <template>
40
- <Transition name="toast">
41
- <div v-if="messageToastState.current && messageToastState.isOpen" class="fixed inset-x-3 bottom-3 z-60 rounded border border-border bg-card p-4 text-card-foreground transition-colors duration-300 ease-in-out sm:inset-x-auto sm:right-6 sm:bottom-6 sm:max-w-87.5 sm:min-w-62.5" role="alert" :aria-live="toastAriaLive" aria-atomic="true">
42
- <div class="flex items-center gap-4">
43
- <!-- Leading type icon -->
44
- <HugeiconsIcon :icon="toastIcon" class="size-5" :class="toastIconClasses" />
45
-
46
- <!-- Message text -->
47
- <p class="m-0 flex-1 text-sm font-normal leading-5">
48
- {{ currentToast?.message }}
49
- </p>
50
-
51
- <!-- Dismiss button -->
52
- <Button variant="ghost" size="icon-sm" class="-me-1 h-auto min-h-0 w-auto p-0 opacity-70 hover:opacity-100" :aria-label="closeAriaLabel" @click="closeMessageToast">
53
- <HugeiconsIcon :icon="Cancel01Icon" class="size-4" />
54
- </Button>
55
- </div>
56
- </div>
57
- </Transition>
174
+ <section :aria-label="regionAriaLabel" class="pointer-events-none fixed inset-x-3 bottom-3 z-60 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:w-[380px]">
175
+ <ol class="pointer-events-auto relative transition-[height] duration-300 ease-out-expo" :style="{ height: `${containerHeight}px` }" @mouseenter="handleEnter" @mouseleave="handleLeave" @focusin="handleEnter" @focusout="handleLeave">
176
+ <TransitionGroup name="uv-toast" @after-leave="handleAfterLeave">
177
+ <li v-for="(toast, index) in toasts" :key="toast.id" :data-toast-id="toast.id" :data-front="index === 0 ? '' : undefined" role="status" :aria-live="toast.type === 'error' || toast.type === 'warning' ? 'assertive' : 'polite'" aria-atomic="true" class="uv-toast absolute inset-x-0 bottom-0 origin-bottom touch-pan-y select-none overflow-hidden rounded border border-border bg-card text-card-foreground shadow-elevated-lg" :style="getToastStyle(toast, index)" @pointerdown="handlePointerDown($event, toast)" @pointermove="handlePointerMove" @pointerup="handlePointerUp" @pointercancel="handlePointerUp">
178
+ <!-- Measured content -->
179
+ <div :ref="observeToast" :data-toast-id="toast.id" :class="cn('flex items-start gap-3 p-4 transition-opacity duration-200', !expanded && index > 0 && 'opacity-0')">
180
+ <!-- Leading type icon -->
181
+ <div :class="cn('flex size-8 shrink-0 items-center justify-center rounded border', toneClasses[toast.type])">
182
+ <HugeiconsIcon :icon="getToastIcon(toast)" :stroke-width="1.8" class="size-4.5 animate-uv-pop [animation-delay:120ms]" />
183
+ </div>
184
+
185
+ <!-- Title and message -->
186
+ <div class="min-w-0 flex-1 self-center">
187
+ <p v-if="toast.title" class="m-0 text-sm font-semibold leading-5">
188
+ {{ toast.title }}
189
+ </p>
190
+ <p :class="cn('m-0 text-sm leading-5 wrap-break-word', toast.title && 'text-xs text-muted-foreground sm:text-[13px]')">
191
+ {{ toast.message }}
192
+ </p>
193
+ </div>
194
+
195
+ <!-- Action button -->
196
+ <button v-if="toast.action" type="button" class="shrink-0 self-center rounded border border-border bg-secondary px-2.5 py-1.5 text-xs font-semibold shadow-elevated-sm outline-none transition-[background-color,border-color,transform] duration-150 hover:border-border-strong hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/45 active:scale-95" @click="handleAction(toast)">
197
+ {{ toast.action.label }}
198
+ </button>
199
+
200
+ <!-- Dismiss button -->
201
+ <button v-if="toast.dismissible" type="button" :aria-label="closeAriaLabel" class="group/close -me-1 -mt-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/45" @click="closeMessageToast(toast.id)">
202
+ <HugeiconsIcon :icon="Cancel01Icon" class="size-4 transition-transform duration-300 ease-spring group-hover/close:rotate-90" />
203
+ </button>
204
+ </div>
205
+
206
+ <!-- Countdown bar -->
207
+ <div v-if="toast.duration > 0" class="absolute inset-x-0 bottom-0 h-0.5 bg-transparent">
208
+ <div :class="cn('h-full origin-left opacity-60', progressClasses[toast.type])" :style="{ animation: `uv-toast-countdown ${toast.duration}ms linear forwards`, animationPlayState: messageToastState.paused ? 'paused' : 'running' }" />
209
+ </div>
210
+ </li>
211
+ </TransitionGroup>
212
+ </ol>
213
+ </section>
58
214
  </template>
59
215
 
60
- <style scoped>
61
- .toast-enter-active,
62
- .toast-leave-active {
63
- transition: all 0.3s ease-in-out;
216
+ <style>
217
+ .uv-toast {
218
+ transition:
219
+ translate 480ms var(--ease-spring),
220
+ scale 480ms var(--ease-spring),
221
+ opacity 250ms var(--ease-out-expo),
222
+ height 300ms var(--ease-out-expo),
223
+ transform 480ms var(--ease-spring);
224
+ will-change: translate, scale;
225
+ }
226
+
227
+ .uv-toast-enter-from {
228
+ opacity: 0 !important;
229
+ transform: translateY(110%) scale(0.92);
230
+ }
231
+
232
+ .uv-toast-leave-active {
233
+ transition:
234
+ transform 260ms var(--ease-snappy),
235
+ opacity 200ms var(--ease-snappy) !important;
64
236
  }
65
237
 
66
- .toast-enter-from {
67
- transform: translateY(100%);
68
- opacity: 0;
238
+ .uv-toast-leave-to {
239
+ opacity: 0 !important;
240
+ transform: translateX(var(--uv-toast-leave-x, 110%));
69
241
  }
70
242
 
71
- .toast-leave-to {
72
- transform: translateY(8px);
73
- opacity: 0;
243
+ @keyframes uv-toast-countdown {
244
+ from { transform: scaleX(1); }
245
+ to { transform: scaleX(0); }
74
246
  }
75
247
  </style>
@@ -1,2 +1,2 @@
1
- export { closeMessageToast, messageToastState, showMessageToast } from './message-toast-state';
2
- export type { MessageToastType, ShowMessageToastOptions } from './message-toast-state';
1
+ export { closeMessageToast, messageToastState, pauseMessageToasts, resumeMessageToasts, showMessageToast } from './message-toast-state';
2
+ export type { MessageToastAction, MessageToastItem, MessageToastType, ShowMessageToastOptions } from './message-toast-state';
@@ -1,11 +1,32 @@
1
- import { h, reactive, render } from 'vue';
1
+ import { h, markRaw, reactive, render } from 'vue';
2
+ import type { HugeiconsIconDefinition } from '../../lib/common-types';
2
3
 
3
4
  export type MessageToastType = 'success' | 'info' | 'warning' | 'error';
4
5
 
6
+ export interface MessageToastAction {
7
+ label: string;
8
+ onClick: () => void | Promise<void>;
9
+ }
10
+
5
11
  export interface ShowMessageToastOptions {
6
12
  message: string;
7
13
  type?: MessageToastType;
8
14
  duration?: number;
15
+ title?: string;
16
+ icon?: HugeiconsIconDefinition | null;
17
+ action?: MessageToastAction | null;
18
+ dismissible?: boolean;
19
+ }
20
+
21
+ export interface MessageToastItem {
22
+ id: number;
23
+ message: string;
24
+ type: MessageToastType;
25
+ title: string;
26
+ icon: HugeiconsIconDefinition | null;
27
+ action: MessageToastAction | null;
28
+ dismissible: boolean;
29
+ duration: number;
9
30
  }
10
31
 
11
32
  interface MessageToastContent {
@@ -16,25 +37,40 @@ interface MessageToastContent {
16
37
  interface MessageToastState {
17
38
  current: MessageToastContent | null;
18
39
  isOpen: boolean;
40
+ toasts: MessageToastItem[];
41
+ paused: boolean;
42
+ }
43
+
44
+ interface ToastTimer {
45
+ handle: ReturnType<typeof setTimeout> | null;
46
+ remaining: number;
47
+ startedAt: number;
19
48
  }
20
49
 
21
- const TOAST_CLOSE_DURATION_MS = 300;
22
50
  const DEFAULT_TOAST_DURATION_MS = 5000;
51
+ const MAX_TOASTS = 5;
23
52
  const MESSAGE_TOAST_ROOT_ID = 'ui-vintage-message-toast-root';
24
53
 
25
- let dismissTimer: ReturnType<typeof setTimeout> | null = null;
26
- let clearTimer: ReturnType<typeof setTimeout> | null = null;
54
+ const toastTimers = new Map<number, ToastTimer>();
27
55
  let messageToastMountPromise: Promise<void> | null = null;
28
- let openFrame: number | null = null;
29
- let activeToastId = 0;
56
+ let nextToastId = 0;
30
57
 
31
- // Shared message toast state
58
+ // Shared message toast state (current/isOpen are kept for backward compatibility)
32
59
  export const messageToastState: MessageToastState = reactive({
33
60
  current: null,
34
- isOpen: false
61
+ isOpen: false,
62
+ toasts: [],
63
+ paused: false
35
64
  });
36
65
 
37
- // Mount the toast once on demand
66
+ // Keep the legacy single-toast fields in sync with the stack
67
+ const syncLegacyState = () => {
68
+ const latest = messageToastState.toasts[0];
69
+ messageToastState.current = latest ? { message: latest.message, type: latest.type } : null;
70
+ messageToastState.isOpen = Boolean(latest);
71
+ };
72
+
73
+ // Mount the toaster once on demand
38
74
  const ensureMessageToastMounted = () => {
39
75
  // Skip mounting during SSR
40
76
  if (typeof document === 'undefined') {
@@ -42,8 +78,7 @@ const ensureMessageToastMounted = () => {
42
78
  }
43
79
 
44
80
  // Reuse the existing mount root
45
- const existingRoot = document.getElementById(MESSAGE_TOAST_ROOT_ID);
46
- if (existingRoot) {
81
+ if (document.getElementById(MESSAGE_TOAST_ROOT_ID)) {
47
82
  return Promise.resolve();
48
83
  }
49
84
 
@@ -75,93 +110,84 @@ const ensureMessageToastMounted = () => {
75
110
  return messageToastMountPromise;
76
111
  };
77
112
 
78
- // Clear active timers before updating toast state
79
- const clearMessageToastTimers = () => {
80
- // Clear any pending auto-dismiss timer
81
- if (dismissTimer) {
82
- clearTimeout(dismissTimer);
83
- dismissTimer = null;
84
- }
85
-
86
- // Clear any pending clear timer from a previous toast
87
- if (clearTimer) {
88
- clearTimeout(clearTimer);
89
- clearTimer = null;
90
- }
91
-
92
- // Clear any pending open frame from a previous toast
93
- if (openFrame !== null && typeof cancelAnimationFrame !== 'undefined') {
94
- cancelAnimationFrame(openFrame);
95
- openFrame = null;
96
- }
113
+ // Start (or restart) the auto-dismiss countdown of a toast
114
+ const startToastTimer = (id: number) => {
115
+ const timer = toastTimers.get(id);
116
+ if (!timer || timer.handle || messageToastState.paused) return;
117
+ timer.startedAt = Date.now();
118
+ timer.handle = setTimeout(() => closeMessageToast(id), timer.remaining);
97
119
  };
98
120
 
99
- // Clear the current toast after the leave transition
100
- const clearCurrentMessageToast = () => {
101
- messageToastState.current = null;
121
+ // Stop the countdown of a toast and remember how much time is left
122
+ const stopToastTimer = (id: number) => {
123
+ const timer = toastTimers.get(id);
124
+ if (!timer?.handle) return;
125
+ clearTimeout(timer.handle);
126
+ timer.handle = null;
127
+ timer.remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
102
128
  };
103
129
 
104
- // Close the toast after the leave transition
105
- const scheduleCloseMessageToast = () => {
106
- // Skip if there's no active toast
107
- if (!messageToastState.current || !messageToastState.isOpen) {
108
- return;
109
- }
130
+ // Pause every countdown (used while the toaster is hovered or focused)
131
+ export const pauseMessageToasts = () => {
132
+ messageToastState.paused = true;
133
+ toastTimers.forEach((_, id) => stopToastTimer(id));
134
+ };
110
135
 
111
- // Start the close transition
112
- messageToastState.isOpen = false;
113
- clearTimer = setTimeout(() => {
114
- clearTimer = null;
115
- clearCurrentMessageToast();
116
- }, TOAST_CLOSE_DURATION_MS);
136
+ // Resume every countdown
137
+ export const resumeMessageToasts = () => {
138
+ messageToastState.paused = false;
139
+ toastTimers.forEach((_, id) => startToastTimer(id));
117
140
  };
118
141
 
119
- // Dismiss the active toast
120
- export const closeMessageToast = () => {
121
- clearMessageToastTimers();
122
- scheduleCloseMessageToast();
142
+ // Dismiss one toast by id, or every toast when no id is passed
143
+ export const closeMessageToast = (id?: number) => {
144
+ const ids = id === undefined ? messageToastState.toasts.map(toast => toast.id) : [id];
145
+
146
+ // Clear timers and remove the toasts from the stack
147
+ for (const toastId of ids) {
148
+ stopToastTimer(toastId);
149
+ toastTimers.delete(toastId);
150
+ }
151
+ messageToastState.toasts = messageToastState.toasts.filter(toast => !ids.includes(toast.id));
152
+ syncLegacyState();
123
153
  };
124
154
 
125
- // Show a message toast and optionally auto-dismiss it
155
+ // Show a message toast and optionally auto-dismiss it; returns the toast id
126
156
  export const showMessageToast = (options: ShowMessageToastOptions) => {
127
- clearMessageToastTimers();
128
- activeToastId += 1;
129
- const toastId = activeToastId;
157
+ nextToastId += 1;
158
+ const id = nextToastId;
130
159
  const duration = options.duration ?? DEFAULT_TOAST_DURATION_MS;
131
160
 
132
- // Update the toast state while keeping the shell closed until the renderer is mounted
133
- messageToastState.current = {
161
+ // Build the toast item
162
+ const toast: MessageToastItem = {
163
+ id,
134
164
  message: options.message,
135
- type: options.type ?? 'success'
165
+ type: options.type ?? 'success',
166
+ title: options.title ?? '',
167
+ icon: options.icon ? markRaw(options.icon) : null,
168
+ action: options.action ?? null,
169
+ dismissible: options.dismissible ?? true,
170
+ duration
136
171
  };
137
- messageToastState.isOpen = false;
138
172
 
139
- // Mount first so the initial visible transition can run after the component exists
173
+ // Expose the newest toast synchronously, as the single-toast API always did
174
+ messageToastState.current = { message: toast.message, type: toast.type };
175
+
176
+ // Mount first so the enter transition runs once the renderer exists
140
177
  void ensureMessageToastMounted().then(() => {
141
- // Guard against a toast change during the async mount
142
- if (toastId !== activeToastId || !messageToastState.current || typeof requestAnimationFrame === 'undefined') {
143
- return;
178
+ // Push the newest toast on top and trim the overflow
179
+ messageToastState.toasts = [toast, ...messageToastState.toasts];
180
+ for (const overflow of messageToastState.toasts.slice(MAX_TOASTS)) {
181
+ closeMessageToast(overflow.id);
144
182
  }
183
+ syncLegacyState();
145
184
 
146
- // Use requestAnimationFrame to ensure the DOM updates before starting the open transition
147
- openFrame = requestAnimationFrame(() => {
148
- openFrame = null;
149
-
150
- // Guard against a toast change before the frame
151
- if (toastId !== activeToastId || !messageToastState.current) {
152
- return;
153
- }
154
-
155
- // Start the open transition after the renderer is present
156
- messageToastState.isOpen = true;
157
-
158
- // Auto-dismiss the toast after the specified duration
159
- if (duration > 0) {
160
- dismissTimer = setTimeout(() => {
161
- dismissTimer = null;
162
- scheduleCloseMessageToast();
163
- }, duration);
164
- }
165
- });
185
+ // Auto-dismiss the toast after the specified duration
186
+ if (duration > 0) {
187
+ toastTimers.set(id, { handle: null, remaining: duration, startedAt: Date.now() });
188
+ startToastTimer(id);
189
+ }
166
190
  });
191
+
192
+ return id;
167
193
  };
@@ -27,7 +27,7 @@ const hasSlotContent = computed(() => {
27
27
 
28
28
  // Get size classes based on size prop
29
29
  const getSizeClasses = (size: string) => {
30
- const baseClasses = 'font-bold text-(--text-primary-light) dark:text-(--text-primary-dark)';
30
+ const baseClasses = 'font-bold text-foreground';
31
31
 
32
32
  // Return classes based on size
33
33
  switch (size) {
@@ -19,12 +19,12 @@ const props = withDefaults(defineProps<{
19
19
  <!-- Header with icon and label -->
20
20
  <div class="flex items-center justify-between">
21
21
  <!-- Title of the progress bar -->
22
- <span class="text-sm font-semibold text-(--text-primary-light) dark:text-(--text-primary-dark)">
22
+ <span class="text-sm font-semibold text-foreground">
23
23
  {{ props.title }}
24
24
  </span>
25
25
 
26
26
  <!-- Current value / max -->
27
- <span class="text-sm font-semibold text-(--text-primary-light) dark:text-(--text-primary-dark)">
27
+ <span class="text-sm font-semibold text-foreground">
28
28
  {{ props.value }} / {{ props.max }}
29
29
  </span>
30
30
  </div>
@@ -35,12 +35,12 @@ const props = withDefaults(defineProps<{
35
35
  <!-- Bottom labels -->
36
36
  <div v-if="props.bottomLeftLabel || props.bottomRightLabel" class="flex items-center justify-between text-xs">
37
37
  <!-- Bottom left label -->
38
- <span v-if="props.bottomLeftLabel" class="text-(--text-secondary-light) dark:text-(--text-secondary-dark)">
38
+ <span v-if="props.bottomLeftLabel" class="text-muted-foreground">
39
39
  {{ props.bottomLeftLabel }}
40
40
  </span>
41
41
 
42
42
  <!-- Bottom right label -->
43
- <span v-if="props.bottomRightLabel" class="text-(--text-secondary-light) dark:text-(--text-secondary-dark)">
43
+ <span v-if="props.bottomRightLabel" class="text-muted-foreground">
44
44
  {{ props.bottomRightLabel }}
45
45
  </span>
46
46
  </div>