@brumaombra/ui-vintage 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +41 -0
- package/COMPONENTS.md +89 -18
- package/README.md +296 -262
- package/module.mjs +5 -11
- package/package.json +30 -12
- package/src/components/blog/BlogContentRenderer.vue +1 -1
- package/src/components/blog/BlogFAQSection.vue +1 -1
- package/src/components/blog/BlogHeaderSection.vue +93 -19
- package/src/components/blog/BlogInfoSection.vue +42 -37
- package/src/components/blog/BlogPostHeader.vue +181 -0
- package/src/components/blog/BlogProgressBar.vue +3 -5
- package/src/components/blog/BlogSectionTitle.vue +8 -6
- package/src/components/blog/BlogTagsSection.vue +16 -7
- package/src/components/blog/CategoryCard.vue +19 -23
- package/src/components/blog/HeaderCarousel.vue +17 -7
- package/src/components/blog/PostCard.vue +14 -21
- package/src/components/blog/SocialShareSidebar.vue +32 -15
- package/src/components/blog/TableOfContents.vue +64 -29
- package/src/components/blog/index.ts +1 -0
- package/src/components/card-grid/CardGrid.vue +3 -3
- package/src/components/chip/Chip.vue +1 -1
- package/src/components/confirm-dialog/ConfirmDialog.vue +6 -1
- package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
- package/src/components/content/BlogList.vue +29 -52
- package/src/components/content/BlogTable.vue +26 -30
- package/src/components/content/CodeBlock.vue +99 -23
- package/src/components/content/Flow.vue +29 -35
- package/src/components/content/ProseHr.vue +6 -6
- package/src/components/content/ProsePre.vue +9 -4
- package/src/components/content/Terminal.vue +45 -21
- package/src/components/cookie-consent/CookieConsent.vue +1 -1
- package/src/components/dashboard-shell/DashboardShell.vue +24 -7
- package/src/components/empty-state-card/EmptyStateCard.vue +3 -3
- package/src/components/error-page/ErrorPage.vue +4 -4
- package/src/components/info-card/InfoCard.vue +4 -4
- package/src/components/landing/landing-footer/LandingFooter.vue +17 -12
- package/src/components/landing/landing-navbar/LandingNavbar.vue +22 -6
- package/src/components/landing/landing-shell/LandingShell.vue +2 -7
- package/src/components/language-flag/index.ts +1 -0
- package/src/components/language-selector/LanguageSelector.vue +19 -11
- package/src/components/load-more-button/LoadMoreButton.vue +9 -13
- package/src/components/loading-state-card/LoadingStateCard.vue +3 -3
- package/src/components/message-dialog/MessageDialog.vue +14 -12
- package/src/components/message-dialog/message-dialog-state.ts +1 -4
- package/src/components/message-toast/MessageToast.vue +28 -10
- package/src/components/message-toast/message-toast-state.ts +20 -0
- package/src/components/page-header/PageHeader.vue +1 -1
- package/src/components/progress-component/ProgressComponent.vue +7 -3
- package/src/components/single-value-card/SingleValueCard.vue +39 -40
- package/src/components/slider-form-component/SliderFormComponent.vue +1 -1
- package/src/components/stat-strip/StatStrip.vue +36 -0
- package/src/components/stat-strip/index.ts +2 -0
- package/src/components/switch-form-component/SwitchFormComponent.vue +5 -5
- package/src/components/text-link/TextLink.vue +1 -1
- package/src/components/theme-selector/ThemeSelector.vue +6 -3
- package/src/components/theme-selector/theme-transition.ts +15 -3
- package/src/components/ui/accordion/Accordion.vue +19 -19
- package/src/components/ui/alert/AlertTitle.vue +1 -1
- package/src/components/ui/alert/index.ts +1 -1
- package/src/components/ui/avatar/AvatarFallback.vue +2 -1
- package/src/components/ui/avatar/AvatarGroup.vue +13 -3
- package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +3 -3
- package/src/components/ui/busy/Busy.vue +5 -7
- package/src/components/ui/button/Button.vue +4 -2
- package/src/components/ui/button/index.ts +1 -4
- package/src/components/ui/calendar/Calendar.vue +12 -7
- package/src/components/ui/calendar/CalendarCell.vue +1 -1
- package/src/components/ui/calendar/CalendarCellTrigger.vue +1 -1
- package/src/components/ui/calendar/CalendarHeadCell.vue +1 -1
- package/src/components/ui/calendar/CalendarNextButton.vue +2 -2
- package/src/components/ui/calendar/CalendarPrevButton.vue +2 -2
- package/src/components/ui/card/Card.vue +5 -15
- package/src/components/ui/checkbox/Checkbox.vue +5 -1
- package/src/components/ui/combobox/ComboboxInput.vue +5 -1
- package/src/components/ui/combobox/ComboboxItem.vue +1 -1
- package/src/components/ui/combobox/ComboboxSelect.vue +2 -1
- package/src/components/ui/combobox/ComboboxTrigger.vue +1 -1
- package/src/components/ui/command/CommandGroup.vue +17 -3
- package/src/components/ui/command/CommandInput.vue +2 -2
- package/src/components/ui/command/CommandList.vue +5 -1
- package/src/components/ui/data-table/DataTable.vue +13 -4
- package/src/components/ui/date-picker/DatePicker.vue +8 -2
- package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
- package/src/components/ui/dialog/DialogContent.vue +1 -1
- package/src/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/src/components/ui/dropdown-menu/classes.ts +2 -0
- package/src/components/ui/field/Field.vue +4 -0
- package/src/components/ui/field/FieldLabel.vue +6 -2
- package/src/components/ui/field/field-context.ts +62 -0
- package/src/components/ui/field/index.ts +1 -0
- package/src/components/ui/file-dropzone/FileDropzone.vue +14 -5
- package/src/components/ui/input/Input.vue +5 -1
- package/src/components/ui/native-select/NativeSelect.vue +7 -3
- package/src/components/ui/number-field/NumberField.vue +7 -2
- package/src/components/ui/pagination/PaginationEllipsis.vue +1 -1
- package/src/components/ui/pagination/PaginationFirst.vue +1 -1
- package/src/components/ui/pagination/PaginationItem.vue +1 -1
- package/src/components/ui/pagination/PaginationLast.vue +1 -1
- package/src/components/ui/pagination/PaginationNext.vue +1 -1
- package/src/components/ui/pagination/PaginationPrevious.vue +1 -1
- package/src/components/ui/pin-input/PinInputInput.vue +2 -2
- package/src/components/ui/progress/Progress.vue +2 -2
- package/src/components/ui/radio-group/RadioGroupCard.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/SelectScrollDownButton.vue +1 -1
- package/src/components/ui/select/SelectScrollUpButton.vue +1 -1
- package/src/components/ui/select/SelectTrigger.vue +6 -2
- package/src/components/ui/sidebar/Sidebar.vue +1 -1
- package/src/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/src/components/ui/sidebar/SidebarGroupLabel.vue +1 -1
- package/src/components/ui/sidebar/SidebarInset.vue +1 -1
- package/src/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/src/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +2 -2
- package/src/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/src/components/ui/sidebar/SidebarProvider.vue +17 -6
- package/src/components/ui/sidebar/SidebarTrigger.vue +1 -1
- package/src/components/ui/sidebar/index.ts +2 -2
- package/src/components/ui/sidebar/utils.ts +1 -1
- package/src/components/ui/slider/Slider.vue +5 -4
- package/src/components/ui/spinner/Spinner.vue +3 -2
- package/src/components/ui/stepper/SimpleStepper.vue +1 -1
- package/src/components/ui/stepper/StepperDescription.vue +5 -3
- package/src/components/ui/stepper/StepperIndicator.vue +3 -3
- package/src/components/ui/stepper/StepperTitle.vue +5 -3
- package/src/components/ui/switch/Switch.vue +6 -2
- package/src/components/ui/tabs/TabsList.vue +1 -1
- package/src/components/ui/tabs/TabsTrigger.vue +1 -1
- package/src/components/ui/tags-input/TagsInputItemDelete.vue +1 -1
- package/src/components/ui/textarea/Textarea.vue +5 -1
- package/src/components/ui/time-picker/TimePicker.vue +2 -2
- package/src/components/ui/toggle-group/ToggleGroup.vue +1 -1
- package/src/components/ui/tooltip/TooltipContent.vue +1 -1
- package/src/i18n/de.json +16 -6
- package/src/i18n/en.json +16 -6
- package/src/i18n/es.json +16 -6
- package/src/i18n/fr.json +16 -6
- package/src/i18n/it.json +16 -6
- package/src/i18n/ja.json +16 -6
- package/src/i18n/pt.json +16 -6
- package/src/i18n/ru.json +16 -6
- package/src/i18n/zh.json +16 -6
- package/src/lib/color-tokens.ts +1 -1
- package/src/lib/i18n.ts +32 -4
- package/src/lib/utils.ts +21 -1
- package/src/runtime/ui-vintage-i18n-plugin.ts +11 -1
- package/src/styles.css +12 -16
- package/skills/ui-vintage/SKILL.md +0 -42
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
3
3
|
import { computed } from 'vue';
|
|
4
|
-
import
|
|
4
|
+
import AlertCircleIcon from '@hugeicons/core-free-icons/AlertCircleIcon';
|
|
5
|
+
import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
|
|
6
|
+
import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
|
|
5
7
|
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '../ui/alert-dialog';
|
|
6
8
|
import { Button } from '../ui/button';
|
|
7
9
|
import { getUiVintageRuntimeMessage } from '../../lib/i18n';
|
|
@@ -36,21 +38,21 @@ const defaultMessageIcon = computed(() => {
|
|
|
36
38
|
// Resolved container classes for the message icon
|
|
37
39
|
const messageIconClasses = computed(() => {
|
|
38
40
|
const current = currentDialog.value;
|
|
39
|
-
if (!current) return 'border-
|
|
40
|
-
if (current.options.type === 'error') return 'border-
|
|
41
|
-
if (current.options.type === 'warning') return 'border-
|
|
42
|
-
if (current.options.type === 'success') return 'border-
|
|
43
|
-
return 'border-
|
|
41
|
+
if (!current) return 'border-border bg-surface';
|
|
42
|
+
if (current.options.type === 'error') return 'border-destructive/25 bg-destructive/10';
|
|
43
|
+
if (current.options.type === 'warning') return 'border-warning/25 bg-warning/10';
|
|
44
|
+
if (current.options.type === 'success') return 'border-success/25 bg-success/10';
|
|
45
|
+
return 'border-border bg-surface';
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
// Resolved glyph color classes
|
|
47
49
|
const messageGlyphClasses = computed(() => {
|
|
48
50
|
const current = currentDialog.value;
|
|
49
|
-
if (!current) return 'text-
|
|
50
|
-
if (current.options.type === 'error') return 'text-
|
|
51
|
-
if (current.options.type === 'warning') return 'text-
|
|
52
|
-
if (current.options.type === 'success') return 'text-
|
|
53
|
-
return 'text-
|
|
51
|
+
if (!current) return 'text-info';
|
|
52
|
+
if (current.options.type === 'error') return 'text-destructive';
|
|
53
|
+
if (current.options.type === 'warning') return 'text-warning';
|
|
54
|
+
if (current.options.type === 'success') return 'text-success';
|
|
55
|
+
return 'text-info';
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
// Close the dialog when the shell requests it
|
|
@@ -91,7 +93,7 @@ const handleOpenChange = (open: boolean) => {
|
|
|
91
93
|
|
|
92
94
|
<!-- Dialog actions -->
|
|
93
95
|
<AlertDialogFooter class="items-end">
|
|
94
|
-
<Button variant="secondary"
|
|
96
|
+
<Button variant="secondary" @click="resolveActiveMessageDialog">
|
|
95
97
|
<HugeiconsIcon v-if="currentDialog.options.closeButtonIcon" :icon="currentDialog.options.closeButtonIcon" class="size-4" />
|
|
96
98
|
{{ resolvedCloseText }}
|
|
97
99
|
</Button>
|
|
@@ -24,7 +24,6 @@ interface MessageDialogRequest {
|
|
|
24
24
|
|
|
25
25
|
interface MessageDialogState {
|
|
26
26
|
current: MessageDialogRequest | null;
|
|
27
|
-
busy: boolean;
|
|
28
27
|
isOpen: boolean;
|
|
29
28
|
}
|
|
30
29
|
|
|
@@ -39,7 +38,6 @@ const MESSAGE_DIALOG_ROOT_ID = 'ui-vintage-message-dialog-root';
|
|
|
39
38
|
// Shared message dialog state
|
|
40
39
|
export const messageDialogState: MessageDialogState = reactive({
|
|
41
40
|
current: null,
|
|
42
|
-
busy: false,
|
|
43
41
|
isOpen: false
|
|
44
42
|
});
|
|
45
43
|
|
|
@@ -104,7 +102,6 @@ const enqueueMessageDialog = (request: MessageDialogRequest) => {
|
|
|
104
102
|
|
|
105
103
|
// Clear the active dialog and continue the queue
|
|
106
104
|
const clearCurrentMessageDialog = () => {
|
|
107
|
-
messageDialogState.busy = false;
|
|
108
105
|
messageDialogState.isOpen = false;
|
|
109
106
|
messageDialogState.current = null;
|
|
110
107
|
openNextMessageDialog();
|
|
@@ -141,7 +138,7 @@ export const closeMessageDialog = () => {
|
|
|
141
138
|
// Resolve the active message dialog
|
|
142
139
|
export const resolveActiveMessageDialog = () => {
|
|
143
140
|
const current = messageDialogState.current;
|
|
144
|
-
if (!current || messageDialogState.
|
|
141
|
+
if (!current || !messageDialogState.isOpen) return;
|
|
145
142
|
current.resolve();
|
|
146
143
|
scheduleCloseCurrentMessageDialog();
|
|
147
144
|
};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import AlertCircleIcon from '@hugeicons/core-free-icons/AlertCircleIcon';
|
|
3
|
+
import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
|
|
4
|
+
import CheckmarkCircle02Icon from '@hugeicons/core-free-icons/CheckmarkCircle02Icon';
|
|
5
|
+
import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
|
|
3
6
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
4
7
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
|
5
8
|
import { getUiVintageRuntimeMessage } from '../../lib/i18n';
|
|
@@ -30,18 +33,18 @@ const getToastIcon = (toast: MessageToastItem) => {
|
|
|
30
33
|
|
|
31
34
|
// Resolve the tinted icon tile classes for each toast type
|
|
32
35
|
const toneClasses: Record<MessageToastType, string> = {
|
|
33
|
-
success: 'border-
|
|
34
|
-
info: 'border-
|
|
35
|
-
warning: 'border-
|
|
36
|
-
error: 'border-
|
|
36
|
+
success: 'border-success/25 bg-success/10 text-success',
|
|
37
|
+
info: 'border-info/25 bg-info/10 text-info',
|
|
38
|
+
warning: 'border-warning/25 bg-warning/10 text-warning',
|
|
39
|
+
error: 'border-destructive/25 bg-destructive/10 text-destructive'
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
// Progress bar color for each toast type
|
|
40
43
|
const progressClasses: Record<MessageToastType, string> = {
|
|
41
|
-
success: 'bg-
|
|
42
|
-
info: 'bg-
|
|
43
|
-
warning: 'bg-
|
|
44
|
-
error: 'bg-
|
|
44
|
+
success: 'bg-success',
|
|
45
|
+
info: 'bg-info',
|
|
46
|
+
warning: 'bg-warning',
|
|
47
|
+
error: 'bg-destructive'
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
const toasts = computed(() => messageToastState.toasts);
|
|
@@ -86,11 +89,17 @@ const getToastStyle = (toast: MessageToastItem, index: number) => {
|
|
|
86
89
|
};
|
|
87
90
|
};
|
|
88
91
|
|
|
92
|
+
// Toast content elements currently watched by the observer, by toast id
|
|
93
|
+
const observedElements = new Map<number, HTMLElement>();
|
|
94
|
+
|
|
89
95
|
// Measure toast content heights so the stack can lay itself out
|
|
90
96
|
const resizeObserver = typeof ResizeObserver !== 'undefined'
|
|
91
97
|
? new ResizeObserver((entries) => {
|
|
92
98
|
for (const entry of entries) {
|
|
93
99
|
const id = Number((entry.target as HTMLElement).dataset.toastId);
|
|
100
|
+
|
|
101
|
+
// Skip late notifications for toasts that already left
|
|
102
|
+
if (observedElements.get(id) !== entry.target) continue;
|
|
94
103
|
heights.set(id, (entry.target as HTMLElement).offsetHeight);
|
|
95
104
|
}
|
|
96
105
|
})
|
|
@@ -99,7 +108,10 @@ const resizeObserver = typeof ResizeObserver !== 'undefined'
|
|
|
99
108
|
// Register toast content elements with the observer
|
|
100
109
|
const observeToast = (element: unknown) => {
|
|
101
110
|
if (element instanceof HTMLElement && resizeObserver) {
|
|
102
|
-
|
|
111
|
+
const id = Number(element.dataset.toastId);
|
|
112
|
+
if (observedElements.get(id) === element) return;
|
|
113
|
+
heights.set(id, element.offsetHeight);
|
|
114
|
+
observedElements.set(id, element);
|
|
103
115
|
resizeObserver.observe(element);
|
|
104
116
|
}
|
|
105
117
|
};
|
|
@@ -153,6 +165,11 @@ const handleAction = async (toast: MessageToastItem) => {
|
|
|
153
165
|
// Forget measurements of removed toasts
|
|
154
166
|
const handleAfterLeave = (element: Element) => {
|
|
155
167
|
const id = Number((element as HTMLElement).dataset.toastId);
|
|
168
|
+
const content = observedElements.get(id);
|
|
169
|
+
if (content) {
|
|
170
|
+
resizeObserver?.unobserve(content);
|
|
171
|
+
observedElements.delete(id);
|
|
172
|
+
}
|
|
156
173
|
heights.delete(id);
|
|
157
174
|
leaveDirection.delete(id);
|
|
158
175
|
};
|
|
@@ -167,6 +184,7 @@ watch(() => toasts.value.length, (length) => {
|
|
|
167
184
|
// Stop observing on unmount
|
|
168
185
|
onBeforeUnmount(() => {
|
|
169
186
|
resizeObserver?.disconnect();
|
|
187
|
+
observedElements.clear();
|
|
170
188
|
});
|
|
171
189
|
</script>
|
|
172
190
|
|
|
@@ -52,6 +52,7 @@ const MAX_TOASTS = 5;
|
|
|
52
52
|
const MESSAGE_TOAST_ROOT_ID = 'ui-vintage-message-toast-root';
|
|
53
53
|
|
|
54
54
|
const toastTimers = new Map<number, ToastTimer>();
|
|
55
|
+
const pendingToastIds = new Set<number>();
|
|
55
56
|
let messageToastMountPromise: Promise<void> | null = null;
|
|
56
57
|
let nextToastId = 0;
|
|
57
58
|
|
|
@@ -143,6 +144,13 @@ export const resumeMessageToasts = () => {
|
|
|
143
144
|
export const closeMessageToast = (id?: number) => {
|
|
144
145
|
const ids = id === undefined ? messageToastState.toasts.map(toast => toast.id) : [id];
|
|
145
146
|
|
|
147
|
+
// Cancel toasts that were requested but are still waiting for the toaster to mount
|
|
148
|
+
if (id === undefined) {
|
|
149
|
+
pendingToastIds.clear();
|
|
150
|
+
} else {
|
|
151
|
+
pendingToastIds.delete(id);
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
// Clear timers and remove the toasts from the stack
|
|
147
155
|
for (const toastId of ids) {
|
|
148
156
|
stopToastTimer(toastId);
|
|
@@ -170,11 +178,23 @@ export const showMessageToast = (options: ShowMessageToastOptions) => {
|
|
|
170
178
|
duration
|
|
171
179
|
};
|
|
172
180
|
|
|
181
|
+
// Toasts are client-only: never store them in the module state shared across server requests
|
|
182
|
+
if (typeof document === 'undefined') {
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
|
|
173
186
|
// Expose the newest toast synchronously, as the single-toast API always did
|
|
174
187
|
messageToastState.current = { message: toast.message, type: toast.type };
|
|
175
188
|
|
|
176
189
|
// Mount first so the enter transition runs once the renderer exists
|
|
190
|
+
pendingToastIds.add(id);
|
|
177
191
|
void ensureMessageToastMounted().then(() => {
|
|
192
|
+
// Skip toasts that were closed before the toaster finished mounting
|
|
193
|
+
if (!pendingToastIds.delete(id)) {
|
|
194
|
+
syncLegacyState();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
178
198
|
// Push the newest toast on top and trim the overflow
|
|
179
199
|
messageToastState.toasts = [toast, ...messageToastState.toasts];
|
|
180
200
|
for (const overflow of messageToastState.toasts.slice(MAX_TOASTS)) {
|
|
@@ -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-
|
|
30
|
+
const baseClasses = 'font-semibold tracking-tight text-foreground';
|
|
31
31
|
|
|
32
32
|
// Return classes based on size
|
|
33
33
|
switch (size) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { useId } from 'vue';
|
|
2
3
|
import { Progress } from '../ui/progress';
|
|
3
4
|
|
|
4
5
|
// Props
|
|
@@ -12,14 +13,17 @@ const props = withDefaults(defineProps<{
|
|
|
12
13
|
bottomLeftLabel: '',
|
|
13
14
|
bottomRightLabel: ''
|
|
14
15
|
});
|
|
16
|
+
|
|
17
|
+
// Name the progress bar after the visible title
|
|
18
|
+
const titleId = useId();
|
|
15
19
|
</script>
|
|
16
20
|
|
|
17
21
|
<template>
|
|
18
22
|
<div class="space-y-3">
|
|
19
|
-
<!-- Header with
|
|
23
|
+
<!-- Header with label and value -->
|
|
20
24
|
<div class="flex items-center justify-between">
|
|
21
25
|
<!-- Title of the progress bar -->
|
|
22
|
-
<span class="text-sm font-semibold text-foreground">
|
|
26
|
+
<span :id="titleId" class="text-sm font-semibold text-foreground">
|
|
23
27
|
{{ props.title }}
|
|
24
28
|
</span>
|
|
25
29
|
|
|
@@ -30,7 +34,7 @@ const props = withDefaults(defineProps<{
|
|
|
30
34
|
</div>
|
|
31
35
|
|
|
32
36
|
<!-- Progress bar -->
|
|
33
|
-
<Progress :model-value="props.value" :max="props.max" />
|
|
37
|
+
<Progress :model-value="props.value" :max="props.max" :aria-labelledby="titleId" />
|
|
34
38
|
|
|
35
39
|
<!-- Bottom labels -->
|
|
36
40
|
<div v-if="props.bottomLeftLabel || props.bottomRightLabel" class="flex items-center justify-between text-xs">
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import type { HTMLAttributes } from 'vue';
|
|
4
|
-
import
|
|
4
|
+
import ArrowDownRight01Icon from '@hugeicons/core-free-icons/ArrowDownRight01Icon';
|
|
5
|
+
import ArrowUpRight01Icon from '@hugeicons/core-free-icons/ArrowUpRight01Icon';
|
|
5
6
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
7
|
import { AnimatedNumber } from '../ui/animated-number';
|
|
7
|
-
import { Card
|
|
8
|
+
import { Card } from '../ui/card';
|
|
8
9
|
import { cn } from '../../lib/utils';
|
|
9
10
|
import type { HugeiconsIconDefinition } from '../../lib/common-types';
|
|
10
11
|
|
|
@@ -33,11 +34,11 @@ const props = withDefaults(defineProps<{
|
|
|
33
34
|
const valueClass = computed(() => {
|
|
34
35
|
switch (props.valueColor) {
|
|
35
36
|
case 'green':
|
|
36
|
-
return 'text-
|
|
37
|
+
return 'text-success';
|
|
37
38
|
case 'red':
|
|
38
|
-
return 'text-
|
|
39
|
+
return 'text-destructive';
|
|
39
40
|
case 'gray':
|
|
40
|
-
return 'text-
|
|
41
|
+
return 'text-muted-foreground';
|
|
41
42
|
default:
|
|
42
43
|
return 'text-foreground';
|
|
43
44
|
}
|
|
@@ -46,45 +47,43 @@ const valueClass = computed(() => {
|
|
|
46
47
|
// Trend badge presentation
|
|
47
48
|
const trendIsPositive = computed(() => (props.trend ?? 0) >= 0);
|
|
48
49
|
const formattedTrend = computed(() => `${trendIsPositive.value ? '+' : ''}${props.trend}%`);
|
|
50
|
+
|
|
51
|
+
// Value shown without the count-up (numbers are formatted only when formatOptions is given)
|
|
52
|
+
const staticValue = computed(() => typeof props.value === 'number' && props.formatOptions ? new Intl.NumberFormat(undefined, props.formatOptions).format(props.value) : props.value);
|
|
49
53
|
</script>
|
|
50
54
|
|
|
51
55
|
<template>
|
|
52
|
-
<Card :class="cn('
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
</
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<span class="mb-1 truncate text-xs font-semibold text-muted-foreground">
|
|
63
|
-
{{ props.label }}
|
|
64
|
-
</span>
|
|
56
|
+
<Card :class="cn('gap-0! p-4!', props.class)">
|
|
57
|
+
<!-- Label and icon -->
|
|
58
|
+
<div class="flex items-start justify-between gap-3">
|
|
59
|
+
<span class="min-w-0 text-[11px] font-semibold leading-5 tracking-wider text-muted-foreground uppercase">
|
|
60
|
+
{{ props.label }}
|
|
61
|
+
</span>
|
|
62
|
+
<span aria-hidden="true" class="flex size-8 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
|
|
63
|
+
<HugeiconsIcon :icon="props.icon" class="size-4" />
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
<!-- Value and trend -->
|
|
68
|
+
<div class="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
69
|
+
<span :class="['text-2xl font-semibold tracking-tight tabular-nums', valueClass]">
|
|
70
|
+
<AnimatedNumber v-if="props.animate && typeof props.value === 'number'" :value="props.value" :format-options="props.formatOptions" />
|
|
71
|
+
<template v-else>{{ staticValue }}</template>
|
|
72
|
+
</span>
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
<!-- Trend badge -->
|
|
75
|
+
<span v-if="props.trend !== null" :class="cn('inline-flex items-center gap-0.5 rounded-sm px-1 py-0.5 text-[11px] font-semibold tabular-nums', trendIsPositive ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive')">
|
|
76
|
+
<HugeiconsIcon :icon="trendIsPositive ? ArrowUpRight01Icon : ArrowDownRight01Icon" class="size-3" />
|
|
77
|
+
{{ formattedTrend }}
|
|
78
|
+
</span>
|
|
79
|
+
<span v-if="props.trend !== null && props.trendLabel" class="text-[11px] text-muted-foreground">
|
|
80
|
+
{{ props.trendLabel }}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
</div>
|
|
88
|
-
</CardContent>
|
|
84
|
+
<!-- Description -->
|
|
85
|
+
<span v-if="props.description" class="mt-1 text-xs leading-5 text-muted-foreground">
|
|
86
|
+
{{ props.description }}
|
|
87
|
+
</span>
|
|
89
88
|
</Card>
|
|
90
|
-
</template>
|
|
89
|
+
</template>
|
|
@@ -57,6 +57,6 @@ const handleUpdateModelValue = (value?: number[]) => {
|
|
|
57
57
|
</div>
|
|
58
58
|
|
|
59
59
|
<!-- Slider control -->
|
|
60
|
-
<Slider :id="props.id" :model-value="sliderValue" :min="props.min" :max="props.max" :step="props.step" @update:model-value="handleUpdateModelValue" />
|
|
60
|
+
<Slider :id="props.id" :model-value="sliderValue" :min="props.min" :max="props.max" :step="props.step" :thumb-label="props.label" @update:model-value="handleUpdateModelValue" />
|
|
61
61
|
</Card>
|
|
62
62
|
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue';
|
|
3
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
4
|
+
import { AnimatedNumber } from '../ui/animated-number';
|
|
5
|
+
import { cn } from '../../lib/utils';
|
|
6
|
+
import type { HugeiconsIconDefinition } from '../../lib/common-types';
|
|
7
|
+
|
|
8
|
+
// Props
|
|
9
|
+
const props = withDefaults(defineProps<{
|
|
10
|
+
items: Array<{
|
|
11
|
+
label: string;
|
|
12
|
+
value: string | number;
|
|
13
|
+
icon?: HugeiconsIconDefinition;
|
|
14
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
15
|
+
}>;
|
|
16
|
+
animate?: boolean;
|
|
17
|
+
class?: HTMLAttributes['class'];
|
|
18
|
+
}>(), {
|
|
19
|
+
animate: true
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<!-- Compact row of stats in one bordered strip -->
|
|
25
|
+
<ul v-if="props.items.length > 0" data-slot="stat-strip" :class="cn('inline-flex max-w-full flex-wrap items-stretch overflow-hidden rounded border border-border bg-card text-xs shadow-elevated-sm', props.class)">
|
|
26
|
+
<li v-for="(item, index) in props.items" :key="item.label" :class="['flex items-center gap-2 px-4 py-2.5', index > 0 && 'border-l border-border']">
|
|
27
|
+
<HugeiconsIcon v-if="item.icon" :icon="item.icon" class="size-4 shrink-0 text-primary" />
|
|
28
|
+
<span class="font-semibold text-foreground tabular-nums">
|
|
29
|
+
<AnimatedNumber v-if="props.animate && typeof item.value === 'number'" :value="item.value" :format-options="item.formatOptions" />
|
|
30
|
+
<template v-else-if="typeof item.value === 'number'">{{ new Intl.NumberFormat(undefined, item.formatOptions).format(item.value) }}</template>
|
|
31
|
+
<template v-else>{{ item.value }}</template>
|
|
32
|
+
</span>
|
|
33
|
+
<span class="text-muted-foreground">{{ item.label }}</span>
|
|
34
|
+
</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</template>
|
|
@@ -20,17 +20,17 @@ const emits = defineEmits<{
|
|
|
20
20
|
<Card class="flex flex-col gap-2 px-4 py-3 sm:gap-3!">
|
|
21
21
|
<!-- Switch label and control -->
|
|
22
22
|
<div class="flex items-center justify-between gap-4">
|
|
23
|
-
<!-- Switch label -->
|
|
24
|
-
<
|
|
23
|
+
<!-- Switch label (names the switch and toggles it on click) -->
|
|
24
|
+
<label :for="props.id" class="cursor-pointer text-sm font-semibold">
|
|
25
25
|
{{ props.label }}
|
|
26
|
-
</
|
|
26
|
+
</label>
|
|
27
27
|
|
|
28
28
|
<!-- Switch control -->
|
|
29
|
-
<Switch :id="props.id" :model-value="props.modelValue" @update:model-value="emits('update:modelValue', $event)" />
|
|
29
|
+
<Switch :id="props.id" :aria-describedby="`${props.id}-description`" :model-value="props.modelValue" @update:model-value="emits('update:modelValue', $event)" />
|
|
30
30
|
</div>
|
|
31
31
|
|
|
32
32
|
<!-- Description -->
|
|
33
|
-
<div class="text-xs text-muted-foreground">
|
|
33
|
+
<div :id="`${props.id}-description`" class="text-xs text-muted-foreground">
|
|
34
34
|
{{ props.description }}
|
|
35
35
|
</div>
|
|
36
36
|
</Card>
|
|
@@ -31,7 +31,7 @@ const handleClick = (event: MouseEvent) => {
|
|
|
31
31
|
</script>
|
|
32
32
|
|
|
33
33
|
<template>
|
|
34
|
-
<component :is="props.linkComponent" v-bind="getLinkProps()" class="cursor-pointer font-semibold text-
|
|
34
|
+
<component :is="props.linkComponent" v-bind="getLinkProps()" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
|
|
35
35
|
{{ props.text }}
|
|
36
36
|
</component>
|
|
37
37
|
</template>
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import
|
|
4
|
+
import LaptopIcon from '@hugeicons/core-free-icons/LaptopIcon';
|
|
5
|
+
import Moon01Icon from '@hugeicons/core-free-icons/Moon01Icon';
|
|
6
|
+
import Sun01Icon from '@hugeicons/core-free-icons/Sun01Icon';
|
|
7
|
+
import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
|
|
5
8
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
9
|
import { Button } from '../ui/button';
|
|
7
10
|
import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command';
|
|
@@ -98,8 +101,8 @@ onUnmounted(() => {
|
|
|
98
101
|
<!-- Theme options -->
|
|
99
102
|
<PopoverContent side="bottom" align="end" :side-offset="8" class="w-44 p-0!">
|
|
100
103
|
<Command :model-value="currentTheme">
|
|
101
|
-
<CommandList>
|
|
102
|
-
<CommandGroup :heading="t('uiVintage.theme.title')">
|
|
104
|
+
<CommandList :aria-label="t('uiVintage.theme.title')">
|
|
105
|
+
<CommandGroup :heading="t('uiVintage.theme.title')" stagger>
|
|
103
106
|
<CommandItem v-for="option in themeOptions" :key="option.key" :value="option.key" @select="handleSelectTheme(option.key)">
|
|
104
107
|
<HugeiconsIcon :icon="option.icon" class="size-4" />
|
|
105
108
|
<span class="flex-1">{{ option.label }}</span>
|
|
@@ -27,8 +27,14 @@ export const isDarkTheme = (theme: ThemeMode) => {
|
|
|
27
27
|
// Read the persisted theme mode
|
|
28
28
|
export const getStoredTheme = (): ThemeMode => {
|
|
29
29
|
if (typeof localStorage === 'undefined') return 'auto';
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
|
|
31
|
+
// Storage can throw when the browser blocks it (privacy settings, sandboxed frames)
|
|
32
|
+
try {
|
|
33
|
+
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
|
34
|
+
return storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'auto';
|
|
35
|
+
} catch {
|
|
36
|
+
return 'auto';
|
|
37
|
+
}
|
|
32
38
|
};
|
|
33
39
|
|
|
34
40
|
// Apply the selected theme to the document and persist it
|
|
@@ -37,7 +43,13 @@ export const applyTheme = (theme: ThemeMode) => {
|
|
|
37
43
|
const root = document.documentElement;
|
|
38
44
|
root.classList.remove('light', 'dark');
|
|
39
45
|
root.classList.add(isDarkTheme(theme) ? 'dark' : 'light');
|
|
40
|
-
|
|
46
|
+
|
|
47
|
+
// Persisting is best effort: the theme still applies when storage is blocked
|
|
48
|
+
try {
|
|
49
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
50
|
+
} catch {
|
|
51
|
+
// Ignore storage errors
|
|
52
|
+
}
|
|
41
53
|
};
|
|
42
54
|
|
|
43
55
|
// Apply a theme with a circular reveal that grows from the origin point (View Transitions API)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { HTMLAttributes } from 'vue';
|
|
3
3
|
import { computed, ref, useId } from 'vue';
|
|
4
|
-
import
|
|
4
|
+
import ArrowDown01Icon from '@hugeicons/core-free-icons/ArrowDown01Icon';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
6
|
import { Card } from '../card';
|
|
7
7
|
import { cn } from '../../../lib/utils';
|
|
@@ -26,6 +26,7 @@ const emits = defineEmits<{
|
|
|
26
26
|
}>();
|
|
27
27
|
|
|
28
28
|
const contentId = useId();
|
|
29
|
+
const triggerId = useId();
|
|
29
30
|
const internalExpanded = ref(props.initiallyExpanded);
|
|
30
31
|
|
|
31
32
|
// Support both controlled (v-model:open) and uncontrolled usage
|
|
@@ -40,34 +41,33 @@ const toggleExpanded = () => {
|
|
|
40
41
|
</script>
|
|
41
42
|
|
|
42
43
|
<template>
|
|
43
|
-
<Card :data-state="expanded ? 'open' : 'closed'" :class="cn('group/accordion gap-0! overflow-hidden
|
|
44
|
+
<Card :data-state="expanded ? 'open' : 'closed'" :class="cn('group/accordion gap-0! overflow-hidden p-0!', props.class)">
|
|
44
45
|
<!-- Header -->
|
|
45
|
-
<button type="button" :aria-expanded="expanded" :aria-controls="contentId" class="group/trigger flex w-full cursor-pointer items-center
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
</div>
|
|
46
|
+
<button :id="triggerId" type="button" :aria-expanded="expanded" :aria-controls="contentId" class="group/trigger flex w-full cursor-pointer items-center gap-3 px-5 py-3.5 text-left outline-none focus-visible:bg-surface focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/40" @click="toggleExpanded">
|
|
47
|
+
<!-- Icon -->
|
|
48
|
+
<span v-if="props.icon" class="flex size-7 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
|
|
49
|
+
<HugeiconsIcon :icon="props.icon" :class="cn('size-3.5', props.iconClass)" />
|
|
50
|
+
</span>
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
<!-- Title -->
|
|
53
|
+
<span class="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
|
54
|
+
{{ props.title }}
|
|
55
|
+
</span>
|
|
56
|
+
|
|
57
|
+
<!-- Optional content before the toggle icon -->
|
|
58
|
+
<slot name="trailing" />
|
|
57
59
|
|
|
58
60
|
<!-- Toggle icon -->
|
|
59
|
-
<
|
|
60
|
-
<HugeiconsIcon :icon="ArrowDown01Icon" class="size-4 text-muted-foreground transition-colors duration-200 group-hover/trigger:text-primary group-data-[state=open]/accordion:text-primary" />
|
|
61
|
-
</div>
|
|
61
|
+
<HugeiconsIcon :icon="ArrowDown01Icon" class="size-4 shrink-0 text-muted-foreground transition-[rotate,color] duration-[420ms] ease-spring group-hover/trigger:text-primary group-data-[state=open]/accordion:rotate-180 group-data-[state=open]/accordion:text-primary" />
|
|
62
62
|
</button>
|
|
63
63
|
|
|
64
64
|
<!-- Content -->
|
|
65
|
-
<div :id="contentId" role="region" :aria-hidden="!expanded" :inert="!expanded || undefined" :class="['grid transition-[grid-template-rows,opacity] duration-[380ms] ease-out-expo', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0']">
|
|
65
|
+
<div :id="contentId" role="region" :aria-labelledby="triggerId" :aria-hidden="!expanded" :inert="!expanded || undefined" :class="['grid transition-[grid-template-rows,opacity] duration-[380ms] ease-out-expo', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0']">
|
|
66
66
|
<div class="overflow-hidden">
|
|
67
|
-
<div :class="['px-5
|
|
67
|
+
<div :class="['border-t border-border px-5 py-4 transition-[translate,filter] duration-[420ms] ease-spring', expanded ? 'translate-y-0 blur-none' : '-translate-y-2 blur-[2px]']">
|
|
68
68
|
<slot />
|
|
69
69
|
</div>
|
|
70
70
|
</div>
|
|
71
71
|
</div>
|
|
72
72
|
</Card>
|
|
73
|
-
</template>
|
|
73
|
+
</template>
|
|
@@ -9,7 +9,7 @@ const props = defineProps<{
|
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
|
-
<div data-slot="alert-title" :class="cn('col-start-2 line-clamp-1 min-h-4 font-
|
|
12
|
+
<div data-slot="alert-title" :class="cn('col-start-2 line-clamp-1 min-h-4 font-semibold tracking-tight text-current', props.class)">
|
|
13
13
|
<slot />
|
|
14
14
|
</div>
|
|
15
15
|
</template>
|
|
@@ -11,5 +11,5 @@ export type AlertColors = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export const alertClasses: (props?: ClassProp) => string = cva(
|
|
14
|
-
'relative w-full rounded px-3 py-2 text-xs transition-colors duration-300 ease-in-out grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current sm:px-4 sm:py-3 sm:text-sm',
|
|
14
|
+
'relative w-full rounded px-3 py-2 text-xs transition-colors duration-300 ease-in-out grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 content-start items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current sm:px-4 sm:py-3 sm:text-sm',
|
|
15
15
|
);
|
|
@@ -25,7 +25,8 @@ const initials = computed(() => getInitials(props.name));
|
|
|
25
25
|
</script>
|
|
26
26
|
|
|
27
27
|
<template>
|
|
28
|
-
|
|
28
|
+
<!-- With a name the initials act as an image of the person, so screen readers announce the name instead of the letters -->
|
|
29
|
+
<AvatarFallback data-slot="avatar-fallback" v-bind="delegatedProps" :role="props.name ? 'img' : undefined" :aria-label="props.name || undefined" :class="cn('flex size-full items-center justify-center bg-surface font-semibold uppercase text-muted-foreground', textClass, shapeClass, props.class)">
|
|
29
30
|
<slot>{{ initials }}</slot>
|
|
30
31
|
</AvatarFallback>
|
|
31
32
|
</template>
|