@brumaombra/ui-vintage 0.7.13 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/COMPONENTS.md +3643 -0
  2. package/README.md +50 -10
  3. package/package.json +30 -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/cookie-consent/CookieConsent.vue +82 -0
  28. package/src/components/cookie-consent/index.ts +3 -0
  29. package/src/components/cookie-consent/useCookieConsent.ts +55 -0
  30. package/src/components/dashboard-shell/DashboardShell.vue +3 -3
  31. package/src/components/empty-state-card/EmptyStateCard.vue +4 -4
  32. package/src/components/error-page/ErrorPage.vue +16 -14
  33. package/src/components/info-card/InfoCard.vue +1 -1
  34. package/src/components/landing/landing-navbar/LandingNavbar.vue +2 -1
  35. package/src/components/load-more-button/LoadMoreButton.vue +2 -2
  36. package/src/components/loading-state-card/LoadingStateCard.vue +4 -4
  37. package/src/components/message-dialog/MessageDialog.vue +4 -4
  38. package/src/components/message-toast/MessageToast.vue +222 -50
  39. package/src/components/message-toast/index.ts +2 -2
  40. package/src/components/message-toast/message-toast-state.ts +107 -81
  41. package/src/components/page-header/PageHeader.vue +1 -1
  42. package/src/components/progress-component/ProgressComponent.vue +4 -4
  43. package/src/components/single-value-card/SingleValueCard.vue +47 -11
  44. package/src/components/theme-selector/ThemeSelector.vue +20 -36
  45. package/src/components/theme-selector/index.ts +3 -1
  46. package/src/components/theme-selector/theme-transition.ts +86 -0
  47. package/src/components/ui/accordion/Accordion.vue +41 -26
  48. package/src/components/ui/alert-dialog/AlertDialogContent.vue +2 -2
  49. package/src/components/ui/alert-dialog/AlertDialogFooter.vue +1 -1
  50. package/src/components/ui/animated-number/AnimatedNumber.vue +102 -0
  51. package/src/components/ui/animated-number/index.ts +1 -0
  52. package/src/components/ui/avatar/Avatar.vue +66 -0
  53. package/src/components/ui/avatar/AvatarFallback.vue +31 -0
  54. package/src/components/ui/avatar/AvatarGroup.vue +75 -0
  55. package/src/components/ui/avatar/AvatarImage.vue +23 -0
  56. package/src/components/ui/avatar/index.ts +47 -0
  57. package/src/components/ui/badge/Badge.vue +13 -2
  58. package/src/components/ui/breadcrumb/Breadcrumb.vue +4 -1
  59. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +7 -1
  60. package/src/components/ui/busy/Busy.vue +45 -20
  61. package/src/components/ui/button/Button.vue +20 -3
  62. package/src/components/ui/button/index.ts +12 -2
  63. package/src/components/ui/card/Card.vue +21 -8
  64. package/src/components/ui/card/CardText.vue +1 -1
  65. package/src/components/ui/checkbox/Checkbox.vue +48 -0
  66. package/src/components/ui/checkbox/index.ts +1 -0
  67. package/src/components/ui/collapsible/Collapsible.vue +22 -0
  68. package/src/components/ui/collapsible/CollapsibleContent.vue +22 -0
  69. package/src/components/ui/collapsible/CollapsibleTrigger.vue +19 -0
  70. package/src/components/ui/collapsible/index.ts +3 -0
  71. package/src/components/ui/combobox/Combobox.vue +24 -0
  72. package/src/components/ui/combobox/ComboboxAnchor.vue +39 -0
  73. package/src/components/ui/combobox/ComboboxEmpty.vue +21 -0
  74. package/src/components/ui/combobox/ComboboxGroup.vue +26 -0
  75. package/src/components/ui/combobox/ComboboxInput.vue +28 -0
  76. package/src/components/ui/combobox/ComboboxItem.vue +39 -0
  77. package/src/components/ui/combobox/ComboboxList.vue +43 -0
  78. package/src/components/ui/combobox/ComboboxSelect.vue +162 -0
  79. package/src/components/ui/combobox/ComboboxSeparator.vue +16 -0
  80. package/src/components/ui/combobox/ComboboxTrigger.vue +28 -0
  81. package/src/components/ui/combobox/index.ts +11 -0
  82. package/src/components/ui/combobox/types.ts +16 -0
  83. package/src/components/ui/command/CommandDialog.vue +18 -16
  84. package/src/components/ui/command/CommandGroup.vue +1 -1
  85. package/src/components/ui/command/CommandInput.vue +2 -2
  86. package/src/components/ui/command/CommandItem.vue +7 -5
  87. package/src/components/ui/data-table/DataTable.vue +230 -0
  88. package/src/components/ui/data-table/index.ts +20 -0
  89. package/src/components/ui/date-picker/DatePicker.vue +11 -15
  90. package/src/components/ui/date-time-picker/DateTimePicker.vue +2 -7
  91. package/src/components/ui/dialog/DialogContent.vue +9 -4
  92. package/src/components/ui/dialog/DialogOverlay.vue +1 -1
  93. package/src/components/ui/dialog/DialogScrollContent.vue +10 -5
  94. package/src/components/ui/dropdown-menu/DropdownMenu.vue +18 -0
  95. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +34 -0
  96. package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
  97. package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +13 -0
  98. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +34 -0
  99. package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +26 -0
  100. package/src/components/ui/dropdown-menu/DropdownMenuPortal.vue +13 -0
  101. package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +18 -0
  102. package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +32 -0
  103. package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +16 -0
  104. package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +15 -0
  105. package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +18 -0
  106. package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +39 -0
  107. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +36 -0
  108. package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +15 -0
  109. package/src/components/ui/dropdown-menu/classes.ts +24 -0
  110. package/src/components/ui/dropdown-menu/index.ts +15 -0
  111. package/src/components/ui/field/FieldDescription.vue +1 -1
  112. package/src/components/ui/field/FieldError.vue +4 -1
  113. package/src/components/ui/file-dropzone/FileDropzone.vue +250 -0
  114. package/src/components/ui/file-dropzone/index.ts +35 -0
  115. package/src/components/ui/hover-card/HoverCard.vue +21 -0
  116. package/src/components/ui/hover-card/HoverCardContent.vue +33 -0
  117. package/src/components/ui/hover-card/HoverCardTrigger.vue +13 -0
  118. package/src/components/ui/hover-card/index.ts +3 -0
  119. package/src/components/ui/input/Input.vue +5 -5
  120. package/src/components/ui/kbd/Kbd.vue +21 -0
  121. package/src/components/ui/kbd/KbdGroup.vue +72 -0
  122. package/src/components/ui/kbd/index.ts +2 -0
  123. package/src/components/ui/number-field/NumberField.vue +130 -0
  124. package/src/components/ui/number-field/index.ts +1 -0
  125. package/src/components/ui/pagination/Pagination.vue +27 -0
  126. package/src/components/ui/pagination/PaginationContent.vue +20 -0
  127. package/src/components/ui/pagination/PaginationEllipsis.vue +31 -0
  128. package/src/components/ui/pagination/PaginationFirst.vue +32 -0
  129. package/src/components/ui/pagination/PaginationItem.vue +41 -0
  130. package/src/components/ui/pagination/PaginationLast.vue +32 -0
  131. package/src/components/ui/pagination/PaginationNext.vue +32 -0
  132. package/src/components/ui/pagination/PaginationPrevious.vue +32 -0
  133. package/src/components/ui/pagination/SimplePagination.vue +49 -0
  134. package/src/components/ui/pagination/index.ts +9 -0
  135. package/src/components/ui/pin-input/PinInput.vue +48 -0
  136. package/src/components/ui/pin-input/PinInputInput.vue +49 -0
  137. package/src/components/ui/pin-input/context.ts +8 -0
  138. package/src/components/ui/pin-input/index.ts +2 -0
  139. package/src/components/ui/popover/PopoverContent.vue +1 -1
  140. package/src/components/ui/progress/Progress.vue +22 -7
  141. package/src/components/ui/radio-group/RadioGroup.vue +27 -0
  142. package/src/components/ui/radio-group/RadioGroupCard.vue +56 -0
  143. package/src/components/ui/radio-group/RadioGroupItem.vue +40 -0
  144. package/src/components/ui/radio-group/index.ts +3 -0
  145. package/src/components/ui/select/SelectContent.vue +1 -1
  146. package/src/components/ui/select/SelectItem.vue +2 -2
  147. package/src/components/ui/select/SelectItemContent.vue +1 -1
  148. package/src/components/ui/select/SelectTrigger.vue +6 -6
  149. package/src/components/ui/select/SelectValueContent.vue +1 -1
  150. package/src/components/ui/sheet/SheetContent.vue +5 -5
  151. package/src/components/ui/sheet/SheetOverlay.vue +1 -1
  152. package/src/components/ui/sidebar/Sidebar.vue +7 -5
  153. package/src/components/ui/sidebar/SidebarRail.vue +3 -1
  154. package/src/components/ui/sidebar/SidebarTrigger.vue +3 -1
  155. package/src/components/ui/skeleton/Skeleton.vue +4 -4
  156. package/src/components/ui/slider/Slider.vue +8 -6
  157. package/src/components/ui/spinner/Spinner.vue +47 -0
  158. package/src/components/ui/spinner/index.ts +1 -0
  159. package/src/components/ui/stepper/SimpleStepper.vue +56 -0
  160. package/src/components/ui/stepper/Stepper.vue +30 -0
  161. package/src/components/ui/stepper/StepperDescription.vue +20 -0
  162. package/src/components/ui/stepper/StepperIndicator.vue +41 -0
  163. package/src/components/ui/stepper/StepperItem.vue +21 -0
  164. package/src/components/ui/stepper/StepperSeparator.vue +34 -0
  165. package/src/components/ui/stepper/StepperTitle.vue +20 -0
  166. package/src/components/ui/stepper/StepperTrigger.vue +20 -0
  167. package/src/components/ui/stepper/index.ts +17 -0
  168. package/src/components/ui/switch/Switch.vue +8 -6
  169. package/src/components/ui/tabs/TabsContent.vue +1 -1
  170. package/src/components/ui/tabs/TabsList.vue +13 -5
  171. package/src/components/ui/tabs/TabsTrigger.vue +5 -3
  172. package/src/components/ui/tags-input/TagsInput.vue +49 -0
  173. package/src/components/ui/tags-input/TagsInputInput.vue +23 -0
  174. package/src/components/ui/tags-input/TagsInputItem.vue +35 -0
  175. package/src/components/ui/tags-input/TagsInputItemDelete.vue +31 -0
  176. package/src/components/ui/tags-input/TagsInputItemText.vue +21 -0
  177. package/src/components/ui/tags-input/index.ts +5 -0
  178. package/src/components/ui/textarea/Textarea.vue +4 -5
  179. package/src/components/ui/toggle-group/Toggle.vue +33 -0
  180. package/src/components/ui/toggle-group/ToggleGroup.vue +93 -0
  181. package/src/components/ui/toggle-group/ToggleGroupItem.vue +34 -0
  182. package/src/components/ui/toggle-group/index.ts +69 -0
  183. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  184. package/src/i18n/de.json +83 -0
  185. package/src/i18n/en.json +83 -0
  186. package/src/i18n/es.json +83 -0
  187. package/src/i18n/fr.json +83 -0
  188. package/src/i18n/it.json +83 -0
  189. package/src/i18n/ja.json +83 -0
  190. package/src/i18n/pt.json +83 -0
  191. package/src/i18n/ru.json +83 -0
  192. package/src/i18n/zh.json +83 -0
  193. package/src/lib/color-tokens.ts +14 -14
  194. package/src/styles.css +289 -5
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui';
3
+ import type { HTMLAttributes } from 'vue';
4
+ import { reactiveOmit } from '@vueuse/core';
5
+ import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from 'reka-ui';
6
+ import { cn } from '../../../lib/utils';
7
+ import { dropdownMenuItemClass } from './classes';
8
+
9
+ // Props
10
+ const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>();
11
+
12
+ // Emits
13
+ const emits = defineEmits<DropdownMenuRadioItemEmits>();
14
+
15
+ const delegatedProps = reactiveOmit(props, 'class');
16
+ const forwarded = useForwardPropsEmits(delegatedProps, emits);
17
+ </script>
18
+
19
+ <template>
20
+ <DropdownMenuRadioItem data-slot="dropdown-menu-radio-item" v-bind="forwarded" :class="cn(dropdownMenuItemClass, 'group/dropdown-radio-item pl-9 data-[state=checked]:text-foreground', props.class)">
21
+ <!-- Radio ring with a dot that pops in when checked -->
22
+ <span class="pointer-events-none absolute left-3 flex size-4 items-center justify-center rounded-full border border-border-strong transition-colors duration-150 group-data-[state=checked]/dropdown-radio-item:border-primary">
23
+ <DropdownMenuItemIndicator>
24
+ <slot name="indicator-icon">
25
+ <span class="block size-2 animate-uv-pop rounded-full bg-primary" />
26
+ </slot>
27
+ </DropdownMenuItemIndicator>
28
+ </span>
29
+
30
+ <slot />
31
+ </DropdownMenuRadioItem>
32
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuSeparatorProps } from 'reka-ui';
3
+ import type { HTMLAttributes } from 'vue';
4
+ import { reactiveOmit } from '@vueuse/core';
5
+ import { DropdownMenuSeparator } from 'reka-ui';
6
+ import { cn } from '../../../lib/utils';
7
+
8
+ // Props
9
+ const props = defineProps<DropdownMenuSeparatorProps & { class?: HTMLAttributes['class'] }>();
10
+
11
+ const delegatedProps = reactiveOmit(props, 'class');
12
+ </script>
13
+
14
+ <template>
15
+ <DropdownMenuSeparator data-slot="dropdown-menu-separator" v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-border', props.class)" />
16
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { cn } from '../../../lib/utils';
4
+
5
+ // Props
6
+ const props = defineProps<{
7
+ class?: HTMLAttributes['class'];
8
+ }>();
9
+ </script>
10
+
11
+ <template>
12
+ <span data-slot="dropdown-menu-shortcut" :class="cn('ml-auto pl-4 text-[11px] font-semibold tracking-widest text-muted-foreground', props.class)">
13
+ <slot />
14
+ </span>
15
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'reka-ui';
3
+ import { DropdownMenuSub, useForwardPropsEmits } from 'reka-ui';
4
+
5
+ // Props
6
+ const props = defineProps<DropdownMenuSubProps>();
7
+
8
+ // Emits
9
+ const emits = defineEmits<DropdownMenuSubEmits>();
10
+
11
+ const forwarded = useForwardPropsEmits(props, emits);
12
+ </script>
13
+
14
+ <template>
15
+ <DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
16
+ <slot v-bind="slotProps" />
17
+ </DropdownMenuSub>
18
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui';
3
+ import type { HTMLAttributes } from 'vue';
4
+ import { reactiveOmit } from '@vueuse/core';
5
+ import { DropdownMenuPortal, DropdownMenuSubContent, useForwardPropsEmits } from 'reka-ui';
6
+ import { cn } from '../../../lib/utils';
7
+ import { dropdownMenuStaggerClass, dropdownMenuSurfaceClass } from './classes';
8
+
9
+ defineOptions({
10
+ inheritAttrs: false
11
+ });
12
+
13
+ // Props
14
+ const props = withDefaults(defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>(), {
15
+ sideOffset: 6,
16
+ alignOffset: -5,
17
+ collisionPadding: 8
18
+ });
19
+
20
+ // Emits
21
+ const emits = defineEmits<DropdownMenuSubContentEmits>();
22
+
23
+ const delegatedProps = reactiveOmit(props, 'class');
24
+ const forwarded = useForwardPropsEmits(delegatedProps, emits);
25
+ </script>
26
+
27
+ <template>
28
+ <DropdownMenuPortal>
29
+ <DropdownMenuSubContent data-slot="dropdown-menu-sub-content" v-bind="{ ...$attrs, ...forwarded }" :class="cn(
30
+ dropdownMenuSurfaceClass,
31
+ 'max-h-(--reka-dropdown-menu-content-available-height)',
32
+ dropdownMenuStaggerClass,
33
+ props.class
34
+ )
35
+ ">
36
+ <slot />
37
+ </DropdownMenuSubContent>
38
+ </DropdownMenuPortal>
39
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuSubTriggerProps } from 'reka-ui';
3
+ import type { HTMLAttributes } from 'vue';
4
+ import { reactiveOmit } from '@vueuse/core';
5
+ import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
6
+ import { HugeiconsIcon } from '@hugeicons/vue';
7
+ import { DropdownMenuSubTrigger, useForwardProps } from 'reka-ui';
8
+ import { cn } from '../../../lib/utils';
9
+ import { dropdownMenuItemClass } from './classes';
10
+
11
+ // Props
12
+ const props = defineProps<DropdownMenuSubTriggerProps & {
13
+ class?: HTMLAttributes['class'];
14
+ inset?: boolean;
15
+ }>();
16
+
17
+ const delegatedProps = reactiveOmit(props, 'class', 'inset');
18
+ const forwardedProps = useForwardProps(delegatedProps);
19
+ </script>
20
+
21
+ <template>
22
+ <DropdownMenuSubTrigger data-slot="dropdown-menu-sub-trigger" :data-inset="props.inset ? '' : undefined" v-bind="forwardedProps" :class="cn(
23
+ 'group/dropdown-sub-trigger',
24
+ dropdownMenuItemClass,
25
+ 'data-[state=open]:bg-accent data-[state=open]:text-foreground',
26
+ props.class
27
+ )
28
+ ">
29
+ <slot />
30
+
31
+ <!-- Chevron nudges right while the sub menu is open -->
32
+ <span class="ml-auto flex items-center transition-transform duration-300 ease-spring group-data-[state=open]/dropdown-sub-trigger:translate-x-1">
33
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 opacity-60" />
34
+ </span>
35
+ </DropdownMenuSubTrigger>
36
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuTriggerProps } from 'reka-ui';
3
+ import { DropdownMenuTrigger, useForwardProps } from 'reka-ui';
4
+
5
+ // Props
6
+ const props = defineProps<DropdownMenuTriggerProps>();
7
+
8
+ const forwardedProps = useForwardProps(props);
9
+ </script>
10
+
11
+ <template>
12
+ <DropdownMenuTrigger data-slot="dropdown-menu-trigger" v-bind="forwardedProps">
13
+ <slot />
14
+ </DropdownMenuTrigger>
15
+ </template>
@@ -0,0 +1,24 @@
1
+ // Shared surface classes for the root menu and sub menus
2
+ export const dropdownMenuSurfaceClass = 'uv-floating-motion bg-popover text-popover-foreground z-50 min-w-48 overflow-x-hidden overflow-y-auto rounded border border-border p-1 shadow-elevated-lg outline-hidden';
3
+
4
+ // Lightweight entrance stagger for the first ~8 direct children (runs once on open, not on highlight)
5
+ export const dropdownMenuStaggerClass = [
6
+ '[&[data-state=open]>*]:animate-[uv-float-in_240ms_var(--ease-out-expo)_both] [&>*]:[--uv-float-offset:-3px]',
7
+ '[&[data-state=open]>*:nth-child(2)]:[animation-delay:16ms]',
8
+ '[&[data-state=open]>*:nth-child(3)]:[animation-delay:32ms]',
9
+ '[&[data-state=open]>*:nth-child(4)]:[animation-delay:48ms]',
10
+ '[&[data-state=open]>*:nth-child(5)]:[animation-delay:64ms]',
11
+ '[&[data-state=open]>*:nth-child(6)]:[animation-delay:80ms]',
12
+ '[&[data-state=open]>*:nth-child(7)]:[animation-delay:96ms]',
13
+ '[&[data-state=open]>*:nth-child(8)]:[animation-delay:112ms]',
14
+ '[&[data-state=open]>*:nth-child(n+9)]:[animation-delay:128ms]'
15
+ ].join(' ');
16
+
17
+ // Shared item classes (items, checkbox/radio items, sub triggers)
18
+ export const dropdownMenuItemClass = [
19
+ 'relative flex cursor-pointer items-center gap-3 rounded px-3 py-2 text-xs font-semibold text-muted-foreground outline-hidden select-none transition-colors duration-150 sm:text-sm',
20
+ 'data-highlighted:bg-accent data-highlighted:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-9',
21
+ '[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
22
+ // Leading icons nudge slightly when the item is highlighted
23
+ '[&>svg]:transition-transform [&>svg]:duration-300 [&>svg]:ease-spring [&[data-highlighted]>svg]:translate-x-0.5 [&[data-highlighted]>svg]:scale-110'
24
+ ].join(' ');
@@ -0,0 +1,15 @@
1
+ export { default as DropdownMenu } from './DropdownMenu.vue';
2
+ export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue';
3
+ export { default as DropdownMenuContent } from './DropdownMenuContent.vue';
4
+ export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue';
5
+ export { default as DropdownMenuItem } from './DropdownMenuItem.vue';
6
+ export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue';
7
+ export { default as DropdownMenuPortal } from './DropdownMenuPortal.vue';
8
+ export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue';
9
+ export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue';
10
+ export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue';
11
+ export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue';
12
+ export { default as DropdownMenuSub } from './DropdownMenuSub.vue';
13
+ export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue';
14
+ export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue';
15
+ export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue';
@@ -10,7 +10,7 @@ const props = defineProps<{
10
10
 
11
11
  <template>
12
12
  <p data-slot="field-description" :class="cn(
13
- 'text-xs leading-5 font-normal text-(--text-secondary-light) dark:text-(--text-secondary-dark) group-has-data-[orientation=horizontal]/field:text-balance',
13
+ 'text-xs leading-5 font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance',
14
14
  'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
15
15
  '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
16
16
  props.class
@@ -22,6 +22,9 @@ const content = computed(() => {
22
22
  ).values()
23
23
  ];
24
24
 
25
+ // Nothing to show when every entry is empty
26
+ if (uniqueErrors.length === 0) return null;
27
+
25
28
  if (uniqueErrors.length === 1 && uniqueErrors[0]) {
26
29
  return typeof uniqueErrors[0] === 'string'
27
30
  ? uniqueErrors[0]
@@ -35,7 +38,7 @@ const content = computed(() => {
35
38
  </script>
36
39
 
37
40
  <template>
38
- <div v-if="$slots.default || content" role="alert" data-slot="field-error" :class="cn('text-destructive text-xs font-normal', props.class)">
41
+ <div v-if="$slots.default || content" role="alert" data-slot="field-error" :class="cn('text-destructive animate-[uv-fade-up_0.3s_var(--ease-out-expo)_both] text-xs font-normal', props.class)">
39
42
  <slot v-if="$slots.default" />
40
43
 
41
44
  <template v-else-if="typeof content === 'string'">
@@ -0,0 +1,250 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import type { HugeiconsIconDefinition } from '../../../lib/common-types';
4
+ import type { FileRejection } from '.';
5
+ import { Cancel01Icon, CloudUploadIcon, Doc01Icon, File01Icon, FileZipIcon, Image01Icon, MusicNote01Icon, Pdf01Icon, Video01Icon, Xls01Icon } from '@hugeicons/core-free-icons';
6
+ import { HugeiconsIcon } from '@hugeicons/vue';
7
+ import { computed, onBeforeUnmount, ref } from 'vue';
8
+ import { useI18n } from 'vue-i18n';
9
+ import { cn } from '../../../lib/utils';
10
+ import { Button, buttonVariants } from '../button';
11
+ import { formatFileSize, isFileAccepted } from '.';
12
+
13
+ // Props
14
+ const props = withDefaults(defineProps<{
15
+ accept?: string;
16
+ multiple?: boolean;
17
+ maxSize?: number;
18
+ maxFiles?: number;
19
+ disabled?: boolean;
20
+ title?: string;
21
+ description?: string;
22
+ class?: HTMLAttributes['class'];
23
+ }>(), {
24
+ multiple: true,
25
+ disabled: false,
26
+ });
27
+
28
+ // Emits
29
+ const emits = defineEmits<{
30
+ reject: [rejections: FileRejection[]];
31
+ }>();
32
+
33
+ // Selected files (v-model)
34
+ const files = defineModel<File[]>({ default: () => [] });
35
+
36
+ const { t } = useI18n();
37
+
38
+ const inputRef = ref<HTMLInputElement | null>(null);
39
+ const isDragging = ref(false);
40
+ const isRejected = ref(false);
41
+ let dragDepth = 0;
42
+ let rejectTimer: ReturnType<typeof setTimeout> | undefined;
43
+
44
+ // Resolved texts
45
+ const resolvedTitle = computed(() => props.title || t('uiVintage.fileDropzone.title'));
46
+ const resolvedDescription = computed(() => props.description || t('uiVintage.fileDropzone.description'));
47
+
48
+ // Constraint hints shown under the description
49
+ const hints = computed(() => {
50
+ const list: string[] = [];
51
+ if (props.maxSize) list.push(t('uiVintage.fileDropzone.maxSize', { size: formatFileSize(props.maxSize) }));
52
+ if (props.multiple && props.maxFiles) list.push(t('uiVintage.fileDropzone.maxFiles', { count: props.maxFiles }));
53
+ return list;
54
+ });
55
+
56
+ // Stable keys for the file list
57
+ const fileIds = new WeakMap<File, number>();
58
+ let nextFileId = 0;
59
+ function getFileId(file: File) {
60
+ if (!fileIds.has(file)) fileIds.set(file, nextFileId++);
61
+ return fileIds.get(file)!;
62
+ }
63
+
64
+ // Pick an icon from the file type or extension
65
+ function getFileIcon(file: File): HugeiconsIconDefinition {
66
+ const type = file.type.toLowerCase();
67
+ const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
68
+ if (type.startsWith('image/')) return Image01Icon;
69
+ if (type.startsWith('video/')) return Video01Icon;
70
+ if (type.startsWith('audio/')) return MusicNote01Icon;
71
+ if (type === 'application/pdf' || extension === 'pdf') return Pdf01Icon;
72
+ if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) return FileZipIcon;
73
+ if (['doc', 'docx', 'odt', 'rtf', 'txt', 'md'].includes(extension)) return Doc01Icon;
74
+ if (['xls', 'xlsx', 'ods', 'csv'].includes(extension)) return Xls01Icon;
75
+ return File01Icon;
76
+ }
77
+
78
+ // Briefly shake the drop zone when something is refused
79
+ function flashRejected() {
80
+ isRejected.value = false;
81
+ clearTimeout(rejectTimer);
82
+ requestAnimationFrame(() => {
83
+ isRejected.value = true;
84
+ rejectTimer = setTimeout(() => (isRejected.value = false), 450);
85
+ });
86
+ }
87
+
88
+ // Validate incoming files and merge them into the model
89
+ function addFiles(incoming: File[]) {
90
+ if (props.disabled || incoming.length === 0) return;
91
+ const rejections: FileRejection[] = [];
92
+ const accepted: File[] = [];
93
+
94
+ // Type and size checks
95
+ for (const file of incoming) {
96
+ if (!isFileAccepted(file, props.accept)) rejections.push({ file, reason: 'type' });
97
+ else if (props.maxSize !== undefined && file.size > props.maxSize) rejections.push({ file, reason: 'size' });
98
+ else accepted.push(file);
99
+ }
100
+
101
+ let next: File[];
102
+ if (!props.multiple) {
103
+ // Single mode keeps only the first valid file
104
+ next = accepted.slice(0, 1);
105
+ for (const file of accepted.slice(1)) rejections.push({ file, reason: 'count' });
106
+ if (next.length === 0) next = [...files.value];
107
+ } else {
108
+ // Skip exact duplicates already in the list
109
+ const isDuplicate = (file: File) => files.value.some(existing => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified);
110
+ const fresh = accepted.filter(file => !isDuplicate(file));
111
+
112
+ // Enforce the max file count
113
+ const room = props.maxFiles !== undefined ? Math.max(0, props.maxFiles - files.value.length) : fresh.length;
114
+ for (const file of fresh.slice(room)) rejections.push({ file, reason: 'count' });
115
+ next = [...files.value, ...fresh.slice(0, room)];
116
+ }
117
+
118
+ if (next.length !== files.value.length || next.some((file, index) => file !== files.value[index])) files.value = next;
119
+ if (rejections.length > 0) {
120
+ emits('reject', rejections);
121
+ flashRejected();
122
+ }
123
+ }
124
+
125
+ // Remove a file from the list
126
+ function removeFile(file: File) {
127
+ files.value = files.value.filter(existing => existing !== file);
128
+ }
129
+
130
+ // Open the native file picker
131
+ function openPicker() {
132
+ if (props.disabled) return;
133
+ inputRef.value?.click();
134
+ }
135
+
136
+ // Native picker selection
137
+ function onInputChange(event: Event) {
138
+ const input = event.target as HTMLInputElement;
139
+ addFiles(Array.from(input.files ?? []));
140
+ input.value = '';
141
+ }
142
+
143
+ // Drag tracking (depth counter avoids flicker when crossing child elements)
144
+ function onDragEnter(event: DragEvent) {
145
+ if (props.disabled || !event.dataTransfer?.types.includes('Files')) return;
146
+ dragDepth++;
147
+ isDragging.value = true;
148
+ }
149
+
150
+ function onDragOver(event: DragEvent) {
151
+ if (props.disabled || !event.dataTransfer) return;
152
+ event.dataTransfer.dropEffect = 'copy';
153
+ }
154
+
155
+ function onDragLeave() {
156
+ if (!isDragging.value) return;
157
+ dragDepth = Math.max(0, dragDepth - 1);
158
+ if (dragDepth === 0) isDragging.value = false;
159
+ }
160
+
161
+ function onDrop(event: DragEvent) {
162
+ dragDepth = 0;
163
+ isDragging.value = false;
164
+ if (props.disabled) return;
165
+ addFiles(Array.from(event.dataTransfer?.files ?? []));
166
+ }
167
+
168
+ onBeforeUnmount(() => clearTimeout(rejectTimer));
169
+ </script>
170
+
171
+ <template>
172
+ <div data-slot="file-dropzone" :class="cn('flex w-full flex-col gap-3', props.class)">
173
+ <!-- Drop area -->
174
+ <div role="button" :tabindex="props.disabled ? -1 : 0" :aria-disabled="props.disabled || undefined" :aria-label="resolvedTitle" :data-dragging="isDragging ? '' : undefined" :data-disabled="props.disabled ? '' : undefined" :class="cn(
175
+ 'group/dropzone relative isolate flex min-h-44 cursor-pointer select-none flex-col items-center justify-center gap-3 rounded border-2 border-dashed border-border bg-surface/50 px-6 py-8 text-center outline-none',
176
+ '[transition:border-color_150ms,background-color_220ms,box-shadow_220ms,transform_420ms_var(--ease-spring)]',
177
+ 'hover:border-border-strong hover:bg-surface focus-visible:ring-[3px] focus-visible:ring-ring/45 active:scale-[0.99] active:duration-75',
178
+ 'data-dragging:scale-[1.01] data-dragging:border-transparent data-dragging:bg-primary/5 data-dragging:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_12%,transparent)] data-dragging:hover:border-transparent data-dragging:hover:bg-primary/5',
179
+ 'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:border-border data-disabled:hover:bg-surface/50 data-disabled:active:scale-100',
180
+ isRejected && 'animate-uv-shake border-destructive/70'
181
+ )" @click="openPicker" @keydown.enter.prevent="openPicker" @keydown.space.prevent="openPicker" @dragenter.prevent="onDragEnter" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
182
+ <!-- Marching dashed border while dragging -->
183
+ <span aria-hidden="true" class="uv-dropzone-ants pointer-events-none absolute -inset-0.5 rounded opacity-0 transition-opacity duration-150 group-data-dragging/dropzone:opacity-100" />
184
+
185
+ <!-- Upload icon (floats up and grows while dragging) -->
186
+ <span aria-hidden="true" class="relative flex size-14 items-center justify-center rounded border border-border bg-card text-muted-foreground shadow-elevated-sm transition-[translate,scale,color,border-color,box-shadow] duration-420 ease-bounce group-hover/dropzone:-translate-y-0.5 group-hover/dropzone:text-foreground group-data-dragging/dropzone:-translate-y-2 group-data-dragging/dropzone:scale-115 group-data-dragging/dropzone:border-primary/50 group-data-dragging/dropzone:text-primary group-data-dragging/dropzone:shadow-glow">
187
+ <HugeiconsIcon :icon="CloudUploadIcon" class="size-6 group-data-dragging/dropzone:animate-uv-float" />
188
+ </span>
189
+
190
+ <!-- Texts -->
191
+ <div class="flex flex-col items-center gap-1">
192
+ <p class="text-xs font-semibold text-foreground sm:text-sm">
193
+ {{ isDragging ? t('uiVintage.fileDropzone.dropActive') : resolvedTitle }}
194
+ </p>
195
+ <p class="text-xs text-muted-foreground">{{ resolvedDescription }}</p>
196
+ <p v-if="hints.length" class="text-xs text-muted-foreground/80">{{ hints.join(' · ') }}</p>
197
+ </div>
198
+
199
+ <!-- Browse hint (decorative, the whole zone is the button) -->
200
+ <span aria-hidden="true" :class="cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'pointer-events-none transition-opacity group-data-dragging/dropzone:opacity-0')">
201
+ {{ t('uiVintage.fileDropzone.browse') }}
202
+ </span>
203
+
204
+ <!-- Hidden native input -->
205
+ <input ref="inputRef" type="file" class="hidden" tabindex="-1" :accept="props.accept" :multiple="props.multiple" :disabled="props.disabled" @change="onInputChange" @click.stop>
206
+ </div>
207
+
208
+ <!-- Selected files -->
209
+ <TransitionGroup tag="ul" data-slot="file-dropzone-list" :aria-label="t('uiVintage.fileDropzone.files')" class="flex flex-col" enter-active-class="transition-[opacity,translate,filter] duration-420 ease-out-expo" enter-from-class="translate-y-2.5 opacity-0 blur-[3px]" leave-active-class="transition-[grid-template-rows,opacity,scale] duration-220 ease-snappy" leave-to-class="grid-rows-[0fr] scale-[0.98] opacity-0">
210
+ <li v-for="file in files" :key="getFileId(file)" class="grid grid-rows-[1fr]">
211
+ <div class="min-h-0 overflow-hidden">
212
+ <div class="pb-2">
213
+ <!-- File row -->
214
+ <div data-slot="file-dropzone-item" class="flex items-center gap-3 rounded border border-border bg-card py-2 pr-2 pl-3 shadow-elevated-sm">
215
+ <span class="flex size-9 shrink-0 items-center justify-center rounded-sm bg-surface text-muted-foreground">
216
+ <HugeiconsIcon :icon="getFileIcon(file)" class="size-4.5" />
217
+ </span>
218
+ <div class="flex min-w-0 flex-1 flex-col">
219
+ <span class="truncate text-xs font-semibold text-foreground sm:text-sm" :title="file.name">{{ file.name }}</span>
220
+ <span class="text-xs text-muted-foreground tabular-nums">{{ formatFileSize(file.size) }}</span>
221
+ </div>
222
+ <Button variant="ghost" size="icon-sm" :disabled="props.disabled" :aria-label="t('uiVintage.fileDropzone.remove', { name: file.name })" class="hover:text-destructive" @click="removeFile(file)">
223
+ <HugeiconsIcon :icon="Cancel01Icon" class="size-4" />
224
+ </Button>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </li>
229
+ </TransitionGroup>
230
+ </div>
231
+ </template>
232
+
233
+ <style>
234
+ /* Animated dashed border shown while files are dragged over the drop zone */
235
+ .uv-dropzone-ants {
236
+ background-image:
237
+ linear-gradient(90deg, var(--primary) 55%, transparent 0),
238
+ linear-gradient(90deg, var(--primary) 55%, transparent 0),
239
+ linear-gradient(0deg, var(--primary) 55%, transparent 0),
240
+ linear-gradient(0deg, var(--primary) 55%, transparent 0);
241
+ background-size: 16px 2px, 16px 2px, 2px 16px, 2px 16px;
242
+ background-position: 0 0, 0 100%, 0 0, 100% 0;
243
+ background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
244
+ animation: uv-dropzone-march 0.7s linear infinite;
245
+ }
246
+
247
+ @keyframes uv-dropzone-march {
248
+ to { background-position: 16px 0, -16px 100%, 0 -16px, 100% 16px; }
249
+ }
250
+ </style>
@@ -0,0 +1,35 @@
1
+ export { default as FileDropzone } from './FileDropzone.vue';
2
+
3
+ export type FileRejectionReason = 'type' | 'size' | 'count';
4
+
5
+ export type FileRejection = {
6
+ file: File;
7
+ reason: FileRejectionReason;
8
+ };
9
+
10
+ // Format a byte count as a human readable size (1024 based)
11
+ export function formatFileSize(bytes: number): string {
12
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
13
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
14
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
15
+ const value = bytes / 1024 ** exponent;
16
+
17
+ // One decimal for small values, none for bytes or large values
18
+ const digits = exponent === 0 || value >= 10 ? 0 : 1;
19
+ return `${value.toFixed(digits)} ${units[exponent]}`;
20
+ }
21
+
22
+ // Check a file against an accept string (".pdf,image/*,application/json")
23
+ export function isFileAccepted(file: File, accept: string | undefined): boolean {
24
+ if (!accept) return true;
25
+ const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);
26
+ if (tokens.length === 0) return true;
27
+
28
+ const name = file.name.toLowerCase();
29
+ const type = (file.type || '').toLowerCase();
30
+ return tokens.some((token) => {
31
+ if (token.startsWith('.')) return name.endsWith(token);
32
+ if (token.endsWith('/*')) return type.startsWith(token.slice(0, -1));
33
+ return type === token;
34
+ });
35
+ }
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui';
3
+ import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui';
4
+
5
+ // Props
6
+ const props = withDefaults(defineProps<HoverCardRootProps>(), {
7
+ openDelay: 350,
8
+ closeDelay: 150
9
+ });
10
+
11
+ // Emits
12
+ const emits = defineEmits<HoverCardRootEmits>();
13
+
14
+ const forwarded = useForwardPropsEmits(props, emits);
15
+ </script>
16
+
17
+ <template>
18
+ <HoverCardRoot v-slot="slotProps" data-slot="hover-card" v-bind="forwarded">
19
+ <slot v-bind="slotProps" />
20
+ </HoverCardRoot>
21
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import type { HoverCardContentProps } from 'reka-ui';
3
+ import type { HTMLAttributes } from 'vue';
4
+ import { reactiveOmit } from '@vueuse/core';
5
+ import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui';
6
+ import { cn } from '../../../lib/utils';
7
+
8
+ defineOptions({
9
+ inheritAttrs: false
10
+ });
11
+
12
+ // Props
13
+ const props = withDefaults(defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>(), {
14
+ align: 'center',
15
+ sideOffset: 8,
16
+ collisionPadding: 8
17
+ });
18
+
19
+ const delegatedProps = reactiveOmit(props, 'class');
20
+ const forwardedProps = useForwardProps(delegatedProps);
21
+ </script>
22
+
23
+ <template>
24
+ <HoverCardPortal>
25
+ <HoverCardContent data-slot="hover-card-content" v-bind="{ ...$attrs, ...forwardedProps }" :class="cn(
26
+ 'uv-floating-motion bg-popover text-popover-foreground z-50 w-72 rounded border border-border p-4 text-sm shadow-elevated-lg outline-hidden',
27
+ props.class
28
+ )
29
+ ">
30
+ <slot />
31
+ </HoverCardContent>
32
+ </HoverCardPortal>
33
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import type { HoverCardTriggerProps } from 'reka-ui';
3
+ import { HoverCardTrigger } from 'reka-ui';
4
+
5
+ // Props
6
+ const props = defineProps<HoverCardTriggerProps>();
7
+ </script>
8
+
9
+ <template>
10
+ <HoverCardTrigger data-slot="hover-card-trigger" v-bind="props">
11
+ <slot />
12
+ </HoverCardTrigger>
13
+ </template>
@@ -0,0 +1,3 @@
1
+ export { default as HoverCard } from './HoverCard.vue';
2
+ export { default as HoverCardContent } from './HoverCardContent.vue';
3
+ export { default as HoverCardTrigger } from './HoverCardTrigger.vue';
@@ -23,10 +23,10 @@ const modelValue = useVModel(props, 'modelValue', emits, {
23
23
 
24
24
  <template>
25
25
  <input v-model="modelValue" data-slot="input" :class="cn(
26
- 'file:text-foreground selection:bg-primary selection:text-primary-foreground h-13 w-full min-w-0 rounded border border-(--border-light) bg-(--button-secondary-light) px-4 py-3 text-xs font-semibold text-(--text-primary-light) outline-none transition-all duration-300 ease-in-out placeholder:text-(--text-secondary-light) placeholder:opacity-60 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-semibold disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-(--border-dark) dark:bg-(--button-secondary-dark) dark:text-(--text-primary-dark) dark:placeholder:text-(--text-secondary-dark) sm:text-sm',
27
- 'focus-visible:border-(--border-hover-light) focus-visible:ring-0 dark:focus-visible:border-(--border-hover-dark)',
28
- 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
26
+ 'uv-field h-13 w-full min-w-0 rounded border border-input bg-secondary px-4 py-3 text-xs font-semibold text-foreground shadow-elevated-sm outline-none sm:text-sm',
27
+ 'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground placeholder:opacity-60 placeholder:transition-opacity focus-visible:placeholder:opacity-40',
28
+ 'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-semibold file:text-foreground',
29
+ 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60 aria-invalid:animate-uv-shake',
29
30
  props.class
30
- )
31
- " />
31
+ )" />
32
32
  </template>