@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.
- package/COMPONENTS.md +3643 -0
- package/README.md +50 -10
- package/package.json +30 -8
- package/skills/ui-vintage/SKILL.md +42 -0
- package/src/components/blog/BlogFAQSection.vue +2 -2
- package/src/components/blog/BlogHeaderSection.vue +2 -2
- package/src/components/blog/BlogInfoSection.vue +3 -3
- package/src/components/blog/BlogProgressBar.vue +7 -6
- package/src/components/blog/BlogSectionTitle.vue +1 -1
- package/src/components/blog/BlogTagsSection.vue +22 -0
- package/src/components/blog/CategoryCard.vue +1 -1
- package/src/components/blog/HeaderCarousel.vue +6 -6
- package/src/components/blog/PostCard.vue +2 -2
- package/src/components/blog/SocialShareSidebar.vue +1 -1
- package/src/components/blog/TableOfContents.vue +6 -6
- package/src/components/blog/index.ts +1 -0
- package/src/components/busy-indicator/busy-indicator-state.ts +3 -2
- package/src/components/chip/Chip.vue +9 -5
- package/src/components/confirm-dialog/ConfirmDialog.vue +17 -6
- package/src/components/confirm-dialog/confirm-dialog-state.ts +4 -3
- package/src/components/content/BlogList.vue +3 -3
- package/src/components/content/BlogTable.vue +7 -7
- package/src/components/content/CodeBlock.vue +4 -4
- package/src/components/content/Flow.vue +3 -3
- package/src/components/content/ProseHr.vue +2 -2
- package/src/components/content/Terminal.vue +4 -4
- package/src/components/cookie-consent/CookieConsent.vue +82 -0
- package/src/components/cookie-consent/index.ts +3 -0
- package/src/components/cookie-consent/useCookieConsent.ts +55 -0
- package/src/components/dashboard-shell/DashboardShell.vue +3 -3
- package/src/components/empty-state-card/EmptyStateCard.vue +4 -4
- package/src/components/error-page/ErrorPage.vue +16 -14
- package/src/components/info-card/InfoCard.vue +1 -1
- package/src/components/landing/landing-navbar/LandingNavbar.vue +2 -1
- package/src/components/load-more-button/LoadMoreButton.vue +2 -2
- package/src/components/loading-state-card/LoadingStateCard.vue +4 -4
- package/src/components/message-dialog/MessageDialog.vue +4 -4
- package/src/components/message-toast/MessageToast.vue +222 -50
- package/src/components/message-toast/index.ts +2 -2
- package/src/components/message-toast/message-toast-state.ts +107 -81
- package/src/components/page-header/PageHeader.vue +1 -1
- package/src/components/progress-component/ProgressComponent.vue +4 -4
- package/src/components/single-value-card/SingleValueCard.vue +47 -11
- package/src/components/theme-selector/ThemeSelector.vue +20 -36
- package/src/components/theme-selector/index.ts +3 -1
- package/src/components/theme-selector/theme-transition.ts +86 -0
- package/src/components/ui/accordion/Accordion.vue +41 -26
- package/src/components/ui/alert-dialog/AlertDialogContent.vue +2 -2
- package/src/components/ui/alert-dialog/AlertDialogFooter.vue +1 -1
- package/src/components/ui/animated-number/AnimatedNumber.vue +102 -0
- package/src/components/ui/animated-number/index.ts +1 -0
- package/src/components/ui/avatar/Avatar.vue +66 -0
- package/src/components/ui/avatar/AvatarFallback.vue +31 -0
- package/src/components/ui/avatar/AvatarGroup.vue +75 -0
- package/src/components/ui/avatar/AvatarImage.vue +23 -0
- package/src/components/ui/avatar/index.ts +47 -0
- package/src/components/ui/badge/Badge.vue +13 -2
- package/src/components/ui/breadcrumb/Breadcrumb.vue +4 -1
- package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +7 -1
- package/src/components/ui/busy/Busy.vue +45 -20
- package/src/components/ui/button/Button.vue +20 -3
- package/src/components/ui/button/index.ts +12 -2
- package/src/components/ui/card/Card.vue +21 -8
- package/src/components/ui/card/CardText.vue +1 -1
- package/src/components/ui/checkbox/Checkbox.vue +48 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/collapsible/Collapsible.vue +22 -0
- package/src/components/ui/collapsible/CollapsibleContent.vue +22 -0
- package/src/components/ui/collapsible/CollapsibleTrigger.vue +19 -0
- package/src/components/ui/collapsible/index.ts +3 -0
- package/src/components/ui/combobox/Combobox.vue +24 -0
- package/src/components/ui/combobox/ComboboxAnchor.vue +39 -0
- package/src/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/src/components/ui/combobox/ComboboxGroup.vue +26 -0
- package/src/components/ui/combobox/ComboboxInput.vue +28 -0
- package/src/components/ui/combobox/ComboboxItem.vue +39 -0
- package/src/components/ui/combobox/ComboboxList.vue +43 -0
- package/src/components/ui/combobox/ComboboxSelect.vue +162 -0
- package/src/components/ui/combobox/ComboboxSeparator.vue +16 -0
- package/src/components/ui/combobox/ComboboxTrigger.vue +28 -0
- package/src/components/ui/combobox/index.ts +11 -0
- package/src/components/ui/combobox/types.ts +16 -0
- package/src/components/ui/command/CommandDialog.vue +18 -16
- package/src/components/ui/command/CommandGroup.vue +1 -1
- package/src/components/ui/command/CommandInput.vue +2 -2
- package/src/components/ui/command/CommandItem.vue +7 -5
- package/src/components/ui/data-table/DataTable.vue +230 -0
- package/src/components/ui/data-table/index.ts +20 -0
- package/src/components/ui/date-picker/DatePicker.vue +11 -15
- package/src/components/ui/date-time-picker/DateTimePicker.vue +2 -7
- package/src/components/ui/dialog/DialogContent.vue +9 -4
- package/src/components/ui/dialog/DialogOverlay.vue +1 -1
- package/src/components/ui/dialog/DialogScrollContent.vue +10 -5
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +18 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +34 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +13 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +34 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +26 -0
- package/src/components/ui/dropdown-menu/DropdownMenuPortal.vue +13 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +18 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +32 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +16 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +15 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +18 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +39 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +36 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +15 -0
- package/src/components/ui/dropdown-menu/classes.ts +24 -0
- package/src/components/ui/dropdown-menu/index.ts +15 -0
- package/src/components/ui/field/FieldDescription.vue +1 -1
- package/src/components/ui/field/FieldError.vue +4 -1
- package/src/components/ui/file-dropzone/FileDropzone.vue +250 -0
- package/src/components/ui/file-dropzone/index.ts +35 -0
- package/src/components/ui/hover-card/HoverCard.vue +21 -0
- package/src/components/ui/hover-card/HoverCardContent.vue +33 -0
- package/src/components/ui/hover-card/HoverCardTrigger.vue +13 -0
- package/src/components/ui/hover-card/index.ts +3 -0
- package/src/components/ui/input/Input.vue +5 -5
- package/src/components/ui/kbd/Kbd.vue +21 -0
- package/src/components/ui/kbd/KbdGroup.vue +72 -0
- package/src/components/ui/kbd/index.ts +2 -0
- package/src/components/ui/number-field/NumberField.vue +130 -0
- package/src/components/ui/number-field/index.ts +1 -0
- package/src/components/ui/pagination/Pagination.vue +27 -0
- package/src/components/ui/pagination/PaginationContent.vue +20 -0
- package/src/components/ui/pagination/PaginationEllipsis.vue +31 -0
- package/src/components/ui/pagination/PaginationFirst.vue +32 -0
- package/src/components/ui/pagination/PaginationItem.vue +41 -0
- package/src/components/ui/pagination/PaginationLast.vue +32 -0
- package/src/components/ui/pagination/PaginationNext.vue +32 -0
- package/src/components/ui/pagination/PaginationPrevious.vue +32 -0
- package/src/components/ui/pagination/SimplePagination.vue +49 -0
- package/src/components/ui/pagination/index.ts +9 -0
- package/src/components/ui/pin-input/PinInput.vue +48 -0
- package/src/components/ui/pin-input/PinInputInput.vue +49 -0
- package/src/components/ui/pin-input/context.ts +8 -0
- package/src/components/ui/pin-input/index.ts +2 -0
- package/src/components/ui/popover/PopoverContent.vue +1 -1
- package/src/components/ui/progress/Progress.vue +22 -7
- package/src/components/ui/radio-group/RadioGroup.vue +27 -0
- package/src/components/ui/radio-group/RadioGroupCard.vue +56 -0
- package/src/components/ui/radio-group/RadioGroupItem.vue +40 -0
- package/src/components/ui/radio-group/index.ts +3 -0
- package/src/components/ui/select/SelectContent.vue +1 -1
- package/src/components/ui/select/SelectItem.vue +2 -2
- package/src/components/ui/select/SelectItemContent.vue +1 -1
- package/src/components/ui/select/SelectTrigger.vue +6 -6
- package/src/components/ui/select/SelectValueContent.vue +1 -1
- package/src/components/ui/sheet/SheetContent.vue +5 -5
- package/src/components/ui/sheet/SheetOverlay.vue +1 -1
- package/src/components/ui/sidebar/Sidebar.vue +7 -5
- package/src/components/ui/sidebar/SidebarRail.vue +3 -1
- package/src/components/ui/sidebar/SidebarTrigger.vue +3 -1
- package/src/components/ui/skeleton/Skeleton.vue +4 -4
- package/src/components/ui/slider/Slider.vue +8 -6
- package/src/components/ui/spinner/Spinner.vue +47 -0
- package/src/components/ui/spinner/index.ts +1 -0
- package/src/components/ui/stepper/SimpleStepper.vue +56 -0
- package/src/components/ui/stepper/Stepper.vue +30 -0
- package/src/components/ui/stepper/StepperDescription.vue +20 -0
- package/src/components/ui/stepper/StepperIndicator.vue +41 -0
- package/src/components/ui/stepper/StepperItem.vue +21 -0
- package/src/components/ui/stepper/StepperSeparator.vue +34 -0
- package/src/components/ui/stepper/StepperTitle.vue +20 -0
- package/src/components/ui/stepper/StepperTrigger.vue +20 -0
- package/src/components/ui/stepper/index.ts +17 -0
- package/src/components/ui/switch/Switch.vue +8 -6
- package/src/components/ui/tabs/TabsContent.vue +1 -1
- package/src/components/ui/tabs/TabsList.vue +13 -5
- package/src/components/ui/tabs/TabsTrigger.vue +5 -3
- package/src/components/ui/tags-input/TagsInput.vue +49 -0
- package/src/components/ui/tags-input/TagsInputInput.vue +23 -0
- package/src/components/ui/tags-input/TagsInputItem.vue +35 -0
- package/src/components/ui/tags-input/TagsInputItemDelete.vue +31 -0
- package/src/components/ui/tags-input/TagsInputItemText.vue +21 -0
- package/src/components/ui/tags-input/index.ts +5 -0
- package/src/components/ui/textarea/Textarea.vue +4 -5
- package/src/components/ui/toggle-group/Toggle.vue +33 -0
- package/src/components/ui/toggle-group/ToggleGroup.vue +93 -0
- package/src/components/ui/toggle-group/ToggleGroupItem.vue +34 -0
- package/src/components/ui/toggle-group/index.ts +69 -0
- package/src/components/ui/tooltip/TooltipContent.vue +1 -1
- package/src/i18n/de.json +83 -0
- package/src/i18n/en.json +83 -0
- package/src/i18n/es.json +83 -0
- package/src/i18n/fr.json +83 -0
- package/src/i18n/it.json +83 -0
- package/src/i18n/ja.json +83 -0
- package/src/i18n/pt.json +83 -0
- package/src/i18n/ru.json +83 -0
- package/src/i18n/zh.json +83 -0
- package/src/lib/color-tokens.ts +14 -14
- 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-
|
|
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>
|
|
@@ -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
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
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>
|