@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.
Files changed (191) hide show
  1. package/COMPONENTS.md +3602 -0
  2. package/README.md +49 -9
  3. package/package.json +29 -8
  4. package/skills/ui-vintage/SKILL.md +42 -0
  5. package/src/components/blog/BlogFAQSection.vue +2 -2
  6. package/src/components/blog/BlogHeaderSection.vue +2 -2
  7. package/src/components/blog/BlogInfoSection.vue +3 -3
  8. package/src/components/blog/BlogProgressBar.vue +7 -6
  9. package/src/components/blog/BlogSectionTitle.vue +1 -1
  10. package/src/components/blog/BlogTagsSection.vue +22 -0
  11. package/src/components/blog/CategoryCard.vue +1 -1
  12. package/src/components/blog/HeaderCarousel.vue +6 -6
  13. package/src/components/blog/PostCard.vue +2 -2
  14. package/src/components/blog/SocialShareSidebar.vue +1 -1
  15. package/src/components/blog/TableOfContents.vue +6 -6
  16. package/src/components/blog/index.ts +1 -0
  17. package/src/components/busy-indicator/busy-indicator-state.ts +3 -2
  18. package/src/components/chip/Chip.vue +9 -5
  19. package/src/components/confirm-dialog/ConfirmDialog.vue +17 -6
  20. package/src/components/confirm-dialog/confirm-dialog-state.ts +4 -3
  21. package/src/components/content/BlogList.vue +3 -3
  22. package/src/components/content/BlogTable.vue +7 -7
  23. package/src/components/content/CodeBlock.vue +4 -4
  24. package/src/components/content/Flow.vue +3 -3
  25. package/src/components/content/ProseHr.vue +2 -2
  26. package/src/components/content/Terminal.vue +4 -4
  27. package/src/components/dashboard-shell/DashboardShell.vue +3 -3
  28. package/src/components/empty-state-card/EmptyStateCard.vue +4 -4
  29. package/src/components/error-page/ErrorPage.vue +16 -14
  30. package/src/components/info-card/InfoCard.vue +1 -1
  31. package/src/components/landing/landing-navbar/LandingNavbar.vue +2 -1
  32. package/src/components/load-more-button/LoadMoreButton.vue +2 -2
  33. package/src/components/loading-state-card/LoadingStateCard.vue +4 -4
  34. package/src/components/message-dialog/MessageDialog.vue +4 -4
  35. package/src/components/message-toast/MessageToast.vue +222 -50
  36. package/src/components/message-toast/index.ts +2 -2
  37. package/src/components/message-toast/message-toast-state.ts +107 -81
  38. package/src/components/page-header/PageHeader.vue +1 -1
  39. package/src/components/progress-component/ProgressComponent.vue +4 -4
  40. package/src/components/single-value-card/SingleValueCard.vue +47 -11
  41. package/src/components/theme-selector/ThemeSelector.vue +20 -36
  42. package/src/components/theme-selector/index.ts +3 -1
  43. package/src/components/theme-selector/theme-transition.ts +86 -0
  44. package/src/components/ui/accordion/Accordion.vue +41 -26
  45. package/src/components/ui/alert-dialog/AlertDialogContent.vue +2 -2
  46. package/src/components/ui/alert-dialog/AlertDialogFooter.vue +1 -1
  47. package/src/components/ui/animated-number/AnimatedNumber.vue +88 -0
  48. package/src/components/ui/animated-number/index.ts +1 -0
  49. package/src/components/ui/avatar/Avatar.vue +66 -0
  50. package/src/components/ui/avatar/AvatarFallback.vue +31 -0
  51. package/src/components/ui/avatar/AvatarGroup.vue +75 -0
  52. package/src/components/ui/avatar/AvatarImage.vue +23 -0
  53. package/src/components/ui/avatar/index.ts +47 -0
  54. package/src/components/ui/badge/Badge.vue +13 -2
  55. package/src/components/ui/breadcrumb/Breadcrumb.vue +4 -1
  56. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +7 -1
  57. package/src/components/ui/busy/Busy.vue +45 -20
  58. package/src/components/ui/button/Button.vue +20 -3
  59. package/src/components/ui/button/index.ts +12 -2
  60. package/src/components/ui/card/Card.vue +21 -8
  61. package/src/components/ui/card/CardText.vue +1 -1
  62. package/src/components/ui/checkbox/Checkbox.vue +48 -0
  63. package/src/components/ui/checkbox/index.ts +1 -0
  64. package/src/components/ui/collapsible/Collapsible.vue +22 -0
  65. package/src/components/ui/collapsible/CollapsibleContent.vue +22 -0
  66. package/src/components/ui/collapsible/CollapsibleTrigger.vue +19 -0
  67. package/src/components/ui/collapsible/index.ts +3 -0
  68. package/src/components/ui/combobox/Combobox.vue +24 -0
  69. package/src/components/ui/combobox/ComboboxAnchor.vue +39 -0
  70. package/src/components/ui/combobox/ComboboxEmpty.vue +21 -0
  71. package/src/components/ui/combobox/ComboboxGroup.vue +26 -0
  72. package/src/components/ui/combobox/ComboboxInput.vue +28 -0
  73. package/src/components/ui/combobox/ComboboxItem.vue +39 -0
  74. package/src/components/ui/combobox/ComboboxList.vue +43 -0
  75. package/src/components/ui/combobox/ComboboxSelect.vue +162 -0
  76. package/src/components/ui/combobox/ComboboxSeparator.vue +16 -0
  77. package/src/components/ui/combobox/ComboboxTrigger.vue +28 -0
  78. package/src/components/ui/combobox/index.ts +11 -0
  79. package/src/components/ui/combobox/types.ts +16 -0
  80. package/src/components/ui/command/CommandDialog.vue +18 -16
  81. package/src/components/ui/command/CommandGroup.vue +1 -1
  82. package/src/components/ui/command/CommandInput.vue +2 -2
  83. package/src/components/ui/command/CommandItem.vue +7 -5
  84. package/src/components/ui/data-table/DataTable.vue +230 -0
  85. package/src/components/ui/data-table/index.ts +20 -0
  86. package/src/components/ui/date-picker/DatePicker.vue +11 -15
  87. package/src/components/ui/date-time-picker/DateTimePicker.vue +2 -7
  88. package/src/components/ui/dialog/DialogContent.vue +9 -4
  89. package/src/components/ui/dialog/DialogOverlay.vue +1 -1
  90. package/src/components/ui/dialog/DialogScrollContent.vue +10 -5
  91. package/src/components/ui/dropdown-menu/DropdownMenu.vue +18 -0
  92. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +34 -0
  93. package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
  94. package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +13 -0
  95. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +34 -0
  96. package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +26 -0
  97. package/src/components/ui/dropdown-menu/DropdownMenuPortal.vue +13 -0
  98. package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +18 -0
  99. package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +32 -0
  100. package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +16 -0
  101. package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +15 -0
  102. package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +18 -0
  103. package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +39 -0
  104. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +36 -0
  105. package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +15 -0
  106. package/src/components/ui/dropdown-menu/classes.ts +24 -0
  107. package/src/components/ui/dropdown-menu/index.ts +15 -0
  108. package/src/components/ui/field/FieldDescription.vue +1 -1
  109. package/src/components/ui/field/FieldError.vue +4 -1
  110. package/src/components/ui/file-dropzone/FileDropzone.vue +250 -0
  111. package/src/components/ui/file-dropzone/index.ts +35 -0
  112. package/src/components/ui/hover-card/HoverCard.vue +21 -0
  113. package/src/components/ui/hover-card/HoverCardContent.vue +33 -0
  114. package/src/components/ui/hover-card/HoverCardTrigger.vue +13 -0
  115. package/src/components/ui/hover-card/index.ts +3 -0
  116. package/src/components/ui/input/Input.vue +5 -5
  117. package/src/components/ui/kbd/Kbd.vue +21 -0
  118. package/src/components/ui/kbd/KbdGroup.vue +72 -0
  119. package/src/components/ui/kbd/index.ts +2 -0
  120. package/src/components/ui/number-field/NumberField.vue +130 -0
  121. package/src/components/ui/number-field/index.ts +1 -0
  122. package/src/components/ui/pagination/Pagination.vue +27 -0
  123. package/src/components/ui/pagination/PaginationContent.vue +20 -0
  124. package/src/components/ui/pagination/PaginationEllipsis.vue +31 -0
  125. package/src/components/ui/pagination/PaginationFirst.vue +32 -0
  126. package/src/components/ui/pagination/PaginationItem.vue +41 -0
  127. package/src/components/ui/pagination/PaginationLast.vue +32 -0
  128. package/src/components/ui/pagination/PaginationNext.vue +32 -0
  129. package/src/components/ui/pagination/PaginationPrevious.vue +32 -0
  130. package/src/components/ui/pagination/SimplePagination.vue +49 -0
  131. package/src/components/ui/pagination/index.ts +9 -0
  132. package/src/components/ui/pin-input/PinInput.vue +48 -0
  133. package/src/components/ui/pin-input/PinInputInput.vue +49 -0
  134. package/src/components/ui/pin-input/context.ts +8 -0
  135. package/src/components/ui/pin-input/index.ts +2 -0
  136. package/src/components/ui/popover/PopoverContent.vue +1 -1
  137. package/src/components/ui/progress/Progress.vue +22 -7
  138. package/src/components/ui/radio-group/RadioGroup.vue +27 -0
  139. package/src/components/ui/radio-group/RadioGroupCard.vue +56 -0
  140. package/src/components/ui/radio-group/RadioGroupItem.vue +40 -0
  141. package/src/components/ui/radio-group/index.ts +3 -0
  142. package/src/components/ui/select/SelectContent.vue +1 -1
  143. package/src/components/ui/select/SelectItem.vue +2 -2
  144. package/src/components/ui/select/SelectItemContent.vue +1 -1
  145. package/src/components/ui/select/SelectTrigger.vue +6 -6
  146. package/src/components/ui/select/SelectValueContent.vue +1 -1
  147. package/src/components/ui/sheet/SheetContent.vue +5 -5
  148. package/src/components/ui/sheet/SheetOverlay.vue +1 -1
  149. package/src/components/ui/sidebar/Sidebar.vue +7 -5
  150. package/src/components/ui/sidebar/SidebarRail.vue +3 -1
  151. package/src/components/ui/sidebar/SidebarTrigger.vue +3 -1
  152. package/src/components/ui/skeleton/Skeleton.vue +4 -4
  153. package/src/components/ui/slider/Slider.vue +8 -6
  154. package/src/components/ui/spinner/Spinner.vue +47 -0
  155. package/src/components/ui/spinner/index.ts +1 -0
  156. package/src/components/ui/stepper/SimpleStepper.vue +56 -0
  157. package/src/components/ui/stepper/Stepper.vue +30 -0
  158. package/src/components/ui/stepper/StepperDescription.vue +20 -0
  159. package/src/components/ui/stepper/StepperIndicator.vue +41 -0
  160. package/src/components/ui/stepper/StepperItem.vue +21 -0
  161. package/src/components/ui/stepper/StepperSeparator.vue +34 -0
  162. package/src/components/ui/stepper/StepperTitle.vue +20 -0
  163. package/src/components/ui/stepper/StepperTrigger.vue +20 -0
  164. package/src/components/ui/stepper/index.ts +17 -0
  165. package/src/components/ui/switch/Switch.vue +8 -6
  166. package/src/components/ui/tabs/TabsContent.vue +1 -1
  167. package/src/components/ui/tabs/TabsList.vue +13 -5
  168. package/src/components/ui/tabs/TabsTrigger.vue +5 -3
  169. package/src/components/ui/tags-input/TagsInput.vue +49 -0
  170. package/src/components/ui/tags-input/TagsInputInput.vue +23 -0
  171. package/src/components/ui/tags-input/TagsInputItem.vue +35 -0
  172. package/src/components/ui/tags-input/TagsInputItemDelete.vue +31 -0
  173. package/src/components/ui/tags-input/TagsInputItemText.vue +21 -0
  174. package/src/components/ui/tags-input/index.ts +5 -0
  175. package/src/components/ui/textarea/Textarea.vue +4 -5
  176. package/src/components/ui/toggle-group/Toggle.vue +33 -0
  177. package/src/components/ui/toggle-group/ToggleGroup.vue +93 -0
  178. package/src/components/ui/toggle-group/ToggleGroupItem.vue +34 -0
  179. package/src/components/ui/toggle-group/index.ts +69 -0
  180. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  181. package/src/i18n/de.json +76 -0
  182. package/src/i18n/en.json +76 -0
  183. package/src/i18n/es.json +76 -0
  184. package/src/i18n/fr.json +76 -0
  185. package/src/i18n/it.json +76 -0
  186. package/src/i18n/ja.json +76 -0
  187. package/src/i18n/pt.json +76 -0
  188. package/src/i18n/ru.json +76 -0
  189. package/src/i18n/zh.json +76 -0
  190. package/src/lib/color-tokens.ts +14 -14
  191. 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-(--text-primary-light) dark:text-(--text-primary-dark)';
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 h-12 w-12 shrink-0 items-center justify-center p-0!">
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-(--text-secondary-light) dark:text-(--text-secondary-dark)">
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
- <span :class="['text-2xl md:text-xl font-bold', valueClass]">
49
- {{ props.value }}
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: string) => {
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 = localStorage.getItem('theme') || 'auto';
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
- applyTheme('auto');
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
- <HugeiconsIcon :icon="currentThemeIcon" class="h-5 w-5" />
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, CardContent } from '@brumaombra/ui-vintage/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
- const expanded = ref(props.initiallyExpanded);
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
- expanded.value = !expanded.value;
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="p-0! overflow-hidden">
29
- <CardContent class="p-0! gap-0">
30
- <!-- Header -->
31
- <div class="flex items-center justify-between p-5 gap-4 text-left cursor-pointer" @click="toggleExpanded">
32
- <div class="flex items-center gap-3 min-w-0">
33
- <!-- Icon -->
34
- <div v-if="props.icon" class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-(--bg-card-light) border border-(--border-light) dark:bg-(--bg-card-dark) dark:border-(--border-dark) md:h-10 md:w-10">
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
- <!-- Toggle icon -->
45
- <div class="shrink-0 w-8 h-8 flex items-center justify-center rounded bg-(--bg-card-light) dark:bg-(--bg-card-dark) border border-(--border-light) dark:border-(--border-dark) transition-transform duration-300 cursor-pointer" :class="{ 'rotate-180': expanded }">
46
- <HugeiconsIcon :icon="ArrowDown01Icon" class="size-4 text-(--text-secondary-light) dark:text-(--text-secondary-dark)" />
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
- <!-- Content -->
51
- <div :class="['grid transition-all duration-300 ease-in-out', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0']">
52
- <div :class="['overflow-hidden px-5 transition-[padding] duration-300 ease-in-out', expanded ? 'pb-5' : 'pb-0']">
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
- </CardContent>
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/45 backdrop-blur-[2px] transition-[opacity,backdrop-filter] ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
29
- <AlertDialogContent data-slot="alert-dialog-content" v-bind="{ ...$attrs, ...forwarded }" :class="cn('bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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-2xl duration-200 sm:max-w-lg', props.class)">
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>