@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.
Files changed (142) hide show
  1. package/AGENTS.md +41 -0
  2. package/COMPONENTS.md +91 -17
  3. package/README.md +296 -262
  4. package/module.mjs +5 -11
  5. package/package.json +31 -13
  6. package/src/components/blog/AllPostsSection.vue +1 -2
  7. package/src/components/blog/BlogContentRenderer.vue +144 -49
  8. package/src/components/blog/BlogFAQSection.vue +13 -15
  9. package/src/components/blog/BlogHeaderSection.vue +103 -11
  10. package/src/components/blog/BlogInfoSection.vue +46 -42
  11. package/src/components/blog/BlogPostHeader.vue +181 -0
  12. package/src/components/blog/BlogProgressBar.vue +3 -2
  13. package/src/components/blog/BlogSectionTitle.vue +27 -6
  14. package/src/components/blog/BlogTagsSection.vue +20 -8
  15. package/src/components/blog/CategoriesList.vue +2 -2
  16. package/src/components/blog/CategoryCard.vue +23 -19
  17. package/src/components/blog/HeaderCarousel.vue +117 -145
  18. package/src/components/blog/PostCard.vue +25 -33
  19. package/src/components/blog/PostsList.vue +3 -2
  20. package/src/components/blog/SocialShareSidebar.vue +62 -42
  21. package/src/components/blog/TableOfContents.vue +71 -34
  22. package/src/components/blog/index.ts +1 -0
  23. package/src/components/card-grid/CardGrid.vue +2 -2
  24. package/src/components/confirm-dialog/ConfirmDialog.vue +5 -0
  25. package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
  26. package/src/components/content/BlogList.vue +30 -42
  27. package/src/components/content/BlogTable.vue +26 -30
  28. package/src/components/content/CodeBlock.vue +96 -22
  29. package/src/components/content/Flow.vue +30 -36
  30. package/src/components/content/ProseHr.vue +6 -6
  31. package/src/components/content/ProsePre.vue +9 -4
  32. package/src/components/content/Terminal.vue +42 -14
  33. package/src/components/cookie-consent/CookieConsent.vue +1 -1
  34. package/src/components/dashboard-shell/DashboardShell.vue +24 -7
  35. package/src/components/empty-state-card/EmptyStateCard.vue +2 -2
  36. package/src/components/error-page/ErrorPage.vue +3 -3
  37. package/src/components/info-card/InfoCard.vue +4 -4
  38. package/src/components/landing/landing-footer/LandingFooter.vue +16 -6
  39. package/src/components/landing/landing-navbar/LandingNavbar.vue +63 -51
  40. package/src/components/language-flag/index.ts +1 -0
  41. package/src/components/language-selector/LanguageSelector.vue +18 -10
  42. package/src/components/load-more-button/LoadMoreButton.vue +7 -12
  43. package/src/components/loading-state-card/LoadingStateCard.vue +2 -2
  44. package/src/components/message-dialog/MessageDialog.vue +11 -11
  45. package/src/components/message-dialog/message-dialog-state.ts +1 -4
  46. package/src/components/message-toast/MessageToast.vue +24 -9
  47. package/src/components/message-toast/message-toast-state.ts +20 -0
  48. package/src/components/page-header/PageHeader.vue +1 -1
  49. package/src/components/progress-component/ProgressComponent.vue +7 -3
  50. package/src/components/single-value-card/SingleValueCard.vue +37 -39
  51. package/src/components/slider-form-component/SliderFormComponent.vue +1 -1
  52. package/src/components/stat-strip/StatStrip.vue +36 -0
  53. package/src/components/stat-strip/index.ts +2 -0
  54. package/src/components/switch-form-component/SwitchFormComponent.vue +5 -5
  55. package/src/components/text-link/TextLink.vue +1 -1
  56. package/src/components/theme-selector/ThemeSelector.vue +2 -2
  57. package/src/components/theme-selector/theme-transition.ts +15 -3
  58. package/src/components/ui/accordion/Accordion.vue +18 -18
  59. package/src/components/ui/alert/AlertTitle.vue +1 -1
  60. package/src/components/ui/alert/index.ts +1 -1
  61. package/src/components/ui/avatar/AvatarFallback.vue +2 -1
  62. package/src/components/ui/avatar/AvatarGroup.vue +13 -3
  63. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +2 -2
  64. package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
  65. package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
  66. package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
  67. package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
  68. package/src/components/ui/busy/Busy.vue +5 -7
  69. package/src/components/ui/button/Button.vue +4 -2
  70. package/src/components/ui/button/index.ts +1 -4
  71. package/src/components/ui/calendar/Calendar.vue +12 -7
  72. package/src/components/ui/calendar/CalendarCell.vue +1 -1
  73. package/src/components/ui/calendar/CalendarCellTrigger.vue +1 -1
  74. package/src/components/ui/calendar/CalendarHeadCell.vue +1 -1
  75. package/src/components/ui/calendar/CalendarNextButton.vue +1 -1
  76. package/src/components/ui/calendar/CalendarPrevButton.vue +1 -1
  77. package/src/components/ui/card/Card.vue +5 -15
  78. package/src/components/ui/checkbox/Checkbox.vue +5 -1
  79. package/src/components/ui/combobox/ComboboxInput.vue +5 -1
  80. package/src/components/ui/command/CommandGroup.vue +17 -3
  81. package/src/components/ui/command/CommandInput.vue +1 -1
  82. package/src/components/ui/command/CommandItem.vue +2 -2
  83. package/src/components/ui/command/CommandList.vue +5 -1
  84. package/src/components/ui/data-table/DataTable.vue +11 -3
  85. package/src/components/ui/date-picker/DatePicker.vue +7 -1
  86. package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
  87. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  88. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +1 -1
  89. package/src/components/ui/dropdown-menu/classes.ts +2 -0
  90. package/src/components/ui/field/Field.vue +4 -0
  91. package/src/components/ui/field/FieldLabel.vue +6 -2
  92. package/src/components/ui/field/field-context.ts +62 -0
  93. package/src/components/ui/field/index.ts +1 -0
  94. package/src/components/ui/file-dropzone/FileDropzone.vue +4 -4
  95. package/src/components/ui/input/Input.vue +5 -1
  96. package/src/components/ui/native-select/NativeSelect.vue +6 -2
  97. package/src/components/ui/number-field/NumberField.vue +5 -1
  98. package/src/components/ui/pagination/PaginationItem.vue +1 -1
  99. package/src/components/ui/pin-input/PinInputInput.vue +2 -2
  100. package/src/components/ui/progress/Progress.vue +2 -2
  101. package/src/components/ui/radio-group/RadioGroupCard.vue +1 -1
  102. package/src/components/ui/select/SelectItem.vue +1 -1
  103. package/src/components/ui/select/SelectItemContent.vue +1 -1
  104. package/src/components/ui/select/SelectTrigger.vue +5 -1
  105. package/src/components/ui/sidebar/Sidebar.vue +1 -1
  106. package/src/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  107. package/src/components/ui/sidebar/SidebarGroupLabel.vue +1 -1
  108. package/src/components/ui/sidebar/SidebarInset.vue +1 -1
  109. package/src/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  110. package/src/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  111. package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +2 -2
  112. package/src/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  113. package/src/components/ui/sidebar/SidebarProvider.vue +17 -6
  114. package/src/components/ui/sidebar/index.ts +2 -2
  115. package/src/components/ui/sidebar/utils.ts +1 -1
  116. package/src/components/ui/slider/Slider.vue +5 -4
  117. package/src/components/ui/spinner/Spinner.vue +3 -2
  118. package/src/components/ui/stepper/StepperDescription.vue +5 -3
  119. package/src/components/ui/stepper/StepperIndicator.vue +2 -2
  120. package/src/components/ui/stepper/StepperTitle.vue +5 -3
  121. package/src/components/ui/switch/Switch.vue +6 -2
  122. package/src/components/ui/tabs/TabsList.vue +1 -1
  123. package/src/components/ui/tabs/TabsTrigger.vue +1 -1
  124. package/src/components/ui/textarea/Textarea.vue +5 -1
  125. package/src/components/ui/time-picker/TimePicker.vue +2 -2
  126. package/src/components/ui/toggle-group/ToggleGroup.vue +1 -1
  127. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  128. package/src/i18n/de.json +19 -7
  129. package/src/i18n/en.json +19 -7
  130. package/src/i18n/es.json +19 -7
  131. package/src/i18n/fr.json +19 -7
  132. package/src/i18n/it.json +19 -7
  133. package/src/i18n/ja.json +19 -7
  134. package/src/i18n/pt.json +19 -7
  135. package/src/i18n/ru.json +19 -7
  136. package/src/i18n/zh.json +19 -7
  137. package/src/lib/color-tokens.ts +1 -1
  138. package/src/lib/i18n.ts +32 -4
  139. package/src/lib/utils.ts +21 -1
  140. package/src/runtime/ui-vintage-i18n-plugin.ts +11 -1
  141. package/src/styles.css +52 -16
  142. package/skills/ui-vintage/SKILL.md +0 -42
