@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,7 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { ArrowDownRight01Icon, ArrowUpRight01Icon } from '@hugeicons/core-free-icons';
|
|
3
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
|
+
import { AnimatedNumber } from '../ui/animated-number';
|
|
4
7
|
import { Card, CardContent } from '../ui/card';
|
|
8
|
+
import { cn } from '../../lib/utils';
|
|
5
9
|
import type { HugeiconsIconDefinition } from '../../lib/common-types';
|
|
6
10
|
|
|
7
11
|
// Props
|
|
@@ -10,8 +14,19 @@ const props = withDefaults(defineProps<{
|
|
|
10
14
|
label: string;
|
|
11
15
|
icon: HugeiconsIconDefinition;
|
|
12
16
|
valueColor?: 'default' | 'green' | 'red' | 'gray';
|
|
17
|
+
trend?: number | null;
|
|
18
|
+
trendLabel?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
animate?: boolean;
|
|
21
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
22
|
+
class?: HTMLAttributes['class'];
|
|
13
23
|
}>(), {
|
|
14
|
-
valueColor: 'default'
|
|
24
|
+
valueColor: 'default',
|
|
25
|
+
trend: null,
|
|
26
|
+
trendLabel: '',
|
|
27
|
+
description: '',
|
|
28
|
+
animate: true,
|
|
29
|
+
formatOptions: undefined
|
|
15
30
|
});
|
|
16
31
|
|
|
17
32
|
// Value classes
|
|
@@ -24,29 +39,50 @@ const valueClass = computed(() => {
|
|
|
24
39
|
case 'gray':
|
|
25
40
|
return 'text-gray-600 dark:text-gray-400';
|
|
26
41
|
default:
|
|
27
|
-
return 'text-
|
|
42
|
+
return 'text-foreground';
|
|
28
43
|
}
|
|
29
44
|
});
|
|
45
|
+
|
|
46
|
+
// Trend badge presentation
|
|
47
|
+
const trendIsPositive = computed(() => (props.trend ?? 0) >= 0);
|
|
48
|
+
const formattedTrend = computed(() => `${trendIsPositive.value ? '+' : ''}${props.trend}%`);
|
|
30
49
|
</script>
|
|
31
50
|
|
|
32
51
|
<template>
|
|
33
|
-
<Card class="flex-row items-center p-4!">
|
|
34
|
-
<CardContent class="flex-row items-center gap-0 p-0!">
|
|
52
|
+
<Card :class="cn('group/stat flex-row items-center p-4!', props.class)">
|
|
53
|
+
<CardContent class="w-full flex-row items-center gap-0 p-0!">
|
|
35
54
|
<!-- Icon -->
|
|
36
|
-
<Card color="gray" class="mr-4 flex
|
|
37
|
-
<HugeiconsIcon :icon="props.icon" class="size-5" />
|
|
55
|
+
<Card color="gray" class="mr-4 flex size-12 shrink-0 items-center justify-center p-0! transition-[rotate,scale,border-color] duration-500 ease-bounce group-hover/stat:-rotate-6 group-hover/stat:scale-105 group-hover/stat:border-primary/50">
|
|
56
|
+
<HugeiconsIcon :icon="props.icon" class="size-5 transition-colors duration-300 group-hover/stat:text-primary" />
|
|
38
57
|
</Card>
|
|
39
58
|
|
|
40
59
|
<!-- Content -->
|
|
41
|
-
<div class="flex flex-col">
|
|
60
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
42
61
|
<!-- Label -->
|
|
43
|
-
<span class="mb-1 text-xs font-semibold text-
|
|
62
|
+
<span class="mb-1 truncate text-xs font-semibold text-muted-foreground">
|
|
44
63
|
{{ props.label }}
|
|
45
64
|
</span>
|
|
46
65
|
|
|
47
|
-
<!-- Value -->
|
|
48
|
-
<
|
|
49
|
-
|
|
66
|
+
<!-- Value and trend -->
|
|
67
|
+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
68
|
+
<span :class="['text-2xl font-bold md:text-xl', valueClass]">
|
|
69
|
+
<AnimatedNumber v-if="props.animate && typeof props.value === 'number'" :value="props.value" :format-options="props.formatOptions" />
|
|
70
|
+
<template v-else>{{ props.value }}</template>
|
|
71
|
+
</span>
|
|
72
|
+
|
|
73
|
+
<!-- Trend badge -->
|
|
74
|
+
<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-bold', trendIsPositive ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-red-500/10 text-red-600 dark:text-red-400')">
|
|
75
|
+
<HugeiconsIcon :icon="trendIsPositive ? ArrowUpRight01Icon : ArrowDownRight01Icon" class="size-3" />
|
|
76
|
+
{{ formattedTrend }}
|
|
77
|
+
</span>
|
|
78
|
+
<span v-if="props.trend !== null && props.trendLabel" class="text-[11px] text-muted-foreground">
|
|
79
|
+
{{ props.trendLabel }}
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- Description -->
|
|
84
|
+
<span v-if="props.description" class="mt-1 text-xs text-muted-foreground">
|
|
85
|
+
{{ props.description }}
|
|
50
86
|
</span>
|
|
51
87
|
</div>
|
|
52
88
|
</CardContent>
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
4
|
import { LaptopIcon, Moon01Icon, Sun01Icon, Tick02Icon } from '@hugeicons/core-free-icons';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
6
|
import { Button } from '../ui/button';
|
|
7
7
|
import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command';
|
|
8
8
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
9
|
+
import { applyTheme, applyThemeWithTransition, getStoredTheme, type ThemeMode } from './theme-transition';
|
|
9
10
|
|
|
10
11
|
const { t } = useI18n();
|
|
11
12
|
const isOpen = ref(false);
|
|
12
|
-
const currentTheme = ref('auto');
|
|
13
|
+
const currentTheme = ref<ThemeMode>('auto');
|
|
14
|
+
const triggerRef = ref<InstanceType<typeof Button> | null>(null);
|
|
13
15
|
let mediaQueryList: MediaQueryList | undefined;
|
|
14
16
|
let handleSystemThemeChange: (() => void) | undefined;
|
|
15
17
|
|
|
@@ -26,40 +28,20 @@ const getThemeIcon = (theme: string) => {
|
|
|
26
28
|
|
|
27
29
|
// Available themes
|
|
28
30
|
const themes = computed(() => [
|
|
29
|
-
{ id: 'light', label: t('uiVintage.theme.light') },
|
|
30
|
-
{ id: 'dark', label: t('uiVintage.theme.dark') },
|
|
31
|
-
{ id: 'auto', label: t('uiVintage.theme.auto') }
|
|
31
|
+
{ id: 'light' as const, label: t('uiVintage.theme.light') },
|
|
32
|
+
{ id: 'dark' as const, label: t('uiVintage.theme.dark') },
|
|
33
|
+
{ id: 'auto' as const, label: t('uiVintage.theme.auto') }
|
|
32
34
|
]);
|
|
33
35
|
|
|
34
|
-
// Check if system prefers dark mode
|
|
35
|
-
const isSystemDarkPreferred = () => {
|
|
36
|
-
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
// Apply the selected theme to the document
|
|
40
|
-
const applyTheme = (theme: string) => {
|
|
41
|
-
document.documentElement.classList.remove('light', 'dark');
|
|
42
|
-
|
|
43
|
-
// If auto is selected, apply the theme based on system preference
|
|
44
|
-
if (theme === 'auto') {
|
|
45
|
-
if (isSystemDarkPreferred()) {
|
|
46
|
-
document.documentElement.classList.add('dark');
|
|
47
|
-
} else {
|
|
48
|
-
document.documentElement.classList.add('light');
|
|
49
|
-
}
|
|
50
|
-
} else {
|
|
51
|
-
document.documentElement.classList.add(theme);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Persist the selected theme in localStorage
|
|
55
|
-
localStorage.setItem('theme', theme);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
36
|
// Handle theme selection from the menu
|
|
59
|
-
const handleSelectTheme = (theme:
|
|
37
|
+
const handleSelectTheme = async (theme: ThemeMode) => {
|
|
60
38
|
currentTheme.value = theme;
|
|
61
|
-
applyTheme(theme);
|
|
62
39
|
isOpen.value = false;
|
|
40
|
+
|
|
41
|
+
// Reveal the new theme from the trigger button once the menu has closed
|
|
42
|
+
await nextTick();
|
|
43
|
+
const triggerElement = (triggerRef.value as { $el?: Element } | null)?.$el ?? null;
|
|
44
|
+
await applyThemeWithTransition(theme, triggerElement);
|
|
63
45
|
};
|
|
64
46
|
|
|
65
47
|
// Compute the icon for the currently selected theme
|
|
@@ -78,7 +60,7 @@ const themeOptions = computed(() => {
|
|
|
78
60
|
|
|
79
61
|
// On component mounted
|
|
80
62
|
onMounted(() => {
|
|
81
|
-
const savedTheme =
|
|
63
|
+
const savedTheme = getStoredTheme();
|
|
82
64
|
currentTheme.value = savedTheme;
|
|
83
65
|
applyTheme(savedTheme);
|
|
84
66
|
mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
|
@@ -86,7 +68,7 @@ onMounted(() => {
|
|
|
86
68
|
// Handler to update the theme
|
|
87
69
|
handleSystemThemeChange = () => {
|
|
88
70
|
if (currentTheme.value === 'auto') {
|
|
89
|
-
|
|
71
|
+
void applyThemeWithTransition('auto');
|
|
90
72
|
}
|
|
91
73
|
};
|
|
92
74
|
|
|
@@ -106,8 +88,10 @@ onUnmounted(() => {
|
|
|
106
88
|
<Popover v-model:open="isOpen">
|
|
107
89
|
<!-- Trigger button showing the current theme icon -->
|
|
108
90
|
<PopoverTrigger as-child>
|
|
109
|
-
<Button variant="secondary" size="icon" :aria-label="t('uiVintage.theme.ariaLabel')">
|
|
110
|
-
<
|
|
91
|
+
<Button ref="triggerRef" variant="secondary" size="icon" :aria-label="t('uiVintage.theme.ariaLabel')">
|
|
92
|
+
<Transition mode="out-in" enter-active-class="transition-[rotate,scale,opacity] duration-[420ms] ease-bounce" enter-from-class="-rotate-90 scale-50 opacity-0" leave-active-class="transition-[rotate,scale,opacity] duration-150 ease-snappy" leave-to-class="rotate-90 scale-50 opacity-0">
|
|
93
|
+
<HugeiconsIcon :key="currentTheme" :icon="currentThemeIcon" class="h-5 w-5" />
|
|
94
|
+
</Transition>
|
|
111
95
|
</Button>
|
|
112
96
|
</PopoverTrigger>
|
|
113
97
|
|
|
@@ -119,7 +103,7 @@ onUnmounted(() => {
|
|
|
119
103
|
<CommandItem v-for="option in themeOptions" :key="option.key" :value="option.key" @select="handleSelectTheme(option.key)">
|
|
120
104
|
<HugeiconsIcon :icon="option.icon" class="size-4" />
|
|
121
105
|
<span class="flex-1">{{ option.label }}</span>
|
|
122
|
-
<HugeiconsIcon :icon="Tick02Icon" class="ml-auto size-4 shrink-0" :class="currentTheme === option.key ? 'opacity-100' : 'opacity-0'" />
|
|
106
|
+
<HugeiconsIcon :icon="Tick02Icon" class="ml-auto size-4 shrink-0 text-primary! transition-[opacity,scale] duration-300 ease-bounce" :class="currentTheme === option.key ? 'scale-100 opacity-100' : 'scale-50 opacity-0'" />
|
|
123
107
|
</CommandItem>
|
|
124
108
|
</CommandGroup>
|
|
125
109
|
</CommandList>
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { default } from './ThemeSelector.vue';
|
|
2
|
-
export { default as ThemeSelector } from './ThemeSelector.vue';
|
|
2
|
+
export { default as ThemeSelector } from './ThemeSelector.vue';
|
|
3
|
+
export { applyTheme, applyThemeWithTransition, getStoredTheme, isDarkTheme } from './theme-transition';
|
|
4
|
+
export type { ThemeMode, ThemeTransitionOrigin } from './theme-transition';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type ThemeMode = 'light' | 'dark' | 'auto';
|
|
2
|
+
|
|
3
|
+
export interface ThemeTransitionOrigin {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const THEME_STORAGE_KEY = 'theme';
|
|
9
|
+
|
|
10
|
+
type DocumentWithViewTransition = Document & {
|
|
11
|
+
startViewTransition?: (callback: () => void) => {
|
|
12
|
+
ready: Promise<void>;
|
|
13
|
+
finished: Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Check if the system prefers dark mode
|
|
18
|
+
const isSystemDarkPreferred = () => {
|
|
19
|
+
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Resolve whether a theme mode ends up dark
|
|
23
|
+
export const isDarkTheme = (theme: ThemeMode) => {
|
|
24
|
+
return theme === 'dark' || (theme === 'auto' && isSystemDarkPreferred());
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Read the persisted theme mode
|
|
28
|
+
export const getStoredTheme = (): ThemeMode => {
|
|
29
|
+
if (typeof localStorage === 'undefined') return 'auto';
|
|
30
|
+
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
|
31
|
+
return storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'auto';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Apply the selected theme to the document and persist it
|
|
35
|
+
export const applyTheme = (theme: ThemeMode) => {
|
|
36
|
+
if (typeof document === 'undefined') return;
|
|
37
|
+
const root = document.documentElement;
|
|
38
|
+
root.classList.remove('light', 'dark');
|
|
39
|
+
root.classList.add(isDarkTheme(theme) ? 'dark' : 'light');
|
|
40
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Apply a theme with a circular reveal that grows from the origin point (View Transitions API)
|
|
44
|
+
export const applyThemeWithTransition = async (theme: ThemeMode, origin?: ThemeTransitionOrigin | Element | null) => {
|
|
45
|
+
const doc = document as DocumentWithViewTransition;
|
|
46
|
+
const root = document.documentElement;
|
|
47
|
+
const willChange = root.classList.contains('dark') !== isDarkTheme(theme);
|
|
48
|
+
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
49
|
+
|
|
50
|
+
// Fall back to an instant switch when the transition is unsupported or pointless
|
|
51
|
+
if (!doc.startViewTransition || !willChange || prefersReducedMotion) {
|
|
52
|
+
applyTheme(theme);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve the reveal origin (defaults to the top-right corner)
|
|
57
|
+
let x = window.innerWidth;
|
|
58
|
+
let y = 0;
|
|
59
|
+
if (origin instanceof Element) {
|
|
60
|
+
const rect = origin.getBoundingClientRect();
|
|
61
|
+
x = rect.left + rect.width / 2;
|
|
62
|
+
y = rect.top + rect.height / 2;
|
|
63
|
+
} else if (origin) {
|
|
64
|
+
x = origin.x;
|
|
65
|
+
y = origin.y;
|
|
66
|
+
}
|
|
67
|
+
const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
|
|
68
|
+
|
|
69
|
+
// Freeze CSS transitions so the snapshots capture the final colors
|
|
70
|
+
root.classList.add('uv-theme-switching');
|
|
71
|
+
const transition = doc.startViewTransition(() => applyTheme(theme));
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await transition.ready;
|
|
75
|
+
root.animate({
|
|
76
|
+
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`]
|
|
77
|
+
}, {
|
|
78
|
+
duration: 620,
|
|
79
|
+
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
|
80
|
+
pseudoElement: '::view-transition-new(root)'
|
|
81
|
+
});
|
|
82
|
+
await transition.finished;
|
|
83
|
+
} finally {
|
|
84
|
+
root.classList.remove('uv-theme-switching');
|
|
85
|
+
}
|
|
86
|
+
};
|
|
@@ -1,58 +1,73 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { HTMLAttributes } from 'vue';
|
|
3
|
-
import { ref } from 'vue';
|
|
3
|
+
import { computed, ref, useId } from 'vue';
|
|
4
4
|
import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
|
|
5
5
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
6
|
-
import { Card
|
|
6
|
+
import { Card } from '../card';
|
|
7
|
+
import { cn } from '../../../lib/utils';
|
|
7
8
|
import type { HugeiconsIconDefinition } from '../../../lib/common-types';
|
|
8
9
|
|
|
9
10
|
// Props
|
|
10
11
|
const props = withDefaults(defineProps<{
|
|
11
12
|
title: string;
|
|
12
13
|
initiallyExpanded?: boolean;
|
|
14
|
+
open?: boolean;
|
|
13
15
|
icon?: HugeiconsIconDefinition | null;
|
|
14
16
|
iconClass?: HTMLAttributes['class'];
|
|
17
|
+
class?: HTMLAttributes['class'];
|
|
15
18
|
}>(), {
|
|
16
|
-
initiallyExpanded: false
|
|
19
|
+
initiallyExpanded: false,
|
|
20
|
+
open: undefined
|
|
17
21
|
});
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
// Emits
|
|
24
|
+
const emits = defineEmits<{
|
|
25
|
+
'update:open': [value: boolean];
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const contentId = useId();
|
|
29
|
+
const internalExpanded = ref(props.initiallyExpanded);
|
|
30
|
+
|
|
31
|
+
// Support both controlled (v-model:open) and uncontrolled usage
|
|
32
|
+
const expanded = computed(() => props.open ?? internalExpanded.value);
|
|
20
33
|
|
|
21
34
|
// Toggle expanded state
|
|
22
35
|
const toggleExpanded = () => {
|
|
23
|
-
|
|
36
|
+
const nextValue = !expanded.value;
|
|
37
|
+
internalExpanded.value = nextValue;
|
|
38
|
+
emits('update:open', nextValue);
|
|
24
39
|
};
|
|
25
40
|
</script>
|
|
26
41
|
|
|
27
42
|
<template>
|
|
28
|
-
<Card class="
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<div class="flex
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
<HugeiconsIcon :icon="props.icon" :class="['size-4 text-(--text-secondary-light) dark:text-(--text-secondary-dark)', props.iconClass]" />
|
|
36
|
-
</div>
|
|
37
|
-
|
|
38
|
-
<!-- Title -->
|
|
39
|
-
<div class="text-sm md:text-base! font-semibold text-(--text-primary-light) dark:text-(--text-primary-dark) text-left">
|
|
40
|
-
{{ props.title }}
|
|
41
|
-
</div>
|
|
43
|
+
<Card :data-state="expanded ? 'open' : 'closed'" :class="cn('group/accordion gap-0! overflow-hidden sm:gap-0! p-0! data-[state=open]:border-border-strong', props.class)">
|
|
44
|
+
<!-- Header -->
|
|
45
|
+
<button type="button" :aria-expanded="expanded" :aria-controls="contentId" class="flex w-full cursor-pointer items-center justify-between gap-4 p-5 text-left outline-none transition-colors duration-150 hover:bg-surface/70 focus-visible:bg-surface focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/40" @click="toggleExpanded">
|
|
46
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
47
|
+
<!-- Icon -->
|
|
48
|
+
<div v-if="props.icon" class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-card transition-[border-color,color] duration-200 group-data-[state=open]/accordion:border-primary/60 md:size-10">
|
|
49
|
+
<HugeiconsIcon :icon="props.icon" :class="cn('size-4 text-muted-foreground transition-colors duration-200 group-data-[state=open]/accordion:text-primary', props.iconClass)" />
|
|
42
50
|
</div>
|
|
43
51
|
|
|
44
|
-
<!--
|
|
45
|
-
<div class="
|
|
46
|
-
|
|
52
|
+
<!-- Title -->
|
|
53
|
+
<div class="text-left text-sm font-semibold text-foreground md:text-base!">
|
|
54
|
+
{{ props.title }}
|
|
47
55
|
</div>
|
|
48
56
|
</div>
|
|
49
57
|
|
|
50
|
-
<!--
|
|
51
|
-
<div
|
|
52
|
-
<
|
|
58
|
+
<!-- Toggle icon -->
|
|
59
|
+
<div class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-card transition-[rotate,border-color,background-color] duration-[420ms] ease-spring group-data-[state=open]/accordion:rotate-180 group-data-[state=open]/accordion:border-primary/50 group-data-[state=open]/accordion:bg-primary/10">
|
|
60
|
+
<HugeiconsIcon :icon="ArrowDown01Icon" class="size-4 text-muted-foreground transition-colors duration-200 group-data-[state=open]/accordion:text-primary" />
|
|
61
|
+
</div>
|
|
62
|
+
</button>
|
|
63
|
+
|
|
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']">
|
|
66
|
+
<div class="overflow-hidden">
|
|
67
|
+
<div :class="['px-5 pb-5 transition-[translate,filter] duration-[420ms] ease-spring', expanded ? 'translate-y-0 blur-0' : '-translate-y-2 blur-[2px]']">
|
|
53
68
|
<slot />
|
|
54
69
|
</div>
|
|
55
70
|
</div>
|
|
56
|
-
</
|
|
71
|
+
</div>
|
|
57
72
|
</Card>
|
|
58
73
|
</template>
|
|
@@ -25,8 +25,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|
|
25
25
|
|
|
26
26
|
<template>
|
|
27
27
|
<AlertDialogPortal>
|
|
28
|
-
<AlertDialogOverlay data-slot="alert-dialog-overlay" class="fixed inset-0 z-50 bg-black/
|
|
29
|
-
<AlertDialogContent data-slot="alert-dialog-content" v-bind="{ ...$attrs, ...forwarded }" :class="cn('bg-card text-card-foreground
|
|
28
|
+
<AlertDialogOverlay data-slot="alert-dialog-overlay" class="uv-overlay-motion fixed inset-0 z-50 bg-black/40 backdrop-blur-[3px] dark:bg-black/60" />
|
|
29
|
+
<AlertDialogContent data-slot="alert-dialog-content" v-bind="{ ...$attrs, ...forwarded }" :class="cn('uv-modal-motion bg-card text-card-foreground fixed top-[50%] left-[50%] z-50 grid w-[95%] max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-0 overflow-hidden rounded border border-border shadow-elevated-xl sm:max-w-lg', props.class)">
|
|
30
30
|
<slot />
|
|
31
31
|
</AlertDialogContent>
|
|
32
32
|
</AlertDialogPortal>
|
|
@@ -9,7 +9,7 @@ const props = defineProps<{
|
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
|
-
<div data-slot="alert-dialog-footer" :class="cn('flex flex-col-reverse gap-3 border-t border-border px-5 py-4 sm:flex-row-reverse sm:px-6', props.class)">
|
|
12
|
+
<div data-slot="alert-dialog-footer" :class="cn('flex flex-col-reverse gap-3 border-t border-border bg-surface/60 px-5 py-4 sm:flex-row-reverse sm:px-6', props.class)">
|
|
13
13
|
<slot />
|
|
14
14
|
</div>
|
|
15
15
|
</template>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue';
|
|
3
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
4
|
+
import { cn } from '../../../lib/utils';
|
|
5
|
+
|
|
6
|
+
// Props
|
|
7
|
+
const props = withDefaults(defineProps<{
|
|
8
|
+
value: number;
|
|
9
|
+
duration?: number;
|
|
10
|
+
locale?: string;
|
|
11
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
12
|
+
prefix?: string;
|
|
13
|
+
suffix?: string;
|
|
14
|
+
animateOnMount?: boolean;
|
|
15
|
+
class?: HTMLAttributes['class'];
|
|
16
|
+
}>(), {
|
|
17
|
+
duration: 900,
|
|
18
|
+
locale: undefined,
|
|
19
|
+
formatOptions: undefined,
|
|
20
|
+
prefix: '',
|
|
21
|
+
suffix: '',
|
|
22
|
+
animateOnMount: true
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const displayValue = ref(props.animateOnMount ? 0 : props.value);
|
|
26
|
+
const direction = ref<'up' | 'down' | null>(null);
|
|
27
|
+
let frame: number | null = null;
|
|
28
|
+
|
|
29
|
+
// Format the displayed value
|
|
30
|
+
const formatter = computed(() => {
|
|
31
|
+
// Default to the precision of the target so intermediate frames never show stray decimals
|
|
32
|
+
const decimals = (String(props.value).split('.')[1] ?? '').length;
|
|
33
|
+
return new Intl.NumberFormat(props.locale, props.formatOptions ?? { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
34
|
+
});
|
|
35
|
+
const formattedValue = computed(() => `${props.prefix}${formatter.value.format(displayValue.value)}${props.suffix}`);
|
|
36
|
+
|
|
37
|
+
// Decelerating curve so the count settles gently on the final value
|
|
38
|
+
const easeOutExpo = (progress: number) => progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
|
|
39
|
+
|
|
40
|
+
// Tween from the current displayed value to the target value
|
|
41
|
+
const animateTo = (target: number) => {
|
|
42
|
+
if (frame !== null) cancelAnimationFrame(frame);
|
|
43
|
+
|
|
44
|
+
// Jump straight to the value when motion is reduced or unavailable
|
|
45
|
+
const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
46
|
+
if (typeof requestAnimationFrame === 'undefined' || prefersReducedMotion || props.duration <= 0) {
|
|
47
|
+
displayValue.value = target;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const from = displayValue.value;
|
|
52
|
+
const start = performance.now();
|
|
53
|
+
direction.value = target >= from ? 'up' : 'down';
|
|
54
|
+
|
|
55
|
+
// Advance one frame of the tween
|
|
56
|
+
const step = (now: number) => {
|
|
57
|
+
const progress = Math.min(1, (now - start) / props.duration);
|
|
58
|
+
displayValue.value = from + (target - from) * easeOutExpo(progress);
|
|
59
|
+
if (progress < 1) {
|
|
60
|
+
frame = requestAnimationFrame(step);
|
|
61
|
+
} else {
|
|
62
|
+
displayValue.value = target;
|
|
63
|
+
direction.value = null;
|
|
64
|
+
frame = null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
frame = requestAnimationFrame(step);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Re-animate whenever the value changes
|
|
71
|
+
watch(() => props.value, value => animateTo(value));
|
|
72
|
+
|
|
73
|
+
// Count up on mount
|
|
74
|
+
onMounted(() => {
|
|
75
|
+
if (props.animateOnMount) animateTo(props.value);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Stop any pending frame
|
|
79
|
+
onBeforeUnmount(() => {
|
|
80
|
+
if (frame !== null) cancelAnimationFrame(frame);
|
|
81
|
+
});
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<template>
|
|
85
|
+
<span data-slot="animated-number" :data-direction="direction ?? undefined" :aria-label="`${props.prefix}${formatter.format(props.value)}${props.suffix}`" :class="cn('inline-block tabular-nums', props.class)">
|
|
86
|
+
<span aria-hidden="true">{{ formattedValue }}</span>
|
|
87
|
+
</span>
|
|
88
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as AnimatedNumber } from './AnimatedNumber.vue';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AvatarRootProps } from 'reka-ui';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import type { AvatarShape, AvatarSize, AvatarStatus } from '.';
|
|
5
|
+
import { reactiveOmit } from '@vueuse/core';
|
|
6
|
+
import { AvatarRoot } from 'reka-ui';
|
|
7
|
+
import { computed, provide, toRef } from 'vue';
|
|
8
|
+
import { useI18n } from 'vue-i18n';
|
|
9
|
+
import { cn } from '../../../lib/utils';
|
|
10
|
+
import { avatarContextKey, avatarShapeClasses, avatarSizeClasses } from '.';
|
|
11
|
+
|
|
12
|
+
// Props
|
|
13
|
+
const props = withDefaults(defineProps<AvatarRootProps & {
|
|
14
|
+
size?: AvatarSize;
|
|
15
|
+
shape?: AvatarShape;
|
|
16
|
+
status?: AvatarStatus;
|
|
17
|
+
class?: HTMLAttributes['class'];
|
|
18
|
+
}>(), {
|
|
19
|
+
size: 'md',
|
|
20
|
+
shape: 'circle',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const delegatedProps = reactiveOmit(props, 'class', 'size', 'shape', 'status');
|
|
24
|
+
|
|
25
|
+
const { t } = useI18n();
|
|
26
|
+
|
|
27
|
+
// Share size and shape with the image and fallback
|
|
28
|
+
provide(avatarContextKey, { size: toRef(props, 'size'), shape: toRef(props, 'shape') });
|
|
29
|
+
|
|
30
|
+
// Status dot size scales with the avatar
|
|
31
|
+
const statusSizeClasses: Record<AvatarSize, string> = {
|
|
32
|
+
xs: 'size-2',
|
|
33
|
+
sm: 'size-2.5',
|
|
34
|
+
md: 'size-3',
|
|
35
|
+
lg: 'size-3.5',
|
|
36
|
+
xl: 'size-4',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Status dot color
|
|
40
|
+
const statusColorClasses: Record<AvatarStatus, string> = {
|
|
41
|
+
online: 'bg-success',
|
|
42
|
+
offline: 'bg-muted-foreground',
|
|
43
|
+
busy: 'bg-destructive',
|
|
44
|
+
away: 'bg-warning',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Square avatars tuck the dot slightly outside the corner
|
|
48
|
+
const statusPositionClass = computed(() => props.shape === 'square' ? '-right-0.5 -bottom-0.5' : 'right-0 bottom-0');
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<span data-slot="avatar" :data-size="props.size" :data-shape="props.shape" :data-status="props.status" :class="cn('relative inline-flex shrink-0 select-none align-middle', avatarSizeClasses[props.size], avatarShapeClasses[props.shape], props.class)">
|
|
53
|
+
<!-- Avatar surface -->
|
|
54
|
+
<AvatarRoot v-bind="delegatedProps" :class="cn('relative flex size-full items-center justify-center overflow-hidden bg-surface', avatarShapeClasses[props.shape])">
|
|
55
|
+
<slot />
|
|
56
|
+
</AvatarRoot>
|
|
57
|
+
|
|
58
|
+
<!-- Status dot -->
|
|
59
|
+
<span v-if="props.status" data-slot="avatar-status" :class="cn('absolute rounded-full ring-2 ring-background', statusSizeClasses[props.size], statusPositionClass)">
|
|
60
|
+
<!-- Soft halo for online presence -->
|
|
61
|
+
<span v-if="props.status === 'online'" aria-hidden="true" :class="cn('absolute inset-0 rounded-full animate-uv-ping-soft', statusColorClasses[props.status])" />
|
|
62
|
+
<span aria-hidden="true" :class="cn('relative block size-full rounded-full animate-uv-pop', statusColorClasses[props.status])" />
|
|
63
|
+
<span class="sr-only">{{ t(`uiVintage.avatar.status.${props.status}`) }}</span>
|
|
64
|
+
</span>
|
|
65
|
+
</span>
|
|
66
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AvatarFallbackProps } from 'reka-ui';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { reactiveOmit } from '@vueuse/core';
|
|
5
|
+
import { AvatarFallback } from 'reka-ui';
|
|
6
|
+
import { computed, inject } from 'vue';
|
|
7
|
+
import { cn } from '../../../lib/utils';
|
|
8
|
+
import { avatarContextKey, avatarShapeClasses, avatarTextClasses, getInitials } from '.';
|
|
9
|
+
|
|
10
|
+
// Props
|
|
11
|
+
const props = defineProps<AvatarFallbackProps & {
|
|
12
|
+
name?: string;
|
|
13
|
+
class?: HTMLAttributes['class'];
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const delegatedProps = reactiveOmit(props, 'class', 'name');
|
|
17
|
+
|
|
18
|
+
// Size and shape come from the parent avatar
|
|
19
|
+
const context = inject(avatarContextKey, null);
|
|
20
|
+
const textClass = computed(() => avatarTextClasses[context?.size.value ?? 'md']);
|
|
21
|
+
const shapeClass = computed(() => avatarShapeClasses[context?.shape.value ?? 'circle']);
|
|
22
|
+
|
|
23
|
+
// Initials computed from the name prop
|
|
24
|
+
const initials = computed(() => getInitials(props.name));
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<AvatarFallback data-slot="avatar-fallback" v-bind="delegatedProps" :aria-label="props.name || undefined" :class="cn('flex size-full items-center justify-center bg-surface font-bold uppercase text-muted-foreground', textClass, shapeClass, props.class)">
|
|
29
|
+
<slot>{{ initials }}</slot>
|
|
30
|
+
</AvatarFallback>
|
|
31
|
+
</template>
|