@teamofsilicons/silicon-ui 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +81 -2
- package/check.mjs +236 -0
- package/components/accordion/accordion.d.ts +13 -0
- package/components/accordion/accordion.js +35 -0
- package/components/accordion/accordion.module.css +15 -0
- package/components/action-button/action-button.d.ts +11 -0
- package/components/action-button/action-button.js +57 -0
- package/components/action-button/action-button.module.css +15 -0
- package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
- package/components/activity-heatmap/activity-heatmap.js +303 -0
- package/components/activity-heatmap/activity-heatmap.module.css +83 -0
- package/components/alert/alert.d.ts +13 -0
- package/components/alert/alert.js +79 -0
- package/components/alert/alert.module.css +15 -0
- package/components/animated-counter/animated-counter.d.ts +13 -0
- package/components/animated-counter/animated-counter.js +73 -0
- package/components/animated-counter/animated-counter.module.css +12 -0
- package/components/announcement-bar/announcement-bar.d.ts +53 -0
- package/components/announcement-bar/announcement-bar.js +187 -0
- package/components/announcement-bar/announcement-bar.module.css +60 -0
- package/components/avatar/avatar.d.ts +8 -0
- package/components/avatar/avatar.js +18 -0
- package/components/avatar/avatar.module.css +60 -0
- package/components/avatar-group/avatar-group.d.ts +13 -0
- package/components/avatar-group/avatar-group.js +24 -0
- package/components/avatar-group/avatar-group.module.css +27 -0
- package/components/badge/badge.d.ts +10 -0
- package/components/badge/badge.js +66 -0
- package/components/badge/badge.module.css +52 -0
- package/components/bar-chart/bar-chart.d.ts +32 -0
- package/components/bar-chart/bar-chart.js +327 -0
- package/components/bar-chart/bar-chart.module.css +49 -0
- package/components/billing-toggle/billing-toggle.d.ts +35 -0
- package/components/billing-toggle/billing-toggle.js +83 -0
- package/components/billing-toggle/billing-toggle.module.css +139 -0
- package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
- package/components/bottom-sheet/bottom-sheet.js +386 -0
- package/components/bottom-sheet/bottom-sheet.module.css +69 -0
- package/components/breadcrumb/breadcrumb.d.ts +13 -0
- package/components/breadcrumb/breadcrumb.js +19 -0
- package/components/breadcrumb/breadcrumb.module.css +12 -0
- package/components/brush-chart/brush-chart.d.ts +45 -0
- package/components/brush-chart/brush-chart.js +426 -0
- package/components/brush-chart/brush-chart.module.css +69 -0
- package/components/button/button.d.ts +10 -0
- package/components/button/button.js +97 -0
- package/components/button/button.module.css +39 -0
- package/components/button-group/button-group.d.ts +59 -0
- package/components/button-group/button-group.js +280 -0
- package/components/button-group/button-group.module.css +87 -0
- package/components/button-link/button-link.d.ts +24 -0
- package/components/button-link/button-link.js +11 -0
- package/components/button-link/button-link.module.css +7 -0
- package/components/calendar/calendar.d.ts +22 -0
- package/components/calendar/calendar.js +253 -0
- package/components/calendar/calendar.module.css +51 -0
- package/components/card/card.d.ts +19 -0
- package/components/card/card.js +108 -0
- package/components/card/card.module.css +51 -0
- package/components/card-stack/card-stack.d.ts +34 -0
- package/components/card-stack/card-stack.js +291 -0
- package/components/card-stack/card-stack.module.css +63 -0
- package/components/carousel/carousel.d.ts +28 -0
- package/components/carousel/carousel.js +332 -0
- package/components/carousel/carousel.module.css +42 -0
- package/components/chat-thread/chat-thread.d.ts +135 -0
- package/components/chat-thread/chat-thread.js +406 -0
- package/components/chat-thread/chat-thread.module.css +242 -0
- package/components/checkbox/checkbox.d.ts +7 -0
- package/components/checkbox/checkbox.js +23 -0
- package/components/checkbox/checkbox.module.css +15 -0
- package/components/chip-group/chip-group.d.ts +24 -0
- package/components/chip-group/chip-group.js +196 -0
- package/components/chip-group/chip-group.module.css +38 -0
- package/components/code-block/code-block.d.ts +11 -0
- package/components/code-block/code-block.js +133 -0
- package/components/code-block/code-block.module.css +43 -0
- package/components/color-picker/color-picker.d.ts +40 -0
- package/components/color-picker/color-picker.js +449 -0
- package/components/color-picker/color-picker.module.css +111 -0
- package/components/combobox/combobox.d.ts +19 -0
- package/components/combobox/combobox.js +167 -0
- package/components/combobox/combobox.module.css +28 -0
- package/components/command-palette/command-palette.d.ts +20 -0
- package/components/command-palette/command-palette.js +151 -0
- package/components/command-palette/command-palette.module.css +94 -0
- package/components/comment-thread/comment-thread.d.ts +120 -0
- package/components/comment-thread/comment-thread.js +343 -0
- package/components/comment-thread/comment-thread.module.css +195 -0
- package/components/confirm-morph/confirm-morph.d.ts +65 -0
- package/components/confirm-morph/confirm-morph.js +355 -0
- package/components/confirm-morph/confirm-morph.module.css +146 -0
- package/components/context-menu/context-menu.d.ts +18 -0
- package/components/context-menu/context-menu.js +148 -0
- package/components/context-menu/context-menu.module.css +17 -0
- package/components/copy-button/copy-button.d.ts +10 -0
- package/components/copy-button/copy-button.js +69 -0
- package/components/copy-button/copy-button.module.css +21 -0
- package/components/countdown/countdown.d.ts +31 -0
- package/components/countdown/countdown.js +177 -0
- package/components/countdown/countdown.module.css +49 -0
- package/components/date-picker/date-picker.d.ts +17 -0
- package/components/date-picker/date-picker.js +100 -0
- package/components/date-picker/date-picker.module.css +34 -0
- package/components/date-range-picker/date-range-picker.d.ts +44 -0
- package/components/date-range-picker/date-range-picker.js +534 -0
- package/components/date-range-picker/date-range-picker.module.css +214 -0
- package/components/dialog/dialog.d.ts +11 -0
- package/components/dialog/dialog.js +52 -0
- package/components/dialog/dialog.module.css +36 -0
- package/components/donut-chart/donut-chart.d.ts +46 -0
- package/components/donut-chart/donut-chart.js +382 -0
- package/components/donut-chart/donut-chart.module.css +48 -0
- package/components/drawer/drawer.d.ts +20 -0
- package/components/drawer/drawer.js +111 -0
- package/components/drawer/drawer.module.css +97 -0
- package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
- package/components/dropdown-menu/dropdown-menu.js +39 -0
- package/components/dropdown-menu/dropdown-menu.module.css +38 -0
- package/components/elastic-slider/elastic-slider.d.ts +39 -0
- package/components/elastic-slider/elastic-slider.js +261 -0
- package/components/elastic-slider/elastic-slider.module.css +40 -0
- package/components/empty-state/empty-state.d.ts +12 -0
- package/components/empty-state/empty-state.js +69 -0
- package/components/empty-state/empty-state.module.css +13 -0
- package/components/expandable-card/expandable-card.d.ts +13 -0
- package/components/expandable-card/expandable-card.js +89 -0
- package/components/expandable-card/expandable-card.module.css +22 -0
- package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
- package/components/expanding-button-group/expanding-button-group.js +258 -0
- package/components/expanding-button-group/expanding-button-group.module.css +44 -0
- package/components/expanding-search/expanding-search.d.ts +38 -0
- package/components/expanding-search/expanding-search.js +242 -0
- package/components/expanding-search/expanding-search.module.css +52 -0
- package/components/file-dropzone/file-dropzone.d.ts +44 -0
- package/components/file-dropzone/file-dropzone.js +379 -0
- package/components/file-dropzone/file-dropzone.module.css +115 -0
- package/components/file-upload/file-upload.d.ts +22 -0
- package/components/file-upload/file-upload.js +192 -0
- package/components/file-upload/file-upload.module.css +49 -0
- package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
- package/components/filter-toolbar/filter-toolbar.js +446 -0
- package/components/filter-toolbar/filter-toolbar.module.css +70 -0
- package/components/floating-button-group/floating-button-group.d.ts +51 -0
- package/components/floating-button-group/floating-button-group.js +215 -0
- package/components/floating-button-group/floating-button-group.module.css +74 -0
- package/components/gauge/gauge.d.ts +18 -0
- package/components/gauge/gauge.js +101 -0
- package/components/gauge/gauge.module.css +22 -0
- package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
- package/components/hold-to-confirm/hold-to-confirm.js +165 -0
- package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
- package/components/hover-card/hover-card.d.ts +38 -0
- package/components/hover-card/hover-card.js +145 -0
- package/components/hover-card/hover-card.module.css +20 -0
- package/components/image-compare/image-compare.d.ts +27 -0
- package/components/image-compare/image-compare.js +267 -0
- package/components/image-compare/image-compare.module.css +25 -0
- package/components/in-view-title/in-view-title.d.ts +17 -0
- package/components/in-view-title/in-view-title.js +70 -0
- package/components/in-view-title/in-view-title.module.css +13 -0
- package/components/inline-edit/inline-edit.d.ts +27 -0
- package/components/inline-edit/inline-edit.js +315 -0
- package/components/inline-edit/inline-edit.module.css +73 -0
- package/components/input/input.d.ts +7 -0
- package/components/input/input.js +60 -0
- package/components/input/input.module.css +22 -0
- package/components/json-viewer/json-viewer.d.ts +48 -0
- package/components/json-viewer/json-viewer.js +463 -0
- package/components/json-viewer/json-viewer.module.css +92 -0
- package/components/line-chart/line-chart.d.ts +58 -0
- package/components/line-chart/line-chart.js +383 -0
- package/components/line-chart/line-chart.module.css +60 -0
- package/components/mention-input/mention-input.d.ts +72 -0
- package/components/mention-input/mention-input.js +417 -0
- package/components/mention-input/mention-input.module.css +134 -0
- package/components/metric-card/metric-card.d.ts +8 -0
- package/components/metric-card/metric-card.js +44 -0
- package/components/metric-card/metric-card.module.css +18 -0
- package/components/money-input/money-input.d.ts +48 -0
- package/components/money-input/money-input.js +503 -0
- package/components/money-input/money-input.module.css +77 -0
- package/components/morph-select/morph-select.d.ts +50 -0
- package/components/morph-select/morph-select.js +395 -0
- package/components/morph-select/morph-select.module.css +88 -0
- package/components/multi-select/multi-select.d.ts +18 -0
- package/components/multi-select/multi-select.js +105 -0
- package/components/multi-select/multi-select.module.css +37 -0
- package/components/notification-center/notification-center.d.ts +24 -0
- package/components/notification-center/notification-center.js +146 -0
- package/components/notification-center/notification-center.module.css +79 -0
- package/components/number-field/number-field.d.ts +41 -0
- package/components/number-field/number-field.js +455 -0
- package/components/number-field/number-field.module.css +71 -0
- package/components/otp-input/otp-input.d.ts +13 -0
- package/components/otp-input/otp-input.js +129 -0
- package/components/otp-input/otp-input.module.css +28 -0
- package/components/pagination/pagination.d.ts +7 -0
- package/components/pagination/pagination.js +60 -0
- package/components/pagination/pagination.module.css +16 -0
- package/components/password-field/password-field.d.ts +14 -0
- package/components/password-field/password-field.js +51 -0
- package/components/password-field/password-field.module.css +27 -0
- package/components/password-strength/password-strength.d.ts +107 -0
- package/components/password-strength/password-strength.js +161 -0
- package/components/password-strength/password-strength.module.css +57 -0
- package/components/phone-input/phone-input.d.ts +73 -0
- package/components/phone-input/phone-input.js +612 -0
- package/components/phone-input/phone-input.module.css +98 -0
- package/components/popover/popover.d.ts +6 -0
- package/components/popover/popover.js +19 -0
- package/components/popover/popover.module.css +32 -0
- package/components/progress/progress.d.ts +8 -0
- package/components/progress/progress.js +42 -0
- package/components/progress/progress.module.css +1 -0
- package/components/radio-cards/radio-cards.d.ts +35 -0
- package/components/radio-cards/radio-cards.js +110 -0
- package/components/radio-cards/radio-cards.module.css +84 -0
- package/components/radio-group/radio-group.d.ts +13 -0
- package/components/radio-group/radio-group.js +55 -0
- package/components/radio-group/radio-group.module.css +30 -0
- package/components/resizable-panels/resizable-panels.d.ts +37 -0
- package/components/resizable-panels/resizable-panels.js +338 -0
- package/components/resizable-panels/resizable-panels.module.css +40 -0
- package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
- package/components/rich-text-editor/rich-text-editor.js +1733 -0
- package/components/rich-text-editor/rich-text-editor.module.css +201 -0
- package/components/ridgeline/ridgeline.d.ts +37 -0
- package/components/ridgeline/ridgeline.js +346 -0
- package/components/ridgeline/ridgeline.module.css +58 -0
- package/components/scroll-area/scroll-area.d.ts +40 -0
- package/components/scroll-area/scroll-area.js +153 -0
- package/components/scroll-area/scroll-area.module.css +79 -0
- package/components/search-field/search-field.d.ts +7 -0
- package/components/search-field/search-field.js +20 -0
- package/components/search-field/search-field.module.css +17 -0
- package/components/segmented-control/segmented-control.d.ts +16 -0
- package/components/segmented-control/segmented-control.js +56 -0
- package/components/segmented-control/segmented-control.module.css +15 -0
- package/components/select/select.d.ts +15 -0
- package/components/select/select.js +36 -0
- package/components/select/select.module.css +35 -0
- package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
- package/components/shortcut-recorder/shortcut-recorder.js +380 -0
- package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
- package/components/signature-pad/signature-pad.d.ts +72 -0
- package/components/signature-pad/signature-pad.js +450 -0
- package/components/signature-pad/signature-pad.module.css +84 -0
- package/components/skeleton/skeleton.d.ts +13 -0
- package/components/skeleton/skeleton.js +53 -0
- package/components/skeleton/skeleton.module.css +11 -0
- package/components/slider/slider.d.ts +45 -0
- package/components/slider/slider.js +347 -0
- package/components/slider/slider.module.css +45 -0
- package/components/slope-chart/slope-chart.d.ts +38 -0
- package/components/slope-chart/slope-chart.js +183 -0
- package/components/slope-chart/slope-chart.module.css +51 -0
- package/components/slot-text/slot-text.d.ts +26 -0
- package/components/slot-text/slot-text.js +119 -0
- package/components/slot-text/slot-text.module.css +19 -0
- package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
- package/components/sortable-data-table/sortable-data-table.js +240 -0
- package/components/sortable-data-table/sortable-data-table.module.css +116 -0
- package/components/sparkline/sparkline.d.ts +19 -0
- package/components/sparkline/sparkline.js +239 -0
- package/components/sparkline/sparkline.module.css +26 -0
- package/components/split-button/split-button.d.ts +19 -0
- package/components/split-button/split-button.js +33 -0
- package/components/split-button/split-button.module.css +51 -0
- package/components/stepper/stepper.d.ts +36 -0
- package/components/stepper/stepper.js +108 -0
- package/components/stepper/stepper.module.css +74 -0
- package/components/streamgraph/streamgraph.d.ts +51 -0
- package/components/streamgraph/streamgraph.js +376 -0
- package/components/streamgraph/streamgraph.module.css +55 -0
- package/components/swipe-actions/swipe-actions.d.ts +35 -0
- package/components/swipe-actions/swipe-actions.js +330 -0
- package/components/swipe-actions/swipe-actions.module.css +64 -0
- package/components/switch/switch.d.ts +7 -0
- package/components/switch/switch.js +45 -0
- package/components/switch/switch.module.css +12 -0
- package/components/tabs/tabs.d.ts +8 -0
- package/components/tabs/tabs.js +138 -0
- package/components/tabs/tabs.module.css +27 -0
- package/components/tag-input/tag-input.d.ts +15 -0
- package/components/tag-input/tag-input.js +150 -0
- package/components/tag-input/tag-input.module.css +34 -0
- package/components/text-morph/text-morph.d.ts +13 -0
- package/components/text-morph/text-morph.js +69 -0
- package/components/text-morph/text-morph.module.css +8 -0
- package/components/text-reveal/text-reveal.d.ts +14 -0
- package/components/text-reveal/text-reveal.js +20 -0
- package/components/text-reveal/text-reveal.module.css +13 -0
- package/components/text-shimmer/text-shimmer.d.ts +18 -0
- package/components/text-shimmer/text-shimmer.js +90 -0
- package/components/text-shimmer/text-shimmer.module.css +9 -0
- package/components/textarea/textarea.d.ts +7 -0
- package/components/textarea/textarea.js +56 -0
- package/components/textarea/textarea.module.css +22 -0
- package/components/theme-switch/theme-switch.d.ts +11 -0
- package/components/theme-switch/theme-switch.js +37 -0
- package/components/theme-switch/theme-switch.module.css +31 -0
- package/components/time-picker/time-picker.d.ts +13 -0
- package/components/time-picker/time-picker.js +95 -0
- package/components/time-picker/time-picker.module.css +23 -0
- package/components/timeline/timeline.d.ts +47 -0
- package/components/timeline/timeline.js +171 -0
- package/components/timeline/timeline.module.css +51 -0
- package/components/toast/toast.d.ts +8 -0
- package/components/toast/toast.js +118 -0
- package/components/toast/toast.module.css +100 -0
- package/components/toast-stack/toast-stack.d.ts +57 -0
- package/components/toast-stack/toast-stack.js +399 -0
- package/components/toast-stack/toast-stack.module.css +61 -0
- package/components/tooltip/tooltip.d.ts +8 -0
- package/components/tooltip/tooltip.js +62 -0
- package/components/tooltip/tooltip.module.css +18 -0
- package/components/tree-view/tree-view.d.ts +17 -0
- package/components/tree-view/tree-view.js +147 -0
- package/components/tree-view/tree-view.module.css +22 -0
- package/components/treemap/treemap.d.ts +35 -0
- package/components/treemap/treemap.js +375 -0
- package/components/treemap/treemap.module.css +59 -0
- package/components/usage-meter/usage-meter.d.ts +24 -0
- package/components/usage-meter/usage-meter.js +296 -0
- package/components/usage-meter/usage-meter.module.css +61 -0
- package/components/user-menu/user-menu.d.ts +62 -0
- package/components/user-menu/user-menu.js +316 -0
- package/components/user-menu/user-menu.module.css +97 -0
- package/components/waffle-chart/waffle-chart.d.ts +39 -0
- package/components/waffle-chart/waffle-chart.js +244 -0
- package/components/waffle-chart/waffle-chart.module.css +52 -0
- package/components.json +1330 -0
- package/eslint.js +53 -0
- package/lib/escape.d.ts +19 -0
- package/lib/escape.js +50 -0
- package/lib/media.d.ts +353 -0
- package/lib/media.js +65 -0
- package/lib/menu-highlight.d.ts +28 -0
- package/lib/menu-highlight.js +43 -0
- package/lib/motion-tokens.d.ts +60 -0
- package/lib/motion-tokens.js +25 -0
- package/lib/return-focus.d.ts +9 -0
- package/lib/return-focus.js +53 -0
- package/lib/silicon-provider.d.ts +60 -0
- package/lib/silicon-provider.js +47 -0
- package/lib/use-copy-feedback.d.ts +8 -0
- package/lib/use-copy-feedback.js +36 -0
- package/lib/use-today.d.ts +2 -0
- package/lib/use-today.js +29 -0
- package/package.json +76 -4
- package/styles.css +218 -0
- package/styles.css.d.ts +1 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { ArrowUp, CircleAlert, File as FileIcon, FileArchive, FileImage, FilePlay, FileText, RotateCw, X } from "lucide-react";
|
|
5
|
+
import { AnimatePresence, animate, motion, useIsPresent, useMotionTemplate, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } from "motion/react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./file-dropzone.module.css";
|
|
8
|
+
const MB = 1024 * 1024;
|
|
9
|
+
const PERIOD = 7;
|
|
10
|
+
const enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
|
|
11
|
+
const exitFast = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
|
|
12
|
+
const instant = { duration: 0 };
|
|
13
|
+
const fade = { duration: motionTokens.duration.instant };
|
|
14
|
+
/** Exits are shorter than entries: the same critically damped spring, settled sooner. */
|
|
15
|
+
const collapse = { ...motionTokens.spring.smooth, visualDuration: .3 };
|
|
16
|
+
const textIn = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
|
|
17
|
+
const textOut = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };
|
|
18
|
+
const shown = { opacity: 1, y: "0em", filter: "blur(0px)" };
|
|
19
|
+
const clamp = (value) => Math.min(Math.max(value, 0), 100);
|
|
20
|
+
/** A short, decaying side-to-side shake: the refusal reads as "no" without moving anything else. */
|
|
21
|
+
const SHAKE = { x: [0, -7, 6, -4, 3, -1.5, 0] };
|
|
22
|
+
const shakeTransition = { duration: .42, ease: "easeOut" };
|
|
23
|
+
/** New rows drop out of the target into the list, with a touch of life as they land. */
|
|
24
|
+
const land = { type: "spring", visualDuration: .42, bounce: .14 };
|
|
25
|
+
export function formatFileSize(bytes) {
|
|
26
|
+
if (bytes < 1024)
|
|
27
|
+
return `${bytes} B`;
|
|
28
|
+
if (bytes < MB)
|
|
29
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
30
|
+
const mb = bytes / MB;
|
|
31
|
+
return `${mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1))} MB`;
|
|
32
|
+
}
|
|
33
|
+
function TypeIcon({ name }) {
|
|
34
|
+
const props = { size: 20, strokeWidth: 1.75, "aria-hidden": true };
|
|
35
|
+
const extension = name.toLowerCase().split(".").pop() ?? "";
|
|
36
|
+
if (["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "heic"].includes(extension))
|
|
37
|
+
return _jsx(FileImage, { ...props });
|
|
38
|
+
if (["mov", "mp4", "webm", "m4v", "avi"].includes(extension))
|
|
39
|
+
return _jsx(FilePlay, { ...props });
|
|
40
|
+
if (["zip", "gz", "tar", "rar", "7z"].includes(extension))
|
|
41
|
+
return _jsx(FileArchive, { ...props });
|
|
42
|
+
if (["pdf", "md", "txt", "doc", "docx", "rtf"].includes(extension))
|
|
43
|
+
return _jsx(FileText, { ...props });
|
|
44
|
+
return _jsx(FileIcon, { ...props });
|
|
45
|
+
}
|
|
46
|
+
/** Outgoing copies are hidden from assistive tech while they leave, so only the current text is read. */
|
|
47
|
+
function Swap(props) {
|
|
48
|
+
const present = useIsPresent();
|
|
49
|
+
return _jsx(motion.span, { ...props, "aria-hidden": present ? props["aria-hidden"] : true });
|
|
50
|
+
}
|
|
51
|
+
/** The new text rises into place and unblurs while the old one lifts away, in the same slot. */
|
|
52
|
+
function TextSwap({ text, className, reduce }) {
|
|
53
|
+
return _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(Swap, { className: className, initial: reduce ? { opacity: 0 } : textIn, animate: shown, exit: reduce ? { opacity: 0, transition: fade } : textOut, transition: reduce ? fade : enter, children: text }, text) });
|
|
54
|
+
}
|
|
55
|
+
/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */
|
|
56
|
+
function MotionText({ text }) {
|
|
57
|
+
const reduced = useReducedMotion();
|
|
58
|
+
const words = text.split(" ");
|
|
59
|
+
return _jsxs(_Fragment, { children: [_jsx("span", { className: styles.srOnly, children: text }), _jsx("span", { className: styles.words, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: words.map((word, index) => _jsx(motion.span, { className: styles.word, initial: reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.35em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }, transition: reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: index < words.length - 1 ? `${word} ` : word }, `${index}:${word}`)) }) })] });
|
|
60
|
+
}
|
|
61
|
+
/** The error row opens on a spring and follows the measured copy, so a longer message that wraps opens its next line instead of snapping. */
|
|
62
|
+
function ErrorRow({ text }) {
|
|
63
|
+
const reduced = useReducedMotion();
|
|
64
|
+
const copyRef = useRef(null);
|
|
65
|
+
const [height, setHeight] = useState("auto");
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const node = copyRef.current;
|
|
68
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
69
|
+
return;
|
|
70
|
+
const observer = new ResizeObserver(([entry]) => { if (entry)
|
|
71
|
+
setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight); });
|
|
72
|
+
observer.observe(node);
|
|
73
|
+
return () => observer.disconnect();
|
|
74
|
+
}, []);
|
|
75
|
+
return _jsx(motion.div, { className: styles.messageSlot, initial: { height: 0, opacity: 0 }, animate: { height, opacity: 1 }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }, children: _jsxs(motion.div, { ref: copyRef, className: styles.error, role: "alert", initial: reduced ? false : { y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { y: 0, filter: "blur(0px)" }, transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: [_jsx(CircleAlert, { className: styles.errorIcon, size: 14, strokeWidth: 2.25, "aria-hidden": "true" }), _jsx("span", { className: styles.errorCopy, children: _jsx(MotionText, { text: text }) })] }) });
|
|
76
|
+
}
|
|
77
|
+
/** The circle closes, then the tick draws through it. */
|
|
78
|
+
function DrawnCheck({ reduce }) {
|
|
79
|
+
return _jsxs("svg", { className: styles.stateIcon, width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.25, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx(motion.path, { d: "M12 2.5a9.5 9.5 0 1 1 0 19a9.5 9.5 0 1 1 0-19", initial: reduce ? false : { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: motionTokens.duration.considered * .7, ease: [...motionTokens.ease.enter] } }), _jsx(motion.path, { d: "m8.2 12.4 2.6 2.6 5-5.2", initial: reduce ? false : { pathLength: 0 }, animate: { pathLength: 1 }, transition: { ...enter, delay: .16 } })] });
|
|
80
|
+
}
|
|
81
|
+
function Thumb({ item }) {
|
|
82
|
+
const [broken, setBroken] = useState(false);
|
|
83
|
+
if (!item.preview || broken)
|
|
84
|
+
return _jsx("span", { className: styles.fileIcon, children: _jsx(TypeIcon, { name: item.name }) });
|
|
85
|
+
// eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic, and previews are often object URLs
|
|
86
|
+
return _jsx("span", { className: styles.thumb, children: _jsx("img", { src: item.preview, alt: "", width: 40, height: 40, decoding: "async", onError: () => setBroken(true) }) });
|
|
87
|
+
}
|
|
88
|
+
function FileRow({ item, reduce, delay, fresh, canRetry, onRemove, onRetry, removeRef }) {
|
|
89
|
+
const { status } = item;
|
|
90
|
+
const rowRef = useRef(null);
|
|
91
|
+
// A failure shakes the row once: a file refused on arrival after it lands, an upload that fails at the moment it fails.
|
|
92
|
+
const shakenFor = useRef(fresh ? "new" : status);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const previous = shakenFor.current;
|
|
95
|
+
if (status === previous)
|
|
96
|
+
return;
|
|
97
|
+
shakenFor.current = status;
|
|
98
|
+
const node = rowRef.current;
|
|
99
|
+
if (status !== "failed" || reduce || !node)
|
|
100
|
+
return;
|
|
101
|
+
animate(node, SHAKE, { ...shakeTransition, delay: previous === "new" ? delay + .3 : 0 });
|
|
102
|
+
}, [status, reduce, delay]);
|
|
103
|
+
// One spring drives the bar and the counted percentage, so both always agree.
|
|
104
|
+
const progress = useMotionValue(status === "uploaded" ? 100 : item.progress ?? 0);
|
|
105
|
+
const x = useTransform(progress, value => `${clamp(value) - 100}%`);
|
|
106
|
+
const percent = useTransform(progress, value => `${Math.round(clamp(value))}%`);
|
|
107
|
+
// A finished upload keeps its bar until the spring reaches the end, so 100% is seen before the label turns to Uploaded.
|
|
108
|
+
const [filled, setFilled] = useState(status !== "uploading");
|
|
109
|
+
const [seen, setSeen] = useState(status);
|
|
110
|
+
if (seen !== status) {
|
|
111
|
+
setSeen(status);
|
|
112
|
+
if (status === "uploading")
|
|
113
|
+
setFilled(false);
|
|
114
|
+
}
|
|
115
|
+
const phase = status === "uploaded" && !filled && !reduce ? "uploading" : status;
|
|
116
|
+
const target = status === "uploaded" ? 100 : item.progress ?? 0;
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (status !== "uploading" && status !== "uploaded")
|
|
119
|
+
return;
|
|
120
|
+
// Every upload starts from an empty bar, even a retry that failed halfway.
|
|
121
|
+
if (reduce || (status === "uploading" && target === 0)) {
|
|
122
|
+
progress.jump(target);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const controls = animate(progress, target, { ...motionTokens.spring.smooth, onComplete: status === "uploaded" ? () => setFilled(true) : undefined });
|
|
126
|
+
return () => controls.stop();
|
|
127
|
+
}, [status, target, progress, reduce]);
|
|
128
|
+
// The label turns as soon as the count reads 100%, without waiting out the spring's last fraction of a pixel.
|
|
129
|
+
useMotionValueEvent(progress, "change", value => { if (status === "uploaded" && value >= 99.5)
|
|
130
|
+
setFilled(true); });
|
|
131
|
+
const failed = status === "failed";
|
|
132
|
+
const retry = failed && canRetry && item.retryable !== false;
|
|
133
|
+
return _jsx(motion.li, { className: styles.item, initial: reduce ? { opacity: 0 } : { height: 0 }, animate: { height: "auto", opacity: 1 }, exit: reduce ? { opacity: 0, transition: fade } : { height: 0, opacity: 0, transition: { height: collapse, opacity: { ...exitFast, delay: .04 } } }, transition: reduce ? fade : { height: { ...motionTokens.spring.smooth, delay }, opacity: fade }, children: _jsxs(motion.div, { ref: rowRef, className: `${styles.row} ${failed ? styles.isFailed : ""}`, initial: reduce ? false : { opacity: 0, y: -22, scale: .94, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }, exit: reduce ? undefined : { scale: .97, filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }, transition: reduce ? instant : { y: { ...land, delay }, scale: { ...land, delay }, opacity: { ...enter, delay }, filter: { ...enter, delay } }, children: [_jsx(Thumb, { item: item }), _jsxs("span", { className: styles.copy, children: [_jsx("span", { className: styles.name, title: item.name, children: item.name }), _jsxs("span", { className: styles.meta, children: [_jsx("span", { children: formatFileSize(item.size) }), phase && _jsxs(_Fragment, { children: [_jsx("span", { className: styles.dot, "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: styles.phase, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(Swap, { className: `${styles.state} ${phase === "uploaded" ? styles.uploaded : phase === "failed" ? styles.failed : ""}`, initial: reduce ? { opacity: 0 } : textIn, animate: shown, exit: reduce ? { opacity: 0, transition: fade } : textOut, transition: reduce ? fade : enter, children: phase === "uploading" ? _jsxs("span", { children: ["Uploading ", _jsx(motion.span, { className: styles.percent, children: percent })] })
|
|
134
|
+
: phase === "uploaded" ? _jsxs(_Fragment, { children: [_jsx(DrawnCheck, { reduce: reduce }), _jsx("span", { children: "Uploaded" })] })
|
|
135
|
+
: _jsxs(_Fragment, { children: [_jsx(CircleAlert, { className: styles.stateIcon, size: 14, strokeWidth: 2.25, "aria-hidden": "true" }), _jsxs("span", { className: styles.reason, title: item.error, children: [_jsx("span", { className: styles.srOnly, children: "Failed: " }), item.error || "Upload failed"] })] }) }, phase) }) })] })] }), _jsx(AnimatePresence, { initial: false, children: phase === "uploading" && _jsx(motion.span, { className: styles.barFrame, initial: reduce ? { opacity: 0 } : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: reduce ? { opacity: 0, transition: fade } : { height: 0, opacity: 0, transition: { height: { ...collapse, delay: .22 }, opacity: { ...exitFast, delay: .16 } } }, transition: reduce ? fade : { height: motionTokens.spring.smooth, opacity: enter }, children: _jsx("span", { className: styles.bar, role: "progressbar", "aria-label": `Uploading ${item.name}`, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(clamp(target)), children: _jsx(motion.span, { className: styles.barFill, style: { x } }) }) }, "bar") })] }), _jsxs("span", { className: styles.actions, children: [_jsx(AnimatePresence, { initial: false, children: retry && _jsx(motion.span, { className: styles.retrySlot, initial: reduce ? { opacity: 0 } : { width: 0, opacity: 0 }, animate: { width: "auto", opacity: 1 }, exit: reduce ? { opacity: 0, transition: fade } : { width: 0, opacity: 0, transition: { width: collapse, opacity: { duration: motionTokens.duration.instant } } }, transition: reduce ? fade : { width: motionTokens.spring.smooth, opacity: enter }, children: _jsx(motion.span, { className: styles.retryInner, initial: reduce ? false : { scale: .8, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { scale: 1, filter: "blur(0px)" }, transition: reduce ? instant : motionTokens.spring.snappy, children: _jsxs("button", { type: "button", className: styles.retry, onClick: onRetry, "aria-label": `Retry ${item.name}`, title: "Retry", children: [_jsx(RotateCw, { size: 14, strokeWidth: 2, "aria-hidden": "true" }), _jsx("span", { className: styles.retryLabel, children: "Retry" })] }) }) }, "retry") }), _jsx("button", { ref: removeRef, type: "button", className: styles.remove, onClick: onRemove, "aria-label": `Remove ${item.name}`, title: "Remove", children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] })] }) });
|
|
136
|
+
}
|
|
137
|
+
export function FileDropzone({ accept, multiple = true, maxFiles = 5, onFilesChange, label = "Add files", description = "Drop files here or choose from your device", defaultItems, onUpload, maxSize, listPlacement = "below", note, dropLabel, compactAt }) {
|
|
138
|
+
const [items, setItems] = useState(() => defaultItems ?? []);
|
|
139
|
+
const [dragging, setDragging] = useState(false);
|
|
140
|
+
const [error, setError] = useState("");
|
|
141
|
+
const [announcement, setAnnouncement] = useState("");
|
|
142
|
+
// Index of the first row in the latest batch, so only that batch staggers in.
|
|
143
|
+
const [batchStart, setBatchStart] = useState(0);
|
|
144
|
+
const inputRef = useRef(null);
|
|
145
|
+
const dropRef = useRef(null);
|
|
146
|
+
const zoneRef = useRef(null);
|
|
147
|
+
const edgeRef = useRef(null);
|
|
148
|
+
const dragDepth = useRef(0);
|
|
149
|
+
const nextId = useRef(0);
|
|
150
|
+
const controllers = useRef(new Map());
|
|
151
|
+
const removeRefs = useRef(new Map());
|
|
152
|
+
const descriptionId = useId();
|
|
153
|
+
const noteId = useId();
|
|
154
|
+
const reduce = !!useReducedMotion();
|
|
155
|
+
const inside = listPlacement === "inside";
|
|
156
|
+
const compact = compactAt !== undefined && items.length >= compactAt;
|
|
157
|
+
// Rows the visitor added in this session: only these fly in and shake when refused.
|
|
158
|
+
const [freshIds, setFreshIds] = useState(() => new Set());
|
|
159
|
+
// Thumbnails made here are object URLs, released when their row goes or the component unmounts.
|
|
160
|
+
const ownedPreviews = useRef(new Map());
|
|
161
|
+
const [hovered, setHovered] = useState(false);
|
|
162
|
+
const [focusWithin, setFocusWithin] = useState(false);
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const active = controllers.current;
|
|
165
|
+
const previews = ownedPreviews.current;
|
|
166
|
+
return () => { active.forEach(controller => controller.abort()); previews.forEach(url => URL.revokeObjectURL(url)); };
|
|
167
|
+
}, []);
|
|
168
|
+
/* The drag glow: a soft light on the edge and a faint wash that follow the pointer on a spring, so the outline leans toward the file. */
|
|
169
|
+
const pointerX = useMotionValue(50), pointerY = useMotionValue(50);
|
|
170
|
+
const glowSpring = { stiffness: 260, damping: 32, mass: .8 };
|
|
171
|
+
const glowX = useSpring(pointerX, glowSpring), glowY = useSpring(pointerY, glowSpring);
|
|
172
|
+
const edgeLight = useMotionTemplate `radial-gradient(180px circle at ${glowX}% ${glowY}%, var(--accent), transparent 70%)`;
|
|
173
|
+
const washLight = useMotionTemplate `radial-gradient(260px circle at ${glowX}% ${glowY}%, color-mix(in oklab, var(--accent) 6%, transparent), transparent 70%)`;
|
|
174
|
+
const track = (event) => {
|
|
175
|
+
const zone = zoneRef.current;
|
|
176
|
+
if (!zone)
|
|
177
|
+
return;
|
|
178
|
+
const box = zone.getBoundingClientRect();
|
|
179
|
+
const x = ((event.clientX - box.left) / Math.max(1, box.width)) * 100, y = ((event.clientY - box.top) / Math.max(1, box.height)) * 100;
|
|
180
|
+
if (reduce || !dragging) {
|
|
181
|
+
pointerX.jump(x);
|
|
182
|
+
pointerY.jump(y);
|
|
183
|
+
glowX.jump(x);
|
|
184
|
+
glowY.jump(y);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
pointerX.set(x);
|
|
188
|
+
pointerY.set(y);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const shakeZone = () => { if (!reduce && zoneRef.current)
|
|
192
|
+
animate(zoneRef.current, SHAKE, shakeTransition); };
|
|
193
|
+
// The dashed edge is drawn, not bordered, so its dashes can close into a solid line. Its length is fitted to the
|
|
194
|
+
// measured perimeter, so every dash is the same size and the seam never shows, at any width or list height.
|
|
195
|
+
useLayoutEffect(() => {
|
|
196
|
+
const zone = zoneRef.current, edge = edgeRef.current;
|
|
197
|
+
if (!zone || !edge)
|
|
198
|
+
return;
|
|
199
|
+
const fit = () => {
|
|
200
|
+
const width = zone.offsetWidth - 1, height = zone.offsetHeight - 1;
|
|
201
|
+
const radius = Math.max(0, Math.min(parseFloat(getComputedStyle(zone).borderTopLeftRadius) - .5, width / 2, height / 2));
|
|
202
|
+
const perimeter = 2 * (width + height) - 8 * radius + 2 * Math.PI * radius;
|
|
203
|
+
edge.setAttribute("rx", String(radius));
|
|
204
|
+
edge.setAttribute("pathLength", String(Math.max(8, Math.round(perimeter / PERIOD)) * PERIOD));
|
|
205
|
+
};
|
|
206
|
+
fit();
|
|
207
|
+
// A folding prompt also eases its corner radius, so the edge follows every frame of that transition.
|
|
208
|
+
let frame = 0;
|
|
209
|
+
const follow = () => { fit(); frame = requestAnimationFrame(follow); };
|
|
210
|
+
const onRun = (event) => { if (event.target === zone && event.propertyName.includes("radius")) {
|
|
211
|
+
cancelAnimationFrame(frame);
|
|
212
|
+
follow();
|
|
213
|
+
} };
|
|
214
|
+
const onEnd = (event) => { if (event.target === zone && event.propertyName.includes("radius")) {
|
|
215
|
+
cancelAnimationFrame(frame);
|
|
216
|
+
fit();
|
|
217
|
+
} };
|
|
218
|
+
zone.addEventListener("transitionrun", onRun);
|
|
219
|
+
zone.addEventListener("transitionend", onEnd);
|
|
220
|
+
zone.addEventListener("transitioncancel", onEnd);
|
|
221
|
+
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(fit);
|
|
222
|
+
observer?.observe(zone);
|
|
223
|
+
return () => { observer?.disconnect(); cancelAnimationFrame(frame); zone.removeEventListener("transitionrun", onRun); zone.removeEventListener("transitionend", onEnd); zone.removeEventListener("transitioncancel", onEnd); };
|
|
224
|
+
}, []);
|
|
225
|
+
const filesOf = (list) => list.flatMap(item => item.file ? [item.file] : []);
|
|
226
|
+
const patch = (id, next) => setItems(current => current.map(item => item.id === id ? next(item) : item));
|
|
227
|
+
function startUpload(item) {
|
|
228
|
+
if (!onUpload)
|
|
229
|
+
return;
|
|
230
|
+
controllers.current.get(item.id)?.abort();
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
controllers.current.set(item.id, controller);
|
|
233
|
+
patch(item.id, current => ({ ...current, status: "uploading", progress: 0, error: undefined }));
|
|
234
|
+
onUpload({ ...item, status: "uploading", progress: 0, error: undefined }, {
|
|
235
|
+
signal: controller.signal,
|
|
236
|
+
onProgress: percent => { if (!controller.signal.aborted)
|
|
237
|
+
patch(item.id, current => current.status === "uploading" ? { ...current, progress: Math.max(current.progress ?? 0, clamp(percent)) } : current); },
|
|
238
|
+
}).then(() => {
|
|
239
|
+
if (controller.signal.aborted)
|
|
240
|
+
return;
|
|
241
|
+
patch(item.id, current => ({ ...current, status: "uploaded", progress: 100 }));
|
|
242
|
+
setAnnouncement(`${item.name} uploaded`);
|
|
243
|
+
}, (reason) => {
|
|
244
|
+
if (controller.signal.aborted)
|
|
245
|
+
return;
|
|
246
|
+
const message = reason instanceof Error && reason.message ? reason.message : "Upload failed";
|
|
247
|
+
patch(item.id, current => ({ ...current, status: "failed", error: message, retryable: true }));
|
|
248
|
+
setAnnouncement(`${item.name} failed. ${message}`);
|
|
249
|
+
}).finally(() => { if (controllers.current.get(item.id) === controller)
|
|
250
|
+
controllers.current.delete(item.id); });
|
|
251
|
+
}
|
|
252
|
+
function addFiles(incoming) {
|
|
253
|
+
const list = Array.from(incoming);
|
|
254
|
+
const accepted = accept?.split(",").map(value => value.trim().toLowerCase()).filter(Boolean) ?? [];
|
|
255
|
+
const matching = list.filter(file => accepted.length === 0 || accepted.some(type => type.startsWith(".") ? file.name.toLowerCase().endsWith(type) : type.endsWith("/*") ? file.type.startsWith(type.slice(0, -1)) : file.type === type));
|
|
256
|
+
const kept = multiple ? items : [];
|
|
257
|
+
const same = (a, b) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
|
258
|
+
const fresh = matching.filter((file, index) => matching.findIndex(other => same(other, file)) === index && !kept.some(item => item.file && same(item.file, file)));
|
|
259
|
+
const room = multiple ? Math.max(0, maxFiles - kept.length) : 1;
|
|
260
|
+
const added = fresh.slice(0, room).map((file) => {
|
|
261
|
+
const tooLarge = maxSize !== undefined && file.size > maxSize;
|
|
262
|
+
const id = `${file.name}-${file.size}-${file.lastModified}-${nextId.current++}`;
|
|
263
|
+
const preview = file.type.startsWith("image/") && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : undefined;
|
|
264
|
+
if (preview)
|
|
265
|
+
ownedPreviews.current.set(id, preview);
|
|
266
|
+
return { id, name: file.name, size: file.size, file, preview, status: tooLarge ? "failed" : onUpload ? "uploading" : undefined, progress: 0, error: tooLarge ? `File is larger than ${formatFileSize(maxSize)}` : undefined, retryable: !tooLarge };
|
|
267
|
+
});
|
|
268
|
+
const rejected = matching.length !== list.length;
|
|
269
|
+
const overflow = fresh.length > added.length;
|
|
270
|
+
setError(rejected ? (list.length - matching.length === 1 && list.length === 1 ? `${list[0].name} is not an accepted file type.` : "Some files were not added because their type is not accepted.") : overflow ? `You can add up to ${maxFiles} ${maxFiles === 1 ? "file" : "files"}.` : "");
|
|
271
|
+
if (rejected || overflow)
|
|
272
|
+
shakeZone();
|
|
273
|
+
if (!added.length)
|
|
274
|
+
return;
|
|
275
|
+
if (!multiple) {
|
|
276
|
+
controllers.current.forEach(controller => controller.abort());
|
|
277
|
+
controllers.current.clear();
|
|
278
|
+
}
|
|
279
|
+
const next = [...kept, ...added];
|
|
280
|
+
setFreshIds(current => new Set([...current, ...added.map(item => item.id)]));
|
|
281
|
+
setBatchStart(kept.length);
|
|
282
|
+
setItems(current => multiple ? [...current, ...added] : added);
|
|
283
|
+
onFilesChange?.(filesOf(next));
|
|
284
|
+
const failed = added.filter(item => item.status === "failed");
|
|
285
|
+
setAnnouncement(`${added.length} ${added.length === 1 ? "file" : "files"} added.${failed.length ? ` ${failed.map(item => `${item.name}: ${item.error}`).join(". ")}.` : ""}`);
|
|
286
|
+
added.filter(item => item.status === "uploading").forEach(startUpload);
|
|
287
|
+
}
|
|
288
|
+
// Focus stays in the list: the next row's remove button, else the previous one, else the drop target.
|
|
289
|
+
function removeItem(target) {
|
|
290
|
+
controllers.current.get(target.id)?.abort();
|
|
291
|
+
controllers.current.delete(target.id);
|
|
292
|
+
const index = items.findIndex(item => item.id === target.id);
|
|
293
|
+
const neighbor = items[index + 1] ?? items[index - 1];
|
|
294
|
+
const next = items.filter(item => item.id !== target.id);
|
|
295
|
+
const preview = ownedPreviews.current.get(target.id);
|
|
296
|
+
// The row keeps its thumbnail while it closes, then the URL is released.
|
|
297
|
+
if (preview) {
|
|
298
|
+
ownedPreviews.current.delete(target.id);
|
|
299
|
+
window.setTimeout(() => URL.revokeObjectURL(preview), 600);
|
|
300
|
+
}
|
|
301
|
+
setItems(current => current.filter(item => item.id !== target.id));
|
|
302
|
+
setError("");
|
|
303
|
+
setAnnouncement(`${target.name} removed`);
|
|
304
|
+
if (target.file)
|
|
305
|
+
onFilesChange?.(filesOf(next));
|
|
306
|
+
requestAnimationFrame(() => (neighbor ? removeRefs.current.get(neighbor.id) : dropRef.current)?.focus());
|
|
307
|
+
}
|
|
308
|
+
// The retry button leaves with the failure, so focus moves to the row's remove button.
|
|
309
|
+
function retryItem(item) {
|
|
310
|
+
setAnnouncement(`Retrying ${item.name}`);
|
|
311
|
+
startUpload(item);
|
|
312
|
+
requestAnimationFrame(() => removeRefs.current.get(item.id)?.focus());
|
|
313
|
+
}
|
|
314
|
+
// Up and down move between rows, keeping the same action. Delete or Backspace removes the focused row.
|
|
315
|
+
function moveFocus(event) {
|
|
316
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
317
|
+
const row = event.target.closest(`.${styles.row}`);
|
|
318
|
+
const remove = row?.querySelector(`.${styles.remove}`);
|
|
319
|
+
if (remove) {
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
remove.click();
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp")
|
|
326
|
+
return;
|
|
327
|
+
const button = event.target.closest("button");
|
|
328
|
+
const rows = Array.from(event.currentTarget.querySelectorAll(`.${styles.row}`));
|
|
329
|
+
const row = button?.closest(`.${styles.row}`);
|
|
330
|
+
if (!button || !row)
|
|
331
|
+
return;
|
|
332
|
+
const nextRow = rows[rows.indexOf(row) + (event.key === "ArrowDown" ? 1 : -1)];
|
|
333
|
+
if (!nextRow)
|
|
334
|
+
return;
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
(nextRow.querySelector(`.${button.classList.contains(styles.retry) ? styles.retry : styles.remove}`) ?? nextRow.querySelector(`.${styles.remove}`))?.focus();
|
|
337
|
+
}
|
|
338
|
+
// Paste works while the pointer is over the target or focus is inside it, so a screenshot on the clipboard is one keystroke away.
|
|
339
|
+
const pasteArmed = hovered || focusWithin;
|
|
340
|
+
const addRef = useRef(addFiles);
|
|
341
|
+
useLayoutEffect(() => { addRef.current = addFiles; });
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (!pasteArmed)
|
|
344
|
+
return;
|
|
345
|
+
const onPaste = (event) => {
|
|
346
|
+
const files = event.clipboardData?.files;
|
|
347
|
+
if (!files?.length)
|
|
348
|
+
return;
|
|
349
|
+
const target = event.target;
|
|
350
|
+
if (target?.closest("input:not([type=file]), textarea, [contenteditable='true']"))
|
|
351
|
+
return;
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
addRef.current(files);
|
|
354
|
+
};
|
|
355
|
+
document.addEventListener("paste", onPaste);
|
|
356
|
+
return () => document.removeEventListener("paste", onPaste);
|
|
357
|
+
}, [pasteArmed]);
|
|
358
|
+
const carriesFiles = (event) => Array.from(event.dataTransfer?.types ?? []).includes("Files");
|
|
359
|
+
const dropCopy = dropLabel ?? (onUpload ? "Drop to upload" : multiple ? "Drop to add files" : "Drop to add the file");
|
|
360
|
+
const noteCopy = note ?? (accept ? `Accepted: ${accept}` : `Up to ${maxFiles} ${maxFiles === 1 ? "file" : "files"}`);
|
|
361
|
+
// Slots open and fold their own height, so the prompt changes shape on a spring instead of in one frame.
|
|
362
|
+
const slot = { initial: reduce ? { opacity: 0 } : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: reduce ? { opacity: 0, transition: fade } : { height: 0, opacity: 0, transition: { height: collapse, opacity: exitFast } }, transition: reduce ? fade : { height: motionTokens.spring.smooth, opacity: enter } };
|
|
363
|
+
const rowsInside = inside && items.length > 0;
|
|
364
|
+
// The list's own padding opens with the first row, so the gap under the target never appears in one frame.
|
|
365
|
+
const list = _jsx(motion.ul, { className: styles.list, "aria-label": "Files", "aria-hidden": items.length ? undefined : true, onKeyDown: moveFocus, initial: false, animate: { paddingTop: items.length && !inside ? 4 : 0, paddingBottom: items.length && inside ? 12 : 0 }, transition: reduce ? instant : motionTokens.spring.smooth, children: _jsx(AnimatePresence, { initial: false, children: items.map((item, index) => _jsx(FileRow, { item: item, reduce: reduce, delay: reduce ? 0 : Math.min(Math.max(0, index - batchStart), 7) * motionTokens.stagger.item * 1.6, fresh: freshIds.has(item.id), canRetry: !!onUpload, onRemove: () => removeItem(item), onRetry: () => retryItem(item), removeRef: node => { if (node)
|
|
366
|
+
removeRefs.current.set(item.id, node);
|
|
367
|
+
else
|
|
368
|
+
removeRefs.current.delete(item.id); } }, item.id)) }) });
|
|
369
|
+
return _jsxs("div", { className: styles.wrapper, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), onFocus: () => setFocusWithin(true), onBlur: event => { if (!event.currentTarget.contains(event.relatedTarget))
|
|
370
|
+
setFocusWithin(false); }, children: [_jsxs("div", { ref: zoneRef, className: `${styles.dropzone} ${dragging ? styles.dragging : ""} ${inside ? styles.inside : ""} ${compact ? styles.compact : ""}`, onDragEnter: event => { if (!carriesFiles(event))
|
|
371
|
+
return; event.preventDefault(); if (!dragDepth.current)
|
|
372
|
+
track(event); dragDepth.current += 1; setDragging(true); }, onDragOver: event => { if (!carriesFiles(event))
|
|
373
|
+
return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; track(event); }, onDragLeave: event => { if (!carriesFiles(event))
|
|
374
|
+
return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (!dragDepth.current)
|
|
375
|
+
setDragging(false); }, onDrop: event => { event.preventDefault(); dragDepth.current = 0; setDragging(false); if (event.dataTransfer.files.length)
|
|
376
|
+
addFiles(event.dataTransfer.files); }, children: [_jsx(motion.span, { className: styles.wash, style: { backgroundImage: washLight }, "aria-hidden": "true" }), _jsx("svg", { className: styles.edge, "aria-hidden": "true", children: _jsx("rect", { ref: edgeRef, width: "100%", height: "100%", rx: 33.5, pathLength: 1358 }) }), _jsx(motion.span, { className: styles.glow, style: { backgroundImage: edgeLight }, "aria-hidden": "true" }), _jsxs(motion.button, { ref: dropRef, type: "button", className: styles.trigger, onClick: () => inputRef.current?.click(), "aria-describedby": compact ? descriptionId : `${descriptionId} ${noteId}`, initial: false, animate: { paddingTop: compact ? 16 : 26, paddingBottom: compact ? (rowsInside ? 8 : 16) : rowsInside ? 26 : 34 }, transition: reduce ? instant : motionTokens.spring.smooth, children: [_jsx(AnimatePresence, { initial: false, children: (!compact || dragging) && _jsx(motion.span, { className: styles.iconSlot, ...slot, children: _jsxs("span", { className: styles.sheets, "aria-hidden": "true", children: [_jsx("span", { className: styles.sheet, "data-sheet": "back" }), _jsx("span", { className: styles.sheet, "data-sheet": "side" }), _jsx("span", { className: styles.sheet, "data-sheet": "front", children: _jsx(ArrowUp, { size: 14, strokeWidth: 2 }) })] }) }, "icon") }), _jsx("strong", { className: styles.label, children: _jsx(TextSwap, { text: dragging ? dropCopy : label, className: styles.labelText, reduce: reduce }) }), _jsx("span", { id: descriptionId, className: styles.hint, children: description }), _jsx(AnimatePresence, { initial: false, children: !compact && _jsx(motion.span, { className: styles.noteSlot, ...slot, children: _jsx("small", { id: noteId, className: styles.note, children: noteCopy }) }, "note") })] }), inside && list] }), _jsx("input", { ref: inputRef, className: styles.input, type: "file", accept: accept, multiple: multiple, tabIndex: -1, "aria-hidden": "true", onChange: event => { if (event.target.files)
|
|
377
|
+
addFiles(event.target.files); event.target.value = ""; } }), _jsx(AnimatePresence, { initial: false, children: error ? _jsx(ErrorRow, { text: error }, "error") : null }), !inside && list, _jsx("span", { className: styles.srOnly, role: "status", "aria-live": "polite", children: announcement })] });
|
|
378
|
+
}
|
|
379
|
+
export default FileDropzone;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* No grid gap: the error row and the file list carry their own spacing, so each can open from a true zero height. */
|
|
2
|
+
.wrapper { position: relative; display: grid; min-width: 0; color: var(--foreground); }
|
|
3
|
+
.dropzone { position: relative; display: grid; min-width: 0; border-radius: var(--radius-overlay); corner-shape: var(--corner-shape); background: var(--surface); transition: background-color var(--duration-standard) var(--ease-standard), border-radius var(--duration-spring) var(--ease-spring); }
|
|
4
|
+
/* A folded prompt is a slimmer surface, so its corners step down to the panel radius. */
|
|
5
|
+
.dropzone.compact { border-radius: var(--radius-container); corner-shape: var(--corner-shape); }
|
|
6
|
+
/* The edge is a drawn stroke, fitted to the perimeter in script, so a drag can close its dashes into one line. */
|
|
7
|
+
.edge { position: absolute; top: .5px; left: .5px; width: calc(100% - 1px); height: calc(100% - 1px); overflow: visible; pointer-events: none; }
|
|
8
|
+
.edge rect { fill: none; stroke: var(--border-strong); stroke-width: 1; stroke-dasharray: 3.5 3.5; stroke-dashoffset: 0; transition: stroke var(--duration-fast) var(--ease-standard), stroke-dasharray var(--duration-standard) var(--ease-standard), stroke-dashoffset var(--duration-standard) var(--ease-standard); }
|
|
9
|
+
.trigger { display: flex; width: 100%; flex-direction: column; align-items: stretch; justify-content: center; border: 0; border-radius: inherit; corner-shape: inherit; padding: 34px var(--space-comfort); background: transparent; color: inherit; font: inherit; letter-spacing: var(--tracking-body); text-align: center; cursor: pointer; }
|
|
10
|
+
.trigger > * { pointer-events: none; }
|
|
11
|
+
@media (hover: hover) and (pointer: fine) {
|
|
12
|
+
.dropzone:has(.trigger:hover) { background: var(--surface-muted); }
|
|
13
|
+
.dropzone:has(.trigger:hover) .edge rect { stroke: var(--text-muted); }
|
|
14
|
+
}
|
|
15
|
+
/* Color is the only press response: the target never scales. */
|
|
16
|
+
.dropzone:has(.trigger:active) { background: var(--surface-muted); }
|
|
17
|
+
/* A file over the target closes the dashes into a solid edge and the target breathes, slowly, while it waits. */
|
|
18
|
+
.dropzone.dragging { background: color-mix(in oklab, var(--accent) 2.5%, var(--surface)); animation: breathe 2.2s var(--ease-in-out) infinite; }
|
|
19
|
+
@keyframes breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.012); } }
|
|
20
|
+
/* Light that follows the pointer: a wash inside, and a brighter stretch of the edge nearest the file. */
|
|
21
|
+
.wash, .glow { position: absolute; inset: 0; border-radius: inherit; corner-shape: inherit; opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-standard); }
|
|
22
|
+
.glow { padding: 1.5px; -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); }
|
|
23
|
+
.dragging .wash, .dragging .glow { opacity: 1; }
|
|
24
|
+
.dragging .edge rect { stroke: color-mix(in oklab, var(--accent) 40%, var(--border)); stroke-dasharray: 7 0; stroke-dashoffset: 1.75; }
|
|
25
|
+
/* No gaps between the prompt's parts: each slot carries its own space, so a folding slot takes its space with it. */
|
|
26
|
+
.iconSlot, .noteSlot { display: flex; flex-direction: column; overflow: hidden; }
|
|
27
|
+
/* Three sheets of paper, the target's one illustration. The slot's top margin is room for them to lift without clipping. */
|
|
28
|
+
.sheets { position: relative; display: block; width: 36px; height: 44px; align-self: center; margin: 10px 0 14px; }
|
|
29
|
+
.sheet {
|
|
30
|
+
--r: 0deg; --x: 0px; --y: 0px;
|
|
31
|
+
position: absolute; inset: 0; display: grid; place-items: end center; padding: 0 0 5px;
|
|
32
|
+
border: 1px solid var(--border); border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: var(--surface-raised); color: var(--text-muted);
|
|
33
|
+
box-shadow: 0 1px 2px oklch(0% 0 0 / .05), 0 3px 8px oklch(0% 0 0 / .04);
|
|
34
|
+
transform: translate(var(--x), var(--y)) rotate(var(--r));
|
|
35
|
+
transform-origin: 50% 100%;
|
|
36
|
+
transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard);
|
|
37
|
+
}
|
|
38
|
+
.sheet::before, .sheet::after { position: absolute; top: 8px; left: 7px; width: 16px; height: 2px; border-radius: var(--radius-full); corner-shape: round; background: currentColor; content: ""; opacity: .3; }
|
|
39
|
+
.sheet::after { top: 13px; width: 11px; }
|
|
40
|
+
.sheet > svg { transition: transform var(--duration-spring) var(--ease-spring); }
|
|
41
|
+
.dragging .sheet > svg { transform: translateY(-3px); }
|
|
42
|
+
.sheet[data-sheet="back"] { --r: -10deg; --x: -9px; --y: 2px; }
|
|
43
|
+
.sheet[data-sheet="side"] { --r: 10deg; --x: 9px; --y: 2px; }
|
|
44
|
+
@media (hover: hover) and (pointer: fine) {
|
|
45
|
+
.dropzone:has(.trigger:hover) .sheet[data-sheet="back"] { --r: -15deg; --x: -13px; }
|
|
46
|
+
.dropzone:has(.trigger:hover) .sheet[data-sheet="side"] { --r: 15deg; --x: 13px; }
|
|
47
|
+
.dropzone:has(.trigger:hover) .sheet[data-sheet="front"] { --y: -2px; }
|
|
48
|
+
}
|
|
49
|
+
.dragging .sheet { color: var(--text-secondary); border-color: var(--border-strong); }
|
|
50
|
+
.dropzone.dragging .sheet[data-sheet="back"] { --r: -22deg; --x: -20px; --y: -2px; }
|
|
51
|
+
.dropzone.dragging .sheet[data-sheet="side"] { --r: 22deg; --x: 20px; --y: -2px; }
|
|
52
|
+
.dropzone.dragging .sheet[data-sheet="front"] { --y: -8px; }
|
|
53
|
+
/* Full width and centered text: the label box never moves, so an outgoing label leaves from exactly where it was. */
|
|
54
|
+
.label { position: relative; display: block; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
|
|
55
|
+
.labelText { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: top; white-space: nowrap; }
|
|
56
|
+
.hint { margin-top: 6px; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
|
57
|
+
.note { padding-top: 6px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
|
|
58
|
+
.input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
|
59
|
+
.messageSlot { overflow: hidden; }
|
|
60
|
+
.error { display: flex; align-items: flex-start; gap: 6px; padding-top: var(--space-group); color: var(--danger); font-size: var(--text-sm); line-height: var(--leading-body); }
|
|
61
|
+
/* The icon sits on the first line's optical center, so a wrapped message keeps it at the top. */
|
|
62
|
+
.errorIcon { flex: 0 0 auto; margin-top: 3px; }
|
|
63
|
+
.errorCopy { position: relative; min-width: 0; flex: 1; }
|
|
64
|
+
|
|
65
|
+
.list { display: grid; min-width: 0; margin: 0; padding: 0; list-style: none; container: dropzone-list / inline-size; }
|
|
66
|
+
.inside .list { padding-inline: 12px; }
|
|
67
|
+
/* Rows animate height; the gap lives inside the clipped row as margin, so a closing row reaches a true zero. */
|
|
68
|
+
.item { min-width: 0; overflow: hidden; }
|
|
69
|
+
.row { position: relative; display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; column-gap: 12px; min-height: 58px; margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: 8px 8px 8px 9px; background: var(--surface); transition: border-color var(--duration-standard) var(--ease-standard); }
|
|
70
|
+
.row.isFailed { border-color: color-mix(in oklab, var(--danger) 26%, var(--border)); }
|
|
71
|
+
.fileIcon { display: grid; width: 40px; height: 40px; place-items: center; color: var(--text-secondary); }
|
|
72
|
+
.thumb { display: block; width: 40px; height: 40px; overflow: hidden; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: var(--surface-muted); box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .06); }
|
|
73
|
+
.thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
74
|
+
.copy { display: grid; min-width: 0; }
|
|
75
|
+
.name { overflow: hidden; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }
|
|
76
|
+
.meta { display: flex; min-width: 0; align-items: center; margin-top: 1px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
77
|
+
.dot { padding-inline: 6px; }
|
|
78
|
+
/* The outgoing status is measured against this box when it pops out to leave. */
|
|
79
|
+
.phase { position: relative; display: flex; min-width: 0; flex: 1; }
|
|
80
|
+
.state { display: inline-flex; min-width: 0; align-items: center; gap: 5px; }
|
|
81
|
+
.uploaded { color: var(--text-secondary); }
|
|
82
|
+
.uploaded .stateIcon { color: var(--success); }
|
|
83
|
+
.failed { color: var(--danger); }
|
|
84
|
+
.stateIcon { flex: 0 0 auto; }
|
|
85
|
+
.reason { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
86
|
+
.percent { font-variant-numeric: tabular-nums; }
|
|
87
|
+
.barFrame { display: block; overflow: hidden; }
|
|
88
|
+
.bar { display: block; height: 4px; margin: 7px 0 2px; overflow: hidden; border-radius: var(--radius-full); corner-shape: round; background: var(--border); }
|
|
89
|
+
/* The fill slides in from the left instead of scaling, so its rounded end keeps its shape at every value. */
|
|
90
|
+
.barFill { display: block; width: 100%; height: 100%; border-radius: inherit; corner-shape: inherit; background: var(--accent); }
|
|
91
|
+
.actions { display: flex; align-items: center; }
|
|
92
|
+
.retrySlot { display: flex; overflow: hidden; }
|
|
93
|
+
.retryInner { display: flex; padding: 3px 6px 3px 3px; }
|
|
94
|
+
.retry, .remove { display: grid; place-items: center; border: 0; color: var(--text-secondary); font: inherit; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
|
|
95
|
+
.retry { display: flex; height: 30px; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; padding: 0 12px 0 10px; background: var(--surface); color: var(--foreground); font-size: var(--text-xs); font-weight: 500; letter-spacing: var(--tracking-body); white-space: nowrap; }
|
|
96
|
+
/* On narrow lists Retry keeps only its icon, so the failure reason keeps the room to be read in full. */
|
|
97
|
+
@container dropzone-list (max-width: 440px) { .retry { width: 30px; justify-content: center; padding: 0; } .retryLabel { display: none; } }
|
|
98
|
+
/* On the narrowest lists the type icon steps aside for the name and status. */
|
|
99
|
+
@container dropzone-list (max-width: 300px) { .row { grid-template-columns: minmax(0, 1fr) auto; padding-left: 14px; } .fileIcon, .thumb { display: none; } }
|
|
100
|
+
.remove { width: 32px; height: 32px; border-radius: var(--radius-full); corner-shape: round; background: transparent; }
|
|
101
|
+
@media (hover: hover) and (pointer: fine) {
|
|
102
|
+
.retry:hover { border-color: var(--border-strong); background: var(--surface-muted); }
|
|
103
|
+
.remove:hover { background: var(--surface-muted); color: var(--foreground); }
|
|
104
|
+
}
|
|
105
|
+
/* Quick press, spring release. Neither button anchors a floating layer. */
|
|
106
|
+
.retry:active, .remove:active { transform: scale(.97); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
|
|
107
|
+
/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */
|
|
108
|
+
.words { position: relative; display: block; }
|
|
109
|
+
.word { display: inline-block; white-space: pre; }
|
|
110
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
111
|
+
@media (prefers-reduced-motion: reduce) {
|
|
112
|
+
.dropzone, .edge rect, .sheet, .sheet > svg, .row, .retry, .remove, .wash, .glow { transition: none; }
|
|
113
|
+
.dropzone.dragging { animation: none; }
|
|
114
|
+
.retry:active, .remove:active { transform: none; }
|
|
115
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type FileUploadItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
file: File;
|
|
4
|
+
error?: string;
|
|
5
|
+
};
|
|
6
|
+
export interface FileUploadProps {
|
|
7
|
+
accept?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
label?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
multiple?: boolean;
|
|
12
|
+
maxSize?: number;
|
|
13
|
+
value?: FileUploadItem[];
|
|
14
|
+
onChange?: (files: FileUploadItem[]) => void;
|
|
15
|
+
/** Uploads each accepted file. Report progress from 0 to 100, resolve when done, or reject to mark the file as failed. Removing a file aborts its signal. */
|
|
16
|
+
onUpload?: (file: File, options: {
|
|
17
|
+
onProgress: (percent: number) => void;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare function FileUpload({ accept, disabled, label, description, multiple, maxSize, value, onChange, onUpload, }: FileUploadProps): import("react").JSX.Element;
|
|
22
|
+
export default FileUpload;
|