@@ -4,7 +4,7 @@ import type { HTMLAttributes } from 'vue';
4
4
  import { ArrowDownRight01Icon, ArrowUpRight01Icon } from '@hugeicons/core-free-icons';
5
5
  import { HugeiconsIcon } from '@hugeicons/vue';
6
6
  import { AnimatedNumber } from '../ui/animated-number';
7
- import { Card, CardContent } from '../ui/card';
7
+ import { Card } from '../ui/card';
8
8
  import { cn } from '../../lib/utils';
9
9
  import type { HugeiconsIconDefinition } from '../../lib/common-types';
10
10
 
@@ -33,11 +33,11 @@ const props = withDefaults(defineProps<{
33
33
  const valueClass = computed(() => {
34
34
  switch (props.valueColor) {
35
35
  case 'green':
36
- return 'text-green-600 dark:text-green-400';
36
+ return 'text-success';
37
37
  case 'red':
38
- return 'text-red-600 dark:text-red-400';
38
+ return 'text-destructive';
39
39
  case 'gray':
40
- return 'text-gray-600 dark:text-gray-400';
40
+ return 'text-muted-foreground';
41
41
  default:
42
42
  return 'text-foreground';
43
43
  }
@@ -46,45 +46,43 @@ const valueClass = computed(() => {
46
46
  // Trend badge presentation
47
47
  const trendIsPositive = computed(() => (props.trend ?? 0) >= 0);
48
48
  const formattedTrend = computed(() => `${trendIsPositive.value ? '+' : ''}${props.trend}%`);
49
+
50
+ // Value shown without the count-up (numbers are formatted only when formatOptions is given)
51
+ const staticValue = computed(() => typeof props.value === 'number' && props.formatOptions ? new Intl.NumberFormat(undefined, props.formatOptions).format(props.value) : props.value);
49
52
  </script>
50
53
 
51
54
  <template>
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!">
54
- <!-- Icon -->
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" />
57
- </Card>
58
-
59
- <!-- Content -->
60
- <div class="flex min-w-0 flex-1 flex-col">
61
- <!-- Label -->
62
- <span class="mb-1 truncate text-xs font-semibold text-muted-foreground">
63
- {{ props.label }}
64
- </span>
55
+ <Card :class="cn('gap-0! p-4!', props.class)">
56
+ <!-- Label and icon -->
57
+ <div class="flex items-start justify-between gap-3">
58
+ <span class="min-w-0 text-[11px] font-semibold leading-5 tracking-wider text-muted-foreground uppercase">
59
+ {{ props.label }}
60
+ </span>
61
+ <span aria-hidden="true" class="flex size-8 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
62
+ <HugeiconsIcon :icon="props.icon" class="size-4" />
63
+ </span>
64
+ </div>
65
65
 
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>
66
+ <!-- Value and trend -->
67
+ <div class="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1">
68
+ <span :class="['text-2xl font-semibold tracking-tight tabular-nums', valueClass]">
69
+ <AnimatedNumber v-if="props.animate && typeof props.value === 'number'" :value="props.value" :format-options="props.formatOptions" />
70
+ <template v-else>{{ staticValue }}</template>
71
+ </span>
72
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>
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-semibold tabular-nums', trendIsPositive ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive')">
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
82
 
83
- <!-- Description -->
84
- <span v-if="props.description" class="mt-1 text-xs text-muted-foreground">
85
- {{ props.description }}
86
- </span>
87
- </div>
88
- </CardContent>
83
+ <!-- Description -->
84
+ <span v-if="props.description" class="mt-1 text-xs leading-5 text-muted-foreground">
85
+ {{ props.description }}
86
+ </span>
89
87
  </Card>
90
- </template>
88
+ </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>
@@ -0,0 +1,2 @@
1
+ export { default } from './StatStrip.vue';
2
+ export { default as StatStrip } from './StatStrip.vue';
@@ -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
- <div class="text-sm font-semibold">
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
- </div>
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-(--text-primary-light) hover:underline dark:text-(--text-primary-dark)" @click="handleClick">
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>
@@ -98,8 +98,8 @@ onUnmounted(() => {
98
98
  <!-- Theme options -->
99
99
  <PopoverContent side="bottom" align="end" :side-offset="8" class="w-44 p-0!">
100
100
  <Command :model-value="currentTheme">
101
- <CommandList>
102
- <CommandGroup :heading="t('uiVintage.theme.title')">
101
+ <CommandList :aria-label="t('uiVintage.theme.title')">
102
+ <CommandGroup :heading="t('uiVintage.theme.title')" stagger>
103
103
  <CommandItem v-for="option in themeOptions" :key="option.key" :value="option.key" @select="handleSelectTheme(option.key)">
104
104
  <HugeiconsIcon :icon="option.icon" class="size-4" />
105
105
  <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
- const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
31
- return storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'auto';
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
- localStorage.setItem(THEME_STORAGE_KEY, theme);
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)
@@ -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 sm:gap-0! p-0! data-[state=open]:border-border-strong', props.class)">
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="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)" />
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
- <!-- Title -->
53
- <div class="text-left text-sm font-semibold text-foreground md:text-base!">
54
- {{ props.title }}
55
- </div>
56
- </div>
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
- <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>
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 pb-5 transition-[translate,filter] duration-[420ms] ease-spring', expanded ? 'translate-y-0 blur-0' : '-translate-y-2 blur-[2px]']">
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-bold tracking-tight text-current', props.class)">
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
- <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)">
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>
@@ -38,6 +38,15 @@ function getChildren() {
38
38
  return flatten(slots.default?.() ?? []);
39
39
  }
40
40
 
41
+ // Overlap per size, kept clear of the initials so stacked fallbacks stay readable
42
+ const overlapClasses: Record<AvatarSize, string> = {
43
+ xs: '-space-x-1',
44
+ sm: '-space-x-1.5',
45
+ md: '-space-x-2',
46
+ lg: '-space-x-2.5',
47
+ xl: '-space-x-3'
48
+ };
49
+
41
50
  // Whether a max limit applies
42
51
  function hasMax() {
43
52
  return props.max !== undefined && props.max >= 0;
@@ -60,15 +69,16 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node;
60
69
 
61
70
  <template>
62
71
  <div data-slot="avatar-group" :class="cn(
63
- 'flex items-center -space-x-2 hover:-space-x-0.5',
64
- '*:ring-2 *:ring-background *:transition-[margin,translate] *:duration-380 *:ease-spring *:hover:-translate-y-0.5',
72
+ 'flex items-center',
73
+ overlapClasses[props.size],
74
+ '*:ring-2 *:ring-background',
65
75
  props.class
66
76
  )">
67
77
  <!-- Visible avatars -->
68
78
  <VNodeRenderer v-for="(node, index) in getVisible()" :key="node.key ?? index" :node="node" />
69
79
 
70
80
  <!-- Overflow bubble -->
71
- <span v-if="getOverflow() > 0" data-slot="avatar-group-overflow" :class="cn('relative inline-flex shrink-0 select-none items-center justify-center bg-surface font-bold text-muted-foreground', avatarSizeClasses[props.size], avatarShapeClasses[props.shape], avatarTextClasses[props.size])">
81
+ <span v-if="getOverflow() > 0" data-slot="avatar-group-overflow" :class="cn('relative inline-flex shrink-0 select-none items-center justify-center bg-surface font-semibold text-muted-foreground', avatarSizeClasses[props.size], avatarShapeClasses[props.shape], avatarTextClasses[props.size])">
72
82
  +{{ getOverflow() }}
73
83
  </span>
74
84
  </div>
@@ -14,10 +14,10 @@ const { t } = useI18n();
14
14
  </script>
15
15
 
16
16
  <template>
17
- <span data-slot="breadcrumb-ellipsis" role="presentation" aria-hidden="true" :class="cn('flex size-9 items-center justify-center', props.class)">
17
+ <span data-slot="breadcrumb-ellipsis" role="presentation" :class="cn('flex size-6 items-center justify-center rounded-sm transition-colors duration-150 hover:bg-accent hover:text-primary', props.class)">
18
18
  <!-- Default slot content -->
19
19
  <slot>
20
- <HugeiconsIcon :icon="MoreHorizontalIcon" class="size-4" />
20
+ <HugeiconsIcon :icon="MoreHorizontalIcon" class="size-4" aria-hidden="true" />
21
21
  </slot>
22
22
 
23
23
  <!-- Screen reader text for accessibility -->
@@ -11,7 +11,7 @@ const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes
11
11
  </script>
12
12
 
13
13
  <template>
14
- <Primitive data-slot="breadcrumb-link" :as="as" :as-child="asChild" :class="cn('text-muted-foreground hover:underline', props.class)">
14
+ <Primitive data-slot="breadcrumb-link" :as="as" :as-child="asChild" :class="cn('rounded-sm bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground outline-none transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary focus-visible:ring-[3px] focus-visible:ring-ring/45', props.class)">
15
15
  <slot />
16
16
  </Primitive>
17
17
  </template>
@@ -9,7 +9,7 @@ const props = defineProps<{
9
9
  </script>
10
10
 
11
11
  <template>
12
- <ol data-slot="breadcrumb-list" :class="cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5', props.class)">
12
+ <ol data-slot="breadcrumb-list" :class="cn('flex w-fit max-w-full flex-wrap items-center gap-1.5 rounded border border-border bg-card px-3 py-1.5 text-xs wrap-break-word text-muted-foreground shadow-elevated-sm sm:gap-2 sm:text-sm', props.class)">
13
13
  <slot />
14
14
  </ol>
15
15
  </template>
@@ -9,7 +9,7 @@ const props = defineProps<{
9
9
  </script>
10
10
 
11
11
  <template>
12
- <span data-slot="breadcrumb-page" role="link" aria-disabled="true" aria-current="page" :class="cn('text-foreground font-normal', props.class)">
12
+ <span data-slot="breadcrumb-page" role="link" aria-disabled="true" aria-current="page" :class="cn('min-w-0 font-semibold text-foreground', props.class)">
13
13
  <slot />
14
14
  </span>
15
15
  </template>
@@ -1,7 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import type { HTMLAttributes } from 'vue';
3
- import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
4
- import { HugeiconsIcon } from '@hugeicons/vue';
5
3
  import { cn } from '../../../lib/utils';
6
4
 
7
5
  // Props
@@ -11,9 +9,7 @@ const props = defineProps<{
11
9
  </script>
12
10
 
13
11
  <template>
14
- <li data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" :class="cn('[&>svg]:size-3.5', props.class)">
15
- <slot>
16
- <HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5" />
17
- </slot>
12
+ <li data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" :class="cn('font-semibold text-primary/50 select-none [&>svg]:size-3.5', props.class)">
13
+ <slot>/</slot>
18
14
  </li>
19
15
  </template>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, type HTMLAttributes } from 'vue';
3
- import { getUiVintageRuntimeMessage } from '../../../lib/i18n';
3
+ import { useUiVintageMessage } from '../../../lib/i18n';
4
4
  import { cn } from '../../../lib/utils';
5
5
  import { Spinner } from '../spinner';
6
6
 
@@ -14,7 +14,8 @@ const props = withDefaults(defineProps<{
14
14
  show: false
15
15
  });
16
16
 
17
- const resolvedLabel = computed(() => props.label || getUiVintageRuntimeMessage('uiVintage.common.loading.title', 'Loading...'));
17
+ const message = useUiVintageMessage();
18
+ const resolvedLabel = computed(() => props.label || message('uiVintage.common.loading.title', 'Loading...'));
18
19
  </script>
19
20
 
20
21
  <template>
@@ -28,11 +29,8 @@ const resolvedLabel = computed(() => props.label || getUiVintageRuntimeMessage('
28
29
  props.class,
29
30
  )">
30
31
  <slot>
31
- <!-- Spinner with a soft glow -->
32
- <div class="relative flex items-center justify-center">
33
- <span class="absolute size-14 rounded-full bg-primary/15 blur-md" />
34
- <Spinner data-slot="busy-spinner" size="xl" class="text-primary" />
35
- </div>
32
+ <!-- Spinner (decorative: the container already announces the label) -->
33
+ <Spinner data-slot="busy-spinner" size="xl" aria-hidden="true" class="text-primary" />
36
34
 
37
35
  <!-- Label -->
38
36
  <span v-if="resolvedLabel" data-slot="busy-label" class="text-xs font-semibold text-foreground sm:text-sm">
@@ -12,15 +12,17 @@ const props = withDefaults(defineProps<PrimitiveProps & {
12
12
  variant?: ButtonVariants['variant'];
13
13
  size?: ButtonVariants['size'];
14
14
  loading?: boolean;
15
+ disabled?: boolean;
15
16
  class?: HTMLAttributes['class'];
16
17
  }>(), {
17
18
  as: 'button',
18
- loading: false
19
+ loading: false,
20
+ disabled: false
19
21
  });
20
22
  </script>
21
23
 
22
24
  <template>
23
- <Primitive data-slot="button" :data-variant="variant" :data-size="size" :data-loading="props.loading ? '' : undefined" :aria-busy="props.loading || undefined" :disabled="props.loading || undefined" :as="as" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)">
25
+ <Primitive data-slot="button" :data-variant="variant" :data-size="size" :data-loading="props.loading ? '' : undefined" :aria-busy="props.loading || undefined" :disabled="props.disabled || props.loading || undefined" :as="as" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)">
24
26
  <!-- As-child buttons delegate rendering to the slotted element -->
25
27
  <slot v-if="asChild" />
26
28
 
@@ -28,9 +28,6 @@ export type ButtonVariants = {
28
28
  | undefined;
29
29
  };
30
30
 
31
- // Sheen sweep that crosses solid buttons on hover
32
- const sheenClasses = "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:-translate-x-full before:bg-[linear-gradient(105deg,transparent_35%,rgb(255_255_255/0.38)_50%,transparent_65%)] before:transition-transform before:duration-700 before:ease-out-expo hover:before:translate-x-full";
33
-
34
31
  export const buttonVariants: (props?: ButtonVariants & ClassProp) => string = cva(
35
32
  [
36
33
  "relative isolate inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded text-xs font-semibold outline-none sm:text-sm",
@@ -43,7 +40,7 @@ export const buttonVariants: (props?: ButtonVariants & ClassProp) => string = cv
43
40
  {
44
41
  variants: {
45
42
  variant: {
46
- primary: `${getButtonVariantClasses('primary')} ${sheenClasses}`,
43
+ primary: getButtonVariantClasses('primary'),
47
44
  secondary: getButtonVariantClasses('secondary'),
48
45
  ghost: getButtonVariantClasses('ghost'),
49
46
  link: getButtonVariantClasses('link'),
@@ -6,7 +6,8 @@ import { getLocalTimeZone, today } from '@internationalized/date';
6
6
  import { createReusableTemplate, reactiveOmit, useVModel } from '@vueuse/core';
7
7
  import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from 'reka-ui';
8
8
  import { createYear, createYearRange, toDate } from 'reka-ui/date';
9
- import { computed, toRaw } from 'vue';
9
+ import { computed, toRaw, watch } from 'vue';
10
+ import { useI18n } from 'vue-i18n';
10
11
  import { cn } from '../../../lib/utils';
11
12
  import { NativeSelect, NativeSelectOption } from '../native-select';
12
13
  import {
@@ -43,7 +44,11 @@ const placeholder = useVModel(props, 'placeholder', emits, {
43
44
  defaultValue: props.defaultPlaceholder ?? today(getLocalTimeZone()),
44
45
  }) as Ref<DateValue>;
45
46
 
46
- const formatter = useDateFormatter(props.locale ?? 'en');
47
+ // Follow the app language unless a locale is passed explicitly
48
+ const { t, locale: appLocale } = useI18n();
49
+ const resolvedLocale = computed(() => props.locale || appLocale.value || 'en');
50
+ const formatter = useDateFormatter(resolvedLocale.value);
51
+ watch(resolvedLocale, value => formatter.setLocale(value));
47
52
 
48
53
  const yearRange = computed(() => {
49
54
  return (
@@ -81,11 +86,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
81
86
  <template>
82
87
  <DefineMonthTemplate v-slot="{ date }">
83
88
  <div class="**:data-[slot=native-select-icon]:right-1">
84
- <div class="relative h-10 rounded border border-(--border-light) bg-(--button-secondary-light) transition-all duration-300 ease-in-out hover:border-(--border-hover-light) focus-within:border-(--border-hover-light) dark:border-(--border-dark) dark:bg-(--button-secondary-dark) dark:hover:border-(--border-hover-dark) dark:focus-within:border-(--border-hover-dark)">
89
+ <div class="relative h-10 rounded border border-border bg-secondary transition-all duration-300 ease-in-out hover:border-border-strong focus-within:border-border-strong">
85
90
  <div class="absolute inset-0 z-10 flex h-full items-center text-sm pl-2 pointer-events-none">
86
91
  {{ formatter.custom(toDate(date), { month: 'short' }) }}
87
92
  </div>
88
- <NativeSelect class="relative z-20 h-full min-h-0 w-23 rounded-none border-0 bg-transparent pl-2 pr-6 text-xs font-semibold text-transparent shadow-none transition-all duration-300 ease-in-out outline-none ring-0 focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent sm:text-sm" :model-value="date.month" @change="
93
+ <NativeSelect :aria-label="t('uiVintage.datePicker.month')" class="relative z-20 h-full min-h-0 w-23 rounded-none border-0 bg-transparent pl-2 pr-6 text-xs font-semibold text-transparent shadow-none transition-all duration-300 ease-in-out outline-none ring-0 focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent sm:text-sm" :model-value="date.month" @change="
89
94
  (e: Event) => {
90
95
  placeholder = placeholder.set({
91
96
  month: Number((e?.target as any)?.value),
@@ -104,11 +109,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
104
109
 
105
110
  <DefineYearTemplate v-slot="{ date }">
106
111
  <div class="**:data-[slot=native-select-icon]:right-1">
107
- <div class="relative h-10 rounded border border-(--border-light) bg-(--button-secondary-light) transition-all duration-300 ease-in-out hover:border-(--border-hover-light) focus-within:border-(--border-hover-light) dark:border-(--border-dark) dark:bg-(--button-secondary-dark) dark:hover:border-(--border-hover-dark) dark:focus-within:border-(--border-hover-dark)">
112
+ <div class="relative h-10 rounded border border-border bg-secondary transition-all duration-300 ease-in-out hover:border-border-strong focus-within:border-border-strong">
108
113
  <div class="absolute inset-0 z-10 flex h-full items-center text-sm pl-2 pointer-events-none">
109
114
  {{ formatter.custom(toDate(date), { year: 'numeric' }) }}
110
115
  </div>
111
- <NativeSelect class="relative z-20 h-full min-h-0 w-24 rounded-none border-0 bg-transparent pl-2 pr-6 text-xs font-semibold text-transparent shadow-none transition-all duration-300 ease-in-out outline-none ring-0 focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent sm:text-sm" :model-value="date.year" @change="
116
+ <NativeSelect :aria-label="t('uiVintage.datePicker.year')" class="relative z-20 h-full min-h-0 w-24 rounded-none border-0 bg-transparent pl-2 pr-6 text-xs font-semibold text-transparent shadow-none transition-all duration-300 ease-in-out outline-none ring-0 focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent sm:text-sm" :model-value="date.year" @change="
112
117
  (e: Event) => {
113
118
  placeholder = placeholder.set({
114
119
  year: Number((e?.target as any)?.value),
@@ -125,7 +130,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
125
130
  </div>
126
131
  </DefineYearTemplate>
127
132
 
128
- <CalendarRoot v-slot="{ grid, weekDays, date }" v-bind="forwarded" v-model:placeholder="placeholder" data-slot="calendar" :class="cn('p-3', props.class)">
133
+ <CalendarRoot v-slot="{ grid, weekDays, date }" v-bind="forwarded" v-model:placeholder="placeholder" :locale="resolvedLocale" data-slot="calendar" :class="cn('p-3', props.class)">
129
134
  <CalendarHeader class="pt-0">
130
135
  <nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
131
136
  <CalendarPrevButton>
@@ -15,7 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps);
15
15
 
16
16
  <template>
17
17
  <CalendarCell data-slot="calendar-cell" :class="cn(
18
- 'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 flex-1 [&:has([data-selected])]:rounded-md',
18
+ 'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 flex-1 [&:has([data-selected])]:rounded',
19
19
  props.class,
20
20
  )
21
21
  " v-bind="forwardedProps">
@@ -26,7 +26,7 @@ const forwardedProps = useForwardProps(delegatedProps);
26
26
  // Disabled
27
27
  'data-disabled:text-muted-foreground data-disabled:opacity-50',
28
28
  // Unavailable
29
- 'data-unavailable:text-destructive-foreground data-unavailable:line-through',
29
+ 'data-unavailable:text-destructive data-unavailable:line-through',
30
30
  // Outside months
31
31
  'data-outside-view:text-muted-foreground',
32
32
  props.class,
@@ -15,7 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps);
15
15
 
16
16
  <template>
17
17
  <CalendarHeadCell data-slot="calendar-head-cell" :class="cn(
18
- 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem]',
18
+ 'text-muted-foreground rounded flex-1 font-normal text-[0.8rem]',
19
19
  props.class,
20
20
  )
21
21
  " v-bind="forwardedProps">
@@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps);
22
22
  :class="
23
23
  cn(
24
24
  buttonVariants({ variant: 'secondary' }),
25
- 'size-7 min-h-10 bg-transparent p-0 text-(--text-secondary-light) hover:text-(--text-primary-light) dark:text-(--text-secondary-dark) dark:hover:text-(--text-primary-dark)',
25
+ 'size-7 min-h-10 bg-transparent p-0 text-muted-foreground hover:text-foreground',
26
26
  props.class,
27
27
  )
28
28
  "