@brumaombra/ui-vintage 0.8.1 → 0.9.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/AGENTS.md +41 -0
- package/COMPONENTS.md +91 -17
- package/README.md +296 -262
- package/module.mjs +5 -11
- package/package.json +31 -13
- package/src/components/blog/AllPostsSection.vue +1 -2
- package/src/components/blog/BlogContentRenderer.vue +144 -49
- package/src/components/blog/BlogFAQSection.vue +13 -15
- package/src/components/blog/BlogHeaderSection.vue +103 -11
- package/src/components/blog/BlogInfoSection.vue +46 -42
- package/src/components/blog/BlogPostHeader.vue +181 -0
- package/src/components/blog/BlogProgressBar.vue +3 -2
- package/src/components/blog/BlogSectionTitle.vue +27 -6
- package/src/components/blog/BlogTagsSection.vue +20 -8
- package/src/components/blog/CategoriesList.vue +2 -2
- package/src/components/blog/CategoryCard.vue +23 -19
- package/src/components/blog/HeaderCarousel.vue +117 -145
- package/src/components/blog/PostCard.vue +25 -33
- package/src/components/blog/PostsList.vue +3 -2
- package/src/components/blog/SocialShareSidebar.vue +62 -42
- package/src/components/blog/TableOfContents.vue +71 -34
- package/src/components/blog/index.ts +1 -0
- package/src/components/card-grid/CardGrid.vue +2 -2
- package/src/components/confirm-dialog/ConfirmDialog.vue +5 -0
- package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
- package/src/components/content/BlogList.vue +30 -42
- package/src/components/content/BlogTable.vue +26 -30
- package/src/components/content/CodeBlock.vue +96 -22
- package/src/components/content/Flow.vue +30 -36
- package/src/components/content/ProseHr.vue +6 -6
- package/src/components/content/ProsePre.vue +9 -4
- package/src/components/content/Terminal.vue +42 -14
- 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 +2 -2
- package/src/components/error-page/ErrorPage.vue +3 -3
- package/src/components/info-card/InfoCard.vue +4 -4
- package/src/components/landing/landing-footer/LandingFooter.vue +16 -6
- package/src/components/landing/landing-navbar/LandingNavbar.vue +63 -51
- package/src/components/language-flag/index.ts +1 -0
- package/src/components/language-selector/LanguageSelector.vue +18 -10
- package/src/components/load-more-button/LoadMoreButton.vue +7 -12
- package/src/components/loading-state-card/LoadingStateCard.vue +2 -2
- package/src/components/message-dialog/MessageDialog.vue +11 -11
- package/src/components/message-dialog/message-dialog-state.ts +1 -4
- package/src/components/message-toast/MessageToast.vue +24 -9
- 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 +37 -39
- 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 +2 -2
- package/src/components/theme-selector/theme-transition.ts +15 -3
- package/src/components/ui/accordion/Accordion.vue +18 -18
- 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 +2 -2
- package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
- package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
- 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 +1 -1
- package/src/components/ui/calendar/CalendarPrevButton.vue +1 -1
- 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/command/CommandGroup.vue +17 -3
- package/src/components/ui/command/CommandInput.vue +1 -1
- package/src/components/ui/command/CommandItem.vue +2 -2
- package/src/components/ui/command/CommandList.vue +5 -1
- package/src/components/ui/data-table/DataTable.vue +11 -3
- package/src/components/ui/date-picker/DatePicker.vue +7 -1
- package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +1 -1
- 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 +4 -4
- package/src/components/ui/input/Input.vue +5 -1
- package/src/components/ui/native-select/NativeSelect.vue +6 -2
- package/src/components/ui/number-field/NumberField.vue +5 -1
- package/src/components/ui/pagination/PaginationItem.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 +1 -1
- package/src/components/ui/select/SelectItemContent.vue +1 -1
- package/src/components/ui/select/SelectTrigger.vue +5 -1
- 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/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/StepperDescription.vue +5 -3
- package/src/components/ui/stepper/StepperIndicator.vue +2 -2
- 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/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 +19 -7
- package/src/i18n/en.json +19 -7
- package/src/i18n/es.json +19 -7
- package/src/i18n/fr.json +19 -7
- package/src/i18n/it.json +19 -7
- package/src/i18n/ja.json +19 -7
- package/src/i18n/pt.json +19 -7
- package/src/i18n/ru.json +19 -7
- package/src/i18n/zh.json +19 -7
- 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 +52 -16
- package/skills/ui-vintage/SKILL.md +0 -42
|
@@ -25,6 +25,7 @@ export interface LandingFooterSection {
|
|
|
25
25
|
const props = withDefaults(defineProps<{
|
|
26
26
|
appName: string;
|
|
27
27
|
appLogo?: string;
|
|
28
|
+
appLogoDark?: string;
|
|
28
29
|
appLinkTo?: string;
|
|
29
30
|
logoClass?: HTMLAttributes['class'];
|
|
30
31
|
appDescription?: string;
|
|
@@ -49,10 +50,11 @@ const props = withDefaults(defineProps<{
|
|
|
49
50
|
authorLink: '',
|
|
50
51
|
year: () => new Date().getFullYear(),
|
|
51
52
|
showBottomBar: true,
|
|
52
|
-
level:
|
|
53
|
+
level: 2
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
const { t } = useI18n();
|
|
57
|
+
const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
|
|
56
58
|
const hasSingleSection = computed(() => props.sections.length === 1);
|
|
57
59
|
|
|
58
60
|
// Get link props based on the type of link component and whether the link should open in a new tab
|
|
@@ -73,6 +75,11 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
73
75
|
...sharedProps
|
|
74
76
|
};
|
|
75
77
|
};
|
|
78
|
+
|
|
79
|
+
// Logo sources for each theme (either one falls back to the other)
|
|
80
|
+
const lightLogo = computed(() => props.appLogo || props.appLogoDark);
|
|
81
|
+
const darkLogo = computed(() => props.appLogoDark || props.appLogo);
|
|
82
|
+
const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
76
83
|
</script>
|
|
77
84
|
|
|
78
85
|
<template>
|
|
@@ -82,8 +89,11 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
82
89
|
<!-- Brand section -->
|
|
83
90
|
<div :class="cn('max-w-md flex-1', props.brandClass)">
|
|
84
91
|
<!-- App logo and name -->
|
|
85
|
-
<a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity hover:opacity-80">
|
|
86
|
-
<
|
|
92
|
+
<a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity duration-150 hover:opacity-80">
|
|
93
|
+
<template v-if="lightLogo">
|
|
94
|
+
<NuxtImg :src="lightLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('size-8 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
|
|
95
|
+
<NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('hidden size-8 shrink-0 object-contain dark:block', props.logoClass)" />
|
|
96
|
+
</template>
|
|
87
97
|
<span>{{ props.appName }}</span>
|
|
88
98
|
</a>
|
|
89
99
|
|
|
@@ -104,7 +114,7 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
104
114
|
<!-- Section links -->
|
|
105
115
|
<ul class="space-y-3 text-xs sm:text-sm">
|
|
106
116
|
<li v-for="link in props.sections[0]?.links" :key="link.id">
|
|
107
|
-
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="text-muted-foreground hover:
|
|
117
|
+
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
|
|
108
118
|
{{ link.label }}
|
|
109
119
|
</component>
|
|
110
120
|
</li>
|
|
@@ -123,7 +133,7 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
123
133
|
<!-- Section links -->
|
|
124
134
|
<ul class="space-y-3 text-xs sm:text-sm">
|
|
125
135
|
<li v-for="link in section.links" :key="link.id">
|
|
126
|
-
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="text-muted-foreground hover:
|
|
136
|
+
<component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
|
|
127
137
|
{{ link.label }}
|
|
128
138
|
</component>
|
|
129
139
|
</li>
|
|
@@ -142,7 +152,7 @@ const getLinkProps = (link: LandingFooterLink) => {
|
|
|
142
152
|
<!-- Author -->
|
|
143
153
|
<div v-if="props.authorName && props.authorLink" class="flex flex-wrap items-center justify-center gap-1.5 md:justify-end">
|
|
144
154
|
<span>{{ t('uiVintage.footer.madeWith') }}</span>
|
|
145
|
-
<HugeiconsIcon :icon="FavouriteIcon" class="size-4 shrink-0 text-
|
|
155
|
+
<HugeiconsIcon :icon="FavouriteIcon" class="size-4 shrink-0 text-destructive" />
|
|
146
156
|
<span>{{ t('uiVintage.footer.by') }}</span>
|
|
147
157
|
<a :href="props.authorLink" target="_blank" rel="noopener noreferrer" class="font-semibold text-foreground hover:underline">
|
|
148
158
|
{{ props.authorName }}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { NuxtImg } from '#components';
|
|
3
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
3
4
|
import type { HTMLAttributes } from 'vue';
|
|
5
|
+
import { useI18n } from 'vue-i18n';
|
|
4
6
|
import BlogProgressBar from '../../blog/BlogProgressBar.vue';
|
|
5
7
|
import { cn } from '../../../lib/utils';
|
|
6
8
|
|
|
@@ -8,6 +10,7 @@ import { cn } from '../../../lib/utils';
|
|
|
8
10
|
const props = withDefaults(defineProps<{
|
|
9
11
|
appName?: string;
|
|
10
12
|
appLogo?: string;
|
|
13
|
+
appLogoDark?: string;
|
|
11
14
|
appLinkTo?: string;
|
|
12
15
|
logoClass?: HTMLAttributes['class'];
|
|
13
16
|
class?: HTMLAttributes['class'];
|
|
@@ -18,65 +21,74 @@ const props = withDefaults(defineProps<{
|
|
|
18
21
|
}>(), {
|
|
19
22
|
appName: '',
|
|
20
23
|
appLogo: '',
|
|
24
|
+
appLogoDark: '',
|
|
21
25
|
appLinkTo: '/',
|
|
22
26
|
showProgress: false
|
|
23
27
|
});
|
|
24
|
-
</script>
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<nav :class="cn('mx-auto flex max-w-6xl basis-full items-center justify-between px-3 sm:px-6 lg:px-8', props.containerClass)">
|
|
29
|
-
<!-- Left-aligned content -->
|
|
30
|
-
<div :class="cn('flex items-center', props.leftClass)">
|
|
31
|
-
<slot name="left">
|
|
32
|
-
<a :href="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity hover:opacity-80">
|
|
33
|
-
<!-- App logo -->
|
|
34
|
-
<NuxtImg v-if="props.appLogo" :src="props.appLogo" :alt="`${props.appName} logo`" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" fetchpriority="high" :class="cn('size-11 sm:size-9 shrink-0 object-contain', props.logoClass)" />
|
|
29
|
+
const SCROLL_THRESHOLD_PX = 12;
|
|
30
|
+
const isScrolled = ref(false);
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
</a>
|
|
41
|
-
</slot>
|
|
42
|
-
</div>
|
|
32
|
+
// Float the navbar once the page leaves the top
|
|
33
|
+
const handleScroll = () => {
|
|
34
|
+
isScrolled.value = window.scrollY > SCROLL_THRESHOLD_PX;
|
|
35
|
+
};
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
// On component mounted
|
|
38
|
+
onMounted(() => {
|
|
39
|
+
handleScroll();
|
|
40
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
41
|
+
});
|
|
49
42
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</header>
|
|
55
|
-
</template>
|
|
43
|
+
// On before component unmount
|
|
44
|
+
onBeforeUnmount(() => {
|
|
45
|
+
window.removeEventListener('scroll', handleScroll);
|
|
46
|
+
});
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
animation-timeline: scroll(root);
|
|
62
|
-
}
|
|
48
|
+
// Logo sources for each theme (either one falls back to the other)
|
|
49
|
+
const lightLogo = computed(() => props.appLogo || props.appLogoDark);
|
|
50
|
+
const darkLogo = computed(() => props.appLogoDark || props.appLogo);
|
|
51
|
+
const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
|
|
63
52
|
|
|
64
|
-
|
|
65
|
-
from {
|
|
66
|
-
background-color: transparent;
|
|
67
|
-
border-bottom-color: transparent;
|
|
68
|
-
}
|
|
53
|
+
const { t } = useI18n();
|
|
69
54
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
55
|
+
// Logo alt text (also names the home link when the app name is not shown)
|
|
56
|
+
const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<!-- Transparent at the top of the page, a floating card once scrolled -->
|
|
61
|
+
<header :data-scrolled="isScrolled ? '' : undefined" :class="cn('sticky top-0 z-30 w-full text-card-foreground transition-[padding] duration-500 ease-out-expo', isScrolled ? 'px-2 pt-2 sm:px-4 sm:pt-3' : 'px-0 pt-0', props.class)">
|
|
62
|
+
<div :class="cn('relative mx-auto border transition-[max-width,border-radius,background-color,border-color,box-shadow] duration-500 ease-out-expo', isScrolled ? 'max-w-5xl rounded border-border bg-card shadow-elevated-md' : 'max-w-6xl rounded-none border-transparent bg-transparent shadow-none')">
|
|
63
|
+
<nav :class="cn('flex items-center justify-between px-3 transition-[height] duration-500 ease-out-expo sm:px-6 lg:px-8', isScrolled ? 'h-16 sm:px-5 lg:px-5' : 'h-20', props.containerClass)">
|
|
64
|
+
<!-- Left-aligned content -->
|
|
65
|
+
<div :class="cn('flex items-center', props.leftClass)">
|
|
66
|
+
<slot name="left">
|
|
67
|
+
<a v-if="lightLogo || props.appName" :href="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
|
|
68
|
+
<!-- App logo -->
|
|
69
|
+
<template v-if="lightLogo">
|
|
70
|
+
<NuxtImg :src="lightLogo" :alt="logoAlt" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" fetchpriority="high" :class="cn('size-11 sm:size-9 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
|
|
71
|
+
<NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" :class="cn('hidden size-11 sm:size-9 shrink-0 object-contain dark:block', props.logoClass)" />
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<!-- App name -->
|
|
75
|
+
<span v-if="props.appName" class="hidden sm:inline! text-xl font-semibold tracking-tight text-foreground">
|
|
76
|
+
{{ props.appName }}
|
|
77
|
+
</span>
|
|
78
|
+
</a>
|
|
79
|
+
</slot>
|
|
80
|
+
</div>
|
|
75
81
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
<!-- Right-aligned content -->
|
|
83
|
+
<div :class="cn('flex items-center justify-end gap-2 sm:gap-3', props.rightClass)">
|
|
84
|
+
<slot name="right" />
|
|
85
|
+
</div>
|
|
86
|
+
</nav>
|
|
87
|
+
|
|
88
|
+
<!-- Reading progress along the bottom edge of the floating bar (edge to edge, clipped to its rounded corners) -->
|
|
89
|
+
<div v-if="props.showProgress" :class="cn('absolute inset-x-0 bottom-0 overflow-hidden rounded-b-[inherit] transition-opacity duration-300', isScrolled ? 'opacity-100' : 'opacity-0')">
|
|
90
|
+
<BlogProgressBar />
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</header>
|
|
94
|
+
</template>
|
|
@@ -7,5 +7,6 @@ export { default as GermanFlag } from './flags/GermanFlag.vue';
|
|
|
7
7
|
export { default as ItalianFlag } from './flags/ItalianFlag.vue';
|
|
8
8
|
export { default as ChineseFlag } from './flags/ChineseFlag.vue';
|
|
9
9
|
export { default as JapaneseFlag } from './flags/JapaneseFlag.vue';
|
|
10
|
+
export { default as PortugueseFlag } from './flags/PortugueseFlag.vue';
|
|
10
11
|
export { default as RussianFlag } from './flags/RussianFlag.vue';
|
|
11
12
|
export { default as SpanishFlag } from './flags/SpanishFlag.vue';
|
|
@@ -31,10 +31,10 @@ const isOpen = ref(false);
|
|
|
31
31
|
const supportedLanguages = [
|
|
32
32
|
{ code: 'en', label: 'English', loadFlag: () => import('../language-flag/flags/EnglishFlag.vue') },
|
|
33
33
|
{ code: 'it', label: 'Italiano', loadFlag: () => import('../language-flag/flags/ItalianFlag.vue') },
|
|
34
|
-
{ code: 'fr', label: '
|
|
35
|
-
{ code: 'es', label: '
|
|
34
|
+
{ code: 'fr', label: 'Français', loadFlag: () => import('../language-flag/flags/FrenchFlag.vue') },
|
|
35
|
+
{ code: 'es', label: 'Español', loadFlag: () => import('../language-flag/flags/SpanishFlag.vue') },
|
|
36
36
|
{ code: 'de', label: 'Deutsch', loadFlag: () => import('../language-flag/flags/GermanFlag.vue') },
|
|
37
|
-
{ code: 'pt', label: '
|
|
37
|
+
{ code: 'pt', label: 'Português', loadFlag: () => import('../language-flag/flags/PortugueseFlag.vue') },
|
|
38
38
|
{ code: 'zh', label: '中文', loadFlag: () => import('../language-flag/flags/ChineseFlag.vue') },
|
|
39
39
|
{ code: 'ja', label: '日本語', loadFlag: () => import('../language-flag/flags/JapaneseFlag.vue') },
|
|
40
40
|
{ code: 'ru', label: 'Русский', loadFlag: () => import('../language-flag/flags/RussianFlag.vue') }
|
|
@@ -73,10 +73,18 @@ const languageDisplayNames = computed(() => {
|
|
|
73
73
|
return typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames([locale.value], { type: 'language' }) : null;
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
// Base language of a code ('it-IT' and 'it_IT' become 'it')
|
|
77
|
+
const getBaseLanguageCode = (code: string) => code.toLowerCase().split(/[-_]/)[0] ?? '';
|
|
78
|
+
|
|
79
|
+
// Supported flag code for a language code, including regional codes
|
|
80
|
+
const getFlagCode = (code: string) => {
|
|
81
|
+
const languageCode = getBaseLanguageCode(code);
|
|
82
|
+
return isSupportedLanguageCode(languageCode) ? languageCode : null;
|
|
83
|
+
};
|
|
84
|
+
|
|
76
85
|
// Get the display label for a given language code
|
|
77
86
|
const getLanguageLabel = (code: string) => {
|
|
78
|
-
const
|
|
79
|
-
const languageCode = normalizedCode.split('-')[0];
|
|
87
|
+
const languageCode = getBaseLanguageCode(code);
|
|
80
88
|
const supportedLanguage = isSupportedLanguageCode(languageCode) ? getSupportedLanguage(languageCode) : null;
|
|
81
89
|
return supportedLanguage?.label || languageDisplayNames.value?.of(languageCode) || code.toUpperCase();
|
|
82
90
|
};
|
|
@@ -92,7 +100,7 @@ const normalizedLanguages = computed(() => {
|
|
|
92
100
|
// Compute the available flag codes based on the provided language list
|
|
93
101
|
const availableFlagCodes = computed<SupportedLanguageCode[]>(() => {
|
|
94
102
|
// Extract supported language codes from the normalized list
|
|
95
|
-
const supportedCodes = normalizedLanguages.value.map(language => language.code).filter(
|
|
103
|
+
const supportedCodes = normalizedLanguages.value.map(language => getFlagCode(language.code)).filter((code): code is SupportedLanguageCode => code !== null);
|
|
96
104
|
if (supportedCodes.length > 0) {
|
|
97
105
|
return [...new Set(supportedCodes)];
|
|
98
106
|
}
|
|
@@ -103,8 +111,8 @@ const availableFlagCodes = computed<SupportedLanguageCode[]>(() => {
|
|
|
103
111
|
|
|
104
112
|
// Resolve the flag component for a specific code, falling back to the first available code
|
|
105
113
|
const resolveFlagComponent = (code?: string) => {
|
|
106
|
-
const
|
|
107
|
-
const resolvedCode =
|
|
114
|
+
const flagCode = code ? getFlagCode(code) : null;
|
|
115
|
+
const resolvedCode = flagCode && availableFlagCodes.value.includes(flagCode) ? flagCode : availableFlagCodes.value[0];
|
|
108
116
|
return getAsyncFlagComponent(resolvedCode);
|
|
109
117
|
};
|
|
110
118
|
|
|
@@ -138,8 +146,8 @@ const handleSelectLanguage = (language: string) => {
|
|
|
138
146
|
<!-- Popover content with language options -->
|
|
139
147
|
<PopoverContent side="bottom" align="end" :side-offset="8" class="w-44 p-0!">
|
|
140
148
|
<Command :model-value="props.modelValue">
|
|
141
|
-
<CommandList>
|
|
142
|
-
<CommandGroup :heading="t('uiVintage.language.title')">
|
|
149
|
+
<CommandList :aria-label="t('uiVintage.language.title')">
|
|
150
|
+
<CommandGroup :heading="t('uiVintage.language.title')" stagger>
|
|
143
151
|
<CommandItem v-for="option in normalizedLanguages" :key="option.code" :value="option.code" @select="handleSelectLanguage(option.code)">
|
|
144
152
|
<component :is="resolveFlagComponent(option.code)" class="h-4 w-[1.35rem] rounded-[2px]" />
|
|
145
153
|
<span class="flex-1">{{ option.label }}</span>
|
|
@@ -25,24 +25,19 @@ const { t } = useI18n();
|
|
|
25
25
|
const resolvedText = computed(() => props.text || t('uiVintage.buttons.loadMore'));
|
|
26
26
|
const resolvedLoadingText = computed(() => props.loadingText || t('uiVintage.buttons.loading'));
|
|
27
27
|
|
|
28
|
-
// Handle click event
|
|
28
|
+
// Handle click event (ignored while loading, so the button stays focusable without firing twice)
|
|
29
29
|
const handleClick = () => {
|
|
30
|
+
if (props.busy) return;
|
|
30
31
|
emits('load-more');
|
|
31
32
|
};
|
|
32
33
|
</script>
|
|
33
34
|
|
|
34
35
|
<template>
|
|
35
36
|
<div class="flex justify-center">
|
|
36
|
-
<!--
|
|
37
|
-
<Button
|
|
38
|
-
<HugeiconsIcon :icon="RefreshIcon" class="size-4 animate-spin" />
|
|
39
|
-
{{ resolvedLoadingText }}
|
|
40
|
-
</Button>
|
|
41
|
-
|
|
42
|
-
<!-- Load more button -->
|
|
43
|
-
<Button v-else variant="secondary" class="w-full md:w-auto" @click="handleClick">
|
|
44
|
-
<HugeiconsIcon :icon="ArrowDown01Icon" class="size-4" />
|
|
45
|
-
{{ resolvedText }}
|
|
37
|
+
<!-- A single button for both states keeps keyboard focus on it while the next page loads -->
|
|
38
|
+
<Button variant="secondary" :class="['w-full md:w-auto', props.busy && 'cursor-wait']" :aria-disabled="props.busy || undefined" :aria-busy="props.busy || undefined" @click="handleClick">
|
|
39
|
+
<HugeiconsIcon :icon="props.busy ? RefreshIcon : ArrowDown01Icon" :class="['size-4', props.busy && 'animate-spin']" />
|
|
40
|
+
{{ props.busy ? resolvedLoadingText : resolvedText }}
|
|
46
41
|
</Button>
|
|
47
42
|
</div>
|
|
48
|
-
</template>
|
|
43
|
+
</template>
|
|
@@ -28,14 +28,14 @@ const resolvedDescription = computed(() => props.description || t('uiVintage.com
|
|
|
28
28
|
<Card class="flex flex-col items-center justify-center px-4 py-8 text-center md:py-12">
|
|
29
29
|
<CardContent class="flex flex-col items-center justify-center p-0! text-center">
|
|
30
30
|
<!-- Loading spinner icon -->
|
|
31
|
-
<div class="flex items-center justify-center text-4xl text-
|
|
31
|
+
<div aria-hidden="true" class="flex items-center justify-center text-4xl text-muted-foreground opacity-60">
|
|
32
32
|
<HugeiconsIcon :icon="props.icon" class="size-10 animate-spin" />
|
|
33
33
|
</div>
|
|
34
34
|
|
|
35
35
|
<!-- Title -->
|
|
36
36
|
<component :is="`h${props.level}`"
|
|
37
37
|
v-if="resolvedTitle"
|
|
38
|
-
class="text-sm font-
|
|
38
|
+
class="text-sm font-semibold text-foreground md:text-base">
|
|
39
39
|
{{ resolvedTitle }}
|
|
40
40
|
</component>
|
|
41
41
|
|
|
@@ -36,21 +36,21 @@ const defaultMessageIcon = computed(() => {
|
|
|
36
36
|
// Resolved container classes for the message icon
|
|
37
37
|
const messageIconClasses = computed(() => {
|
|
38
38
|
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-
|
|
39
|
+
if (!current) return 'border-border bg-surface';
|
|
40
|
+
if (current.options.type === 'error') return 'border-destructive/25 bg-destructive/10';
|
|
41
|
+
if (current.options.type === 'warning') return 'border-warning/25 bg-warning/10';
|
|
42
|
+
if (current.options.type === 'success') return 'border-success/25 bg-success/10';
|
|
43
|
+
return 'border-border bg-surface';
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
// Resolved glyph color classes
|
|
47
47
|
const messageGlyphClasses = computed(() => {
|
|
48
48
|
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-
|
|
49
|
+
if (!current) return 'text-info';
|
|
50
|
+
if (current.options.type === 'error') return 'text-destructive';
|
|
51
|
+
if (current.options.type === 'warning') return 'text-warning';
|
|
52
|
+
if (current.options.type === 'success') return 'text-success';
|
|
53
|
+
return 'text-info';
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
// Close the dialog when the shell requests it
|
|
@@ -91,7 +91,7 @@ const handleOpenChange = (open: boolean) => {
|
|
|
91
91
|
|
|
92
92
|
<!-- Dialog actions -->
|
|
93
93
|
<AlertDialogFooter class="items-end">
|
|
94
|
-
<Button variant="secondary"
|
|
94
|
+
<Button variant="secondary" @click="resolveActiveMessageDialog">
|
|
95
95
|
<HugeiconsIcon v-if="currentDialog.options.closeButtonIcon" :icon="currentDialog.options.closeButtonIcon" class="size-4" />
|
|
96
96
|
{{ resolvedCloseText }}
|
|
97
97
|
</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
|
};
|
|
@@ -30,18 +30,18 @@ const getToastIcon = (toast: MessageToastItem) => {
|
|
|
30
30
|
|
|
31
31
|
// Resolve the tinted icon tile classes for each toast type
|
|
32
32
|
const toneClasses: Record<MessageToastType, string> = {
|
|
33
|
-
success: 'border-
|
|
34
|
-
info: 'border-
|
|
35
|
-
warning: 'border-
|
|
36
|
-
error: 'border-
|
|
33
|
+
success: 'border-success/25 bg-success/10 text-success',
|
|
34
|
+
info: 'border-info/25 bg-info/10 text-info',
|
|
35
|
+
warning: 'border-warning/25 bg-warning/10 text-warning',
|
|
36
|
+
error: 'border-destructive/25 bg-destructive/10 text-destructive'
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// Progress bar color for each toast type
|
|
40
40
|
const progressClasses: Record<MessageToastType, string> = {
|
|
41
|
-
success: 'bg-
|
|
42
|
-
info: 'bg-
|
|
43
|
-
warning: 'bg-
|
|
44
|
-
error: 'bg-
|
|
41
|
+
success: 'bg-success',
|
|
42
|
+
info: 'bg-info',
|
|
43
|
+
warning: 'bg-warning',
|
|
44
|
+
error: 'bg-destructive'
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
const toasts = computed(() => messageToastState.toasts);
|
|
@@ -86,11 +86,17 @@ const getToastStyle = (toast: MessageToastItem, index: number) => {
|
|
|
86
86
|
};
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
// Toast content elements currently watched by the observer, by toast id
|
|
90
|
+
const observedElements = new Map<number, HTMLElement>();
|
|
91
|
+
|
|
89
92
|
// Measure toast content heights so the stack can lay itself out
|
|
90
93
|
const resizeObserver = typeof ResizeObserver !== 'undefined'
|
|
91
94
|
? new ResizeObserver((entries) => {
|
|
92
95
|
for (const entry of entries) {
|
|
93
96
|
const id = Number((entry.target as HTMLElement).dataset.toastId);
|
|
97
|
+
|
|
98
|
+
// Skip late notifications for toasts that already left
|
|
99
|
+
if (observedElements.get(id) !== entry.target) continue;
|
|
94
100
|
heights.set(id, (entry.target as HTMLElement).offsetHeight);
|
|
95
101
|
}
|
|
96
102
|
})
|
|
@@ -99,7 +105,10 @@ const resizeObserver = typeof ResizeObserver !== 'undefined'
|
|
|
99
105
|
// Register toast content elements with the observer
|
|
100
106
|
const observeToast = (element: unknown) => {
|
|
101
107
|
if (element instanceof HTMLElement && resizeObserver) {
|
|
102
|
-
|
|
108
|
+
const id = Number(element.dataset.toastId);
|
|
109
|
+
if (observedElements.get(id) === element) return;
|
|
110
|
+
heights.set(id, element.offsetHeight);
|
|
111
|
+
observedElements.set(id, element);
|
|
103
112
|
resizeObserver.observe(element);
|
|
104
113
|
}
|
|
105
114
|
};
|
|
@@ -153,6 +162,11 @@ const handleAction = async (toast: MessageToastItem) => {
|
|
|
153
162
|
// Forget measurements of removed toasts
|
|
154
163
|
const handleAfterLeave = (element: Element) => {
|
|
155
164
|
const id = Number((element as HTMLElement).dataset.toastId);
|
|
165
|
+
const content = observedElements.get(id);
|
|
166
|
+
if (content) {
|
|
167
|
+
resizeObserver?.unobserve(content);
|
|
168
|
+
observedElements.delete(id);
|
|
169
|
+
}
|
|
156
170
|
heights.delete(id);
|
|
157
171
|
leaveDirection.delete(id);
|
|
158
172
|
};
|
|
@@ -167,6 +181,7 @@ watch(() => toasts.value.length, (length) => {
|
|
|
167
181
|
// Stop observing on unmount
|
|
168
182
|
onBeforeUnmount(() => {
|
|
169
183
|
resizeObserver?.disconnect();
|
|
184
|
+
observedElements.clear();
|
|
170
185
|
});
|
|
171
186
|
</script>
|
|
172
187
|
|
|
@@ -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">
|