@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.
- package/COMPONENTS.md +3602 -0
- package/README.md +49 -9
- package/package.json +29 -8
- package/skills/ui-vintage/SKILL.md +42 -0
- package/src/components/blog/BlogFAQSection.vue +2 -2
- package/src/components/blog/BlogHeaderSection.vue +2 -2
- package/src/components/blog/BlogInfoSection.vue +3 -3
- package/src/components/blog/BlogProgressBar.vue +7 -6
- package/src/components/blog/BlogSectionTitle.vue +1 -1
- package/src/components/blog/BlogTagsSection.vue +22 -0
- package/src/components/blog/CategoryCard.vue +1 -1
- package/src/components/blog/HeaderCarousel.vue +6 -6
- package/src/components/blog/PostCard.vue +2 -2
- package/src/components/blog/SocialShareSidebar.vue +1 -1
- package/src/components/blog/TableOfContents.vue +6 -6
- package/src/components/blog/index.ts +1 -0
- package/src/components/busy-indicator/busy-indicator-state.ts +3 -2
- package/src/components/chip/Chip.vue +9 -5
- package/src/components/confirm-dialog/ConfirmDialog.vue +17 -6
- package/src/components/confirm-dialog/confirm-dialog-state.ts +4 -3
- package/src/components/content/BlogList.vue +3 -3
- package/src/components/content/BlogTable.vue +7 -7
- package/src/components/content/CodeBlock.vue +4 -4
- package/src/components/content/Flow.vue +3 -3
- package/src/components/content/ProseHr.vue +2 -2
- package/src/components/content/Terminal.vue +4 -4
- package/src/components/dashboard-shell/DashboardShell.vue +3 -3
- package/src/components/empty-state-card/EmptyStateCard.vue +4 -4
- package/src/components/error-page/ErrorPage.vue +16 -14
- package/src/components/info-card/InfoCard.vue +1 -1
- package/src/components/landing/landing-navbar/LandingNavbar.vue +2 -1
- package/src/components/load-more-button/LoadMoreButton.vue +2 -2
- package/src/components/loading-state-card/LoadingStateCard.vue +4 -4
- package/src/components/message-dialog/MessageDialog.vue +4 -4
- package/src/components/message-toast/MessageToast.vue +222 -50
- package/src/components/message-toast/index.ts +2 -2
- package/src/components/message-toast/message-toast-state.ts +107 -81
- package/src/components/page-header/PageHeader.vue +1 -1
- package/src/components/progress-component/ProgressComponent.vue +4 -4
- package/src/components/single-value-card/SingleValueCard.vue +47 -11
- package/src/components/theme-selector/ThemeSelector.vue +20 -36
- package/src/components/theme-selector/index.ts +3 -1
- package/src/components/theme-selector/theme-transition.ts +86 -0
- package/src/components/ui/accordion/Accordion.vue +41 -26
- package/src/components/ui/alert-dialog/AlertDialogContent.vue +2 -2
- package/src/components/ui/alert-dialog/AlertDialogFooter.vue +1 -1
- package/src/components/ui/animated-number/AnimatedNumber.vue +88 -0
- package/src/components/ui/animated-number/index.ts +1 -0
- package/src/components/ui/avatar/Avatar.vue +66 -0
- package/src/components/ui/avatar/AvatarFallback.vue +31 -0
- package/src/components/ui/avatar/AvatarGroup.vue +75 -0
- package/src/components/ui/avatar/AvatarImage.vue +23 -0
- package/src/components/ui/avatar/index.ts +47 -0
- package/src/components/ui/badge/Badge.vue +13 -2
- package/src/components/ui/breadcrumb/Breadcrumb.vue +4 -1
- package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +7 -1
- package/src/components/ui/busy/Busy.vue +45 -20
- package/src/components/ui/button/Button.vue +20 -3
- package/src/components/ui/button/index.ts +12 -2
- package/src/components/ui/card/Card.vue +21 -8
- package/src/components/ui/card/CardText.vue +1 -1
- package/src/components/ui/checkbox/Checkbox.vue +48 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/collapsible/Collapsible.vue +22 -0
- package/src/components/ui/collapsible/CollapsibleContent.vue +22 -0
- package/src/components/ui/collapsible/CollapsibleTrigger.vue +19 -0
- package/src/components/ui/collapsible/index.ts +3 -0
- package/src/components/ui/combobox/Combobox.vue +24 -0
- package/src/components/ui/combobox/ComboboxAnchor.vue +39 -0
- package/src/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/src/components/ui/combobox/ComboboxGroup.vue +26 -0
- package/src/components/ui/combobox/ComboboxInput.vue +28 -0
- package/src/components/ui/combobox/ComboboxItem.vue +39 -0
- package/src/components/ui/combobox/ComboboxList.vue +43 -0
- package/src/components/ui/combobox/ComboboxSelect.vue +162 -0
- package/src/components/ui/combobox/ComboboxSeparator.vue +16 -0
- package/src/components/ui/combobox/ComboboxTrigger.vue +28 -0
- package/src/components/ui/combobox/index.ts +11 -0
- package/src/components/ui/combobox/types.ts +16 -0
- package/src/components/ui/command/CommandDialog.vue +18 -16
- package/src/components/ui/command/CommandGroup.vue +1 -1
- package/src/components/ui/command/CommandInput.vue +2 -2
- package/src/components/ui/command/CommandItem.vue +7 -5
- package/src/components/ui/data-table/DataTable.vue +230 -0
- package/src/components/ui/data-table/index.ts +20 -0
- package/src/components/ui/date-picker/DatePicker.vue +11 -15
- package/src/components/ui/date-time-picker/DateTimePicker.vue +2 -7
- package/src/components/ui/dialog/DialogContent.vue +9 -4
- package/src/components/ui/dialog/DialogOverlay.vue +1 -1
- package/src/components/ui/dialog/DialogScrollContent.vue +10 -5
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +18 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +34 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +13 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +34 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +26 -0
- package/src/components/ui/dropdown-menu/DropdownMenuPortal.vue +13 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +18 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +32 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +16 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +15 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +18 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +39 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +36 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +15 -0
- package/src/components/ui/dropdown-menu/classes.ts +24 -0
- package/src/components/ui/dropdown-menu/index.ts +15 -0
- package/src/components/ui/field/FieldDescription.vue +1 -1
- package/src/components/ui/field/FieldError.vue +4 -1
- package/src/components/ui/file-dropzone/FileDropzone.vue +250 -0
- package/src/components/ui/file-dropzone/index.ts +35 -0
- package/src/components/ui/hover-card/HoverCard.vue +21 -0
- package/src/components/ui/hover-card/HoverCardContent.vue +33 -0
- package/src/components/ui/hover-card/HoverCardTrigger.vue +13 -0
- package/src/components/ui/hover-card/index.ts +3 -0
- package/src/components/ui/input/Input.vue +5 -5
- package/src/components/ui/kbd/Kbd.vue +21 -0
- package/src/components/ui/kbd/KbdGroup.vue +72 -0
- package/src/components/ui/kbd/index.ts +2 -0
- package/src/components/ui/number-field/NumberField.vue +130 -0
- package/src/components/ui/number-field/index.ts +1 -0
- package/src/components/ui/pagination/Pagination.vue +27 -0
- package/src/components/ui/pagination/PaginationContent.vue +20 -0
- package/src/components/ui/pagination/PaginationEllipsis.vue +31 -0
- package/src/components/ui/pagination/PaginationFirst.vue +32 -0
- package/src/components/ui/pagination/PaginationItem.vue +41 -0
- package/src/components/ui/pagination/PaginationLast.vue +32 -0
- package/src/components/ui/pagination/PaginationNext.vue +32 -0
- package/src/components/ui/pagination/PaginationPrevious.vue +32 -0
- package/src/components/ui/pagination/SimplePagination.vue +49 -0
- package/src/components/ui/pagination/index.ts +9 -0
- package/src/components/ui/pin-input/PinInput.vue +48 -0
- package/src/components/ui/pin-input/PinInputInput.vue +49 -0
- package/src/components/ui/pin-input/context.ts +8 -0
- package/src/components/ui/pin-input/index.ts +2 -0
- package/src/components/ui/popover/PopoverContent.vue +1 -1
- package/src/components/ui/progress/Progress.vue +22 -7
- package/src/components/ui/radio-group/RadioGroup.vue +27 -0
- package/src/components/ui/radio-group/RadioGroupCard.vue +56 -0
- package/src/components/ui/radio-group/RadioGroupItem.vue +40 -0
- package/src/components/ui/radio-group/index.ts +3 -0
- package/src/components/ui/select/SelectContent.vue +1 -1
- package/src/components/ui/select/SelectItem.vue +2 -2
- package/src/components/ui/select/SelectItemContent.vue +1 -1
- package/src/components/ui/select/SelectTrigger.vue +6 -6
- package/src/components/ui/select/SelectValueContent.vue +1 -1
- package/src/components/ui/sheet/SheetContent.vue +5 -5
- package/src/components/ui/sheet/SheetOverlay.vue +1 -1
- package/src/components/ui/sidebar/Sidebar.vue +7 -5
- package/src/components/ui/sidebar/SidebarRail.vue +3 -1
- package/src/components/ui/sidebar/SidebarTrigger.vue +3 -1
- package/src/components/ui/skeleton/Skeleton.vue +4 -4
- package/src/components/ui/slider/Slider.vue +8 -6
- package/src/components/ui/spinner/Spinner.vue +47 -0
- package/src/components/ui/spinner/index.ts +1 -0
- package/src/components/ui/stepper/SimpleStepper.vue +56 -0
- package/src/components/ui/stepper/Stepper.vue +30 -0
- package/src/components/ui/stepper/StepperDescription.vue +20 -0
- package/src/components/ui/stepper/StepperIndicator.vue +41 -0
- package/src/components/ui/stepper/StepperItem.vue +21 -0
- package/src/components/ui/stepper/StepperSeparator.vue +34 -0
- package/src/components/ui/stepper/StepperTitle.vue +20 -0
- package/src/components/ui/stepper/StepperTrigger.vue +20 -0
- package/src/components/ui/stepper/index.ts +17 -0
- package/src/components/ui/switch/Switch.vue +8 -6
- package/src/components/ui/tabs/TabsContent.vue +1 -1
- package/src/components/ui/tabs/TabsList.vue +13 -5
- package/src/components/ui/tabs/TabsTrigger.vue +5 -3
- package/src/components/ui/tags-input/TagsInput.vue +49 -0
- package/src/components/ui/tags-input/TagsInputInput.vue +23 -0
- package/src/components/ui/tags-input/TagsInputItem.vue +35 -0
- package/src/components/ui/tags-input/TagsInputItemDelete.vue +31 -0
- package/src/components/ui/tags-input/TagsInputItemText.vue +21 -0
- package/src/components/ui/tags-input/index.ts +5 -0
- package/src/components/ui/textarea/Textarea.vue +4 -5
- package/src/components/ui/toggle-group/Toggle.vue +33 -0
- package/src/components/ui/toggle-group/ToggleGroup.vue +93 -0
- package/src/components/ui/toggle-group/ToggleGroupItem.vue +34 -0
- package/src/components/ui/toggle-group/index.ts +69 -0
- package/src/components/ui/tooltip/TooltipContent.vue +1 -1
- package/src/i18n/de.json +76 -0
- package/src/i18n/en.json +76 -0
- package/src/i18n/es.json +76 -0
- package/src/i18n/fr.json +76 -0
- package/src/i18n/it.json +76 -0
- package/src/i18n/ja.json +76 -0
- package/src/i18n/pt.json +76 -0
- package/src/i18n/ru.json +76 -0
- package/src/i18n/zh.json +76 -0
- package/src/lib/color-tokens.ts +14 -14
- 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 {
|
|
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
|
-
|
|
10
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
16
|
-
const
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return 'polite';
|
|
167
|
+
// Stop observing on unmount
|
|
168
|
+
onBeforeUnmount(() => {
|
|
169
|
+
resizeObserver?.disconnect();
|
|
36
170
|
});
|
|
37
171
|
</script>
|
|
38
172
|
|
|
39
173
|
<template>
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
.toast
|
|
62
|
-
|
|
63
|
-
|
|
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-
|
|
67
|
-
|
|
68
|
-
|
|
238
|
+
.uv-toast-leave-to {
|
|
239
|
+
opacity: 0 !important;
|
|
240
|
+
transform: translateX(var(--uv-toast-leave-x, 110%));
|
|
69
241
|
}
|
|
70
242
|
|
|
71
|
-
|
|
72
|
-
transform:
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
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
|
-
//
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
120
|
-
export const closeMessageToast = () => {
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
const toastId = activeToastId;
|
|
157
|
+
nextToastId += 1;
|
|
158
|
+
const id = nextToastId;
|
|
130
159
|
const duration = options.duration ?? DEFAULT_TOAST_DURATION_MS;
|
|
131
160
|
|
|
132
|
-
//
|
|
133
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
43
|
+
<span v-if="props.bottomRightLabel" class="text-muted-foreground">
|
|
44
44
|
{{ props.bottomRightLabel }}
|
|
45
45
|
</span>
|
|
46
46
|
</div>
|