@hogoshi/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +63 -4
  2. package/src/components/avatar.tsx +30 -0
  3. package/src/components/badge.tsx +26 -0
  4. package/src/components/breadcrumb.tsx +37 -0
  5. package/src/components/button.tsx +58 -0
  6. package/src/components/calendar.tsx +244 -0
  7. package/src/components/card.tsx +18 -0
  8. package/src/components/chart.tsx +387 -0
  9. package/src/components/checkbox.tsx +26 -0
  10. package/src/components/code-block.tsx +118 -0
  11. package/src/components/collapsible.tsx +31 -0
  12. package/src/components/command.tsx +111 -0
  13. package/src/components/context-menu.tsx +150 -0
  14. package/src/components/counter.tsx +56 -0
  15. package/src/components/dialog.tsx +36 -0
  16. package/src/components/dropdown-menu.tsx +54 -0
  17. package/src/components/file-upload.tsx +381 -0
  18. package/src/components/glide.tsx +82 -0
  19. package/src/components/icon.tsx +28 -0
  20. package/src/components/image-viewer.tsx +370 -0
  21. package/src/components/input.tsx +187 -0
  22. package/src/components/kbd.tsx +14 -0
  23. package/src/components/logo-mark.ts +8 -0
  24. package/src/components/logo.tsx +36 -0
  25. package/src/components/navbar.tsx +144 -0
  26. package/src/components/otp.tsx +93 -0
  27. package/src/components/page-loader.tsx +37 -0
  28. package/src/components/pophover.tsx +28 -0
  29. package/src/components/popover.tsx +36 -0
  30. package/src/components/popup.tsx +75 -0
  31. package/src/components/progress.tsx +26 -0
  32. package/src/components/result.tsx +22 -0
  33. package/src/components/scroll-area.tsx +38 -0
  34. package/src/components/select.tsx +52 -0
  35. package/src/components/separator.tsx +14 -0
  36. package/src/components/sheet.tsx +96 -0
  37. package/src/components/sidebar.tsx +195 -0
  38. package/src/components/skeleton.tsx +14 -0
  39. package/src/components/slider.tsx +31 -0
  40. package/src/components/smooth-scroll.tsx +174 -0
  41. package/src/components/spinner.tsx +18 -0
  42. package/src/components/switch.tsx +22 -0
  43. package/src/components/table.tsx +52 -0
  44. package/src/components/tabs.tsx +48 -0
  45. package/src/components/theme-toggle.tsx +214 -0
  46. package/src/components/toaster.tsx +38 -0
  47. package/src/components/tooltip.tsx +43 -0
  48. package/src/components/video-player.tsx +559 -0
  49. package/src/components/video-settings.tsx +233 -0
  50. package/src/index.ts +102 -0
  51. package/src/lib/cn.ts +6 -0
  52. package/src/lib/jsx-highlight.ts +128 -0
  53. package/src/styles.css +443 -0
  54. package/src/theme/theme-script.ts +13 -0
  55. package/src/theme/theme.tsx +77 -0
  56. package/styles.css +1 -0
  57. package/README.md +0 -3
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
4
+ import { ClipboardIcon, Copy01Icon, ScissorIcon, TextIcon } from "@hugeicons-pro/core-stroke-rounded";
5
+ import type { ComponentProps, ReactNode } from "react";
6
+ import { cn } from "../lib/cn";
7
+ import type { AppIcon } from "./icon";
8
+ import { Icon } from "./icon";
9
+ import { Glide } from "./glide";
10
+ import { HIGHLIGHTED, popupClass, popupItemClass } from "./popup";
11
+ import { Separator } from "./separator";
12
+
13
+ export interface ContextMenuEntry {
14
+ id: string;
15
+ label: string;
16
+ icon?: AppIcon;
17
+ onSelect?: () => void;
18
+ destructive?: boolean;
19
+ items?: ContextMenuEntry[];
20
+ }
21
+
22
+ function selectionText() {
23
+ return window.getSelection()?.toString() ?? "";
24
+ }
25
+
26
+ function cutSelection() {
27
+ const text = selectionText();
28
+ if (text) void navigator.clipboard.writeText(text);
29
+ document.execCommand("delete");
30
+ }
31
+
32
+ function copySelection() {
33
+ const text = selectionText();
34
+ if (text) void navigator.clipboard.writeText(text);
35
+ }
36
+
37
+ async function pasteSelection() {
38
+ const text = await navigator.clipboard.readText();
39
+ const active = document.activeElement;
40
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
41
+ const start = active.selectionStart ?? active.value.length;
42
+ const end = active.selectionEnd ?? start;
43
+ active.setRangeText(text, start, end, "end");
44
+ active.dispatchEvent(new Event("input", { bubbles: true }));
45
+ return;
46
+ }
47
+ document.execCommand("insertText", false, text);
48
+ }
49
+
50
+ function selectAll() {
51
+ const active = document.activeElement;
52
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
53
+ active.select();
54
+ return;
55
+ }
56
+ const selection = window.getSelection();
57
+ const node = selection?.anchorNode?.parentElement;
58
+ if (!node || !selection) return;
59
+ const range = document.createRange();
60
+ range.selectNodeContents(node);
61
+ selection.removeAllRanges();
62
+ selection.addRange(range);
63
+ }
64
+
65
+ function editEntries(): ContextMenuEntry[] {
66
+ return [
67
+ {
68
+ id: "edit",
69
+ label: "Edit",
70
+ items: [
71
+ { id: "cut", label: "Cut", icon: ScissorIcon, onSelect: cutSelection },
72
+ { id: "copy", label: "Copy", icon: Copy01Icon, onSelect: copySelection },
73
+ { id: "paste", label: "Paste", icon: ClipboardIcon, onSelect: () => void pasteSelection() },
74
+ { id: "select-all", label: "Select all", icon: TextIcon, onSelect: selectAll },
75
+ ],
76
+ },
77
+ ];
78
+ }
79
+
80
+ export const ContextMenuRoot = BaseContextMenu.Root;
81
+ export const ContextMenuTrigger = BaseContextMenu.Trigger;
82
+
83
+ export function ContextMenuContent({ className, children, ...props }: ComponentProps<typeof BaseContextMenu.Popup>) {
84
+ return (
85
+ <BaseContextMenu.Portal>
86
+ <BaseContextMenu.Positioner className="z-[var(--z-popover)]">
87
+ <BaseContextMenu.Popup className={cn(popupClass, className)} {...props}>
88
+ <Glide selector={HIGHLIGHTED} />
89
+ <EntryList items={editEntries()} />
90
+ {children ? (
91
+ <>
92
+ <Separator className="my-1" />
93
+ {children}
94
+ </>
95
+ ) : null}
96
+ </BaseContextMenu.Popup>
97
+ </BaseContextMenu.Positioner>
98
+ </BaseContextMenu.Portal>
99
+ );
100
+ }
101
+
102
+ export function ContextMenuItem({ className, ...props }: ComponentProps<typeof BaseContextMenu.Item>) {
103
+ return <BaseContextMenu.Item className={cn(popupItemClass, className)} {...props} />;
104
+ }
105
+
106
+ function EntryList({ items }: { items: ContextMenuEntry[] }) {
107
+ return items.map((item, index) =>
108
+ item.items ? (
109
+ <div key={item.id} className={index > 0 ? "border-border mt-1 border-t pt-1" : undefined}>
110
+ <p className="text-fg-subtle px-2 py-1 text-[11px] font-medium tracking-wider uppercase">{item.label}</p>
111
+ <EntryList items={item.items} />
112
+ </div>
113
+ ) : (
114
+ <ContextMenuItem
115
+ key={item.id}
116
+ className={item.destructive ? "text-danger data-[highlighted]:text-danger" : undefined}
117
+ onClick={item.onSelect}
118
+ >
119
+ {item.icon ? <Icon icon={item.icon} variant="stroke" size={16} /> : null}
120
+ {item.label}
121
+ </ContextMenuItem>
122
+ ),
123
+ );
124
+ }
125
+
126
+ export function ContextMenuViewport({ children }: { children: ReactNode }) {
127
+ return (
128
+ <BaseContextMenu.Root>
129
+ <ContextMenuTrigger className="block min-h-svh">{children}</ContextMenuTrigger>
130
+ <ContextMenuContent />
131
+ </BaseContextMenu.Root>
132
+ );
133
+ }
134
+
135
+ export function ContextMenu({
136
+ items,
137
+ children,
138
+ ...props
139
+ }: ComponentProps<typeof BaseContextMenu.Root> & { items?: ContextMenuEntry[]; children?: ReactNode }) {
140
+ return (
141
+ <BaseContextMenu.Root {...props}>
142
+ {items ? <ContextMenuTrigger>{children}</ContextMenuTrigger> : children}
143
+ {items ? (
144
+ <ContextMenuContent>
145
+ <EntryList items={items} />
146
+ </ContextMenuContent>
147
+ ) : null}
148
+ </BaseContextMenu.Root>
149
+ );
150
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { cn } from "../lib/cn";
5
+
6
+ const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
7
+
8
+ function useSettled(value: number, delay: number) {
9
+ const [settled, setSettled] = useState(value);
10
+ useEffect(() => {
11
+ const timer = window.setTimeout(() => setSettled(value), delay);
12
+ return () => window.clearTimeout(timer);
13
+ }, [value, delay]);
14
+ return settled;
15
+ }
16
+
17
+ function Reel({ digit }: { digit: string }) {
18
+ const value = Number(digit);
19
+ const [soft, setSoft] = useState(false);
20
+ useEffect(() => {
21
+ setSoft(true);
22
+ const timer = window.setTimeout(() => setSoft(false), 160);
23
+ return () => window.clearTimeout(timer);
24
+ }, [value]);
25
+ if (!Number.isInteger(value)) return <span className="inline-block px-px">{digit}</span>;
26
+ return (
27
+ <span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
28
+ <span
29
+ className={cn(
30
+ "absolute inset-x-0 top-0 flex flex-col transition-[transform,filter] duration-[var(--motion-duration)] ease-[var(--motion-ease)] motion-reduce:transition-none",
31
+ soft ? "blur-[2px]" : "blur-none",
32
+ )}
33
+ style={{ transform: `translateY(-${value}em)` }}
34
+ >
35
+ {DIGITS.map((item) => (
36
+ <span key={item} className="flex h-[1em] items-center justify-center">
37
+ {item}
38
+ </span>
39
+ ))}
40
+ </span>
41
+ </span>
42
+ );
43
+ }
44
+
45
+ export function Counter({ value, className }: { value: number; className?: string }) {
46
+ const shown = useSettled(value, 120);
47
+ return (
48
+ <span className={cn("inline-flex items-center font-mono text-3xl font-medium tabular-nums", className)}>
49
+ {String(shown)
50
+ .split("")
51
+ .map((digit, index, digits) => (
52
+ <Reel key={digits.length - index} digit={digit} />
53
+ ))}
54
+ </span>
55
+ );
56
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { Dialog as BaseDialog } from "@base-ui/react/dialog";
4
+ import { Cancel01Icon } from "@hugeicons-pro/core-stroke-rounded";
5
+ import type { ComponentProps } from "react";
6
+ import { cn } from "../lib/cn";
7
+ import { Icon } from "./icon";
8
+ import { backdropMotion, popupMotion } from "./popup";
9
+
10
+ export const Dialog = BaseDialog.Root;
11
+ export const DialogTrigger = BaseDialog.Trigger;
12
+ export const DialogClose = BaseDialog.Close;
13
+ export const DialogTitle = BaseDialog.Title;
14
+ export const DialogDescription = BaseDialog.Description;
15
+
16
+ export function DialogContent({ className, children, ...props }: ComponentProps<typeof BaseDialog.Popup>) {
17
+ return (
18
+ <BaseDialog.Portal>
19
+ <BaseDialog.Backdrop className={cn("fixed inset-0 z-[var(--z-overlay)] bg-transparent", backdropMotion)} />
20
+ <BaseDialog.Popup
21
+ className={cn(
22
+ "border-border bg-bg-elevated/70 fixed top-1/2 left-1/2 z-[var(--z-modal)] grid w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-lg)] border p-6 shadow-[var(--shadow-lg)] backdrop-blur-xl outline-none",
23
+ popupMotion,
24
+ className,
25
+ )}
26
+ {...props}
27
+ >
28
+ {children}
29
+ <BaseDialog.Close className="text-fg-subtle hover:bg-bg-subtle hover:text-fg focus-visible:ring-ring absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-[var(--radius-sm)] focus-visible:ring-2 focus-visible:outline-none">
30
+ <Icon icon={Cancel01Icon} variant="stroke" size={16} />
31
+ <span className="sr-only">Close</span>
32
+ </BaseDialog.Close>
33
+ </BaseDialog.Popup>
34
+ </BaseDialog.Portal>
35
+ );
36
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { Menu } from "@base-ui/react/menu";
4
+ import { ArrowRight01Icon } from "@hugeicons-pro/core-stroke-rounded";
5
+ import type { ComponentProps } from "react";
6
+ import { cn } from "../lib/cn";
7
+ import { Glide } from "./glide";
8
+ import { Icon } from "./icon";
9
+ import { HIGHLIGHTED, popupClass, popupItemClass, useAnchoredPopup } from "./popup";
10
+
11
+ export const DropdownMenu = Menu.Root;
12
+ export const DropdownMenuTrigger = Menu.Trigger;
13
+
14
+ export function DropdownMenuContent({ className, children, ...props }: ComponentProps<typeof Menu.Popup>) {
15
+ const { popupClassName, ...anchor } = useAnchoredPopup();
16
+ return (
17
+ <Menu.Portal>
18
+ <Menu.Positioner className="z-[var(--z-popover)]" {...anchor}>
19
+ <Menu.Popup className={cn(popupClass, popupClassName, className)} {...props}>
20
+ <Glide selector={HIGHLIGHTED} />
21
+ {children}
22
+ </Menu.Popup>
23
+ </Menu.Positioner>
24
+ </Menu.Portal>
25
+ );
26
+ }
27
+
28
+ export function DropdownMenuItem({ className, ...props }: ComponentProps<typeof Menu.Item>) {
29
+ return <Menu.Item className={cn(popupItemClass, className)} {...props} />;
30
+ }
31
+
32
+ export const DropdownMenuSub = Menu.SubmenuRoot;
33
+
34
+ export function DropdownMenuSubTrigger({ className, children, ...props }: ComponentProps<typeof Menu.SubmenuTrigger>) {
35
+ return (
36
+ <Menu.SubmenuTrigger className={cn(popupItemClass, className)} {...props}>
37
+ {children}
38
+ <Icon icon={ArrowRight01Icon} variant="stroke" size={14} className="text-fg-subtle ml-auto" />
39
+ </Menu.SubmenuTrigger>
40
+ );
41
+ }
42
+
43
+ export function DropdownMenuSubContent({ className, children, ...props }: ComponentProps<typeof Menu.Popup>) {
44
+ return (
45
+ <Menu.Portal>
46
+ <Menu.Positioner className="z-[var(--z-popover)]" side="right" align="start" sideOffset={6}>
47
+ <Menu.Popup className={cn(popupClass, className)} {...props}>
48
+ <Glide selector={HIGHLIGHTED} />
49
+ {children}
50
+ </Menu.Popup>
51
+ </Menu.Positioner>
52
+ </Menu.Portal>
53
+ );
54
+ }
@@ -0,0 +1,381 @@
1
+ "use client";
2
+
3
+ import { Cancel01Icon, CloudUploadIcon, File01Icon } from "@hugeicons-pro/core-stroke-rounded";
4
+ import {
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ useId,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ type DragEvent,
13
+ type ReactNode,
14
+ } from "react";
15
+ import { cn } from "../lib/cn";
16
+ import { Icon } from "./icon";
17
+ import { ImageViewer } from "./image-viewer";
18
+
19
+ export interface FileRejection {
20
+ file: File;
21
+ reason: string;
22
+ }
23
+
24
+ export interface FileUploadProps {
25
+ accept?: string;
26
+ multiple?: boolean;
27
+ maxSize?: number;
28
+ maxFiles?: number;
29
+ disabled?: boolean;
30
+ value?: File[];
31
+ defaultValue?: File[];
32
+ onValueChange?: (files: File[]) => void;
33
+ onReject?: (rejections: FileRejection[]) => void;
34
+ className?: string;
35
+ children?: ReactNode;
36
+ }
37
+
38
+ interface FileUploadState {
39
+ id: string;
40
+ files: File[];
41
+ urls: (string | null)[];
42
+ errors: FileRejection[];
43
+ accept?: string;
44
+ maxSize?: number;
45
+ multiple: boolean;
46
+ single: boolean;
47
+ disabled: boolean;
48
+ add: (files: File[]) => void;
49
+ remove: (index: number) => void;
50
+ preview: (index: number) => void;
51
+ }
52
+
53
+ const FileUploadContext = createContext<FileUploadState | null>(null);
54
+
55
+ function useFileUpload() {
56
+ const value = useContext(FileUploadContext);
57
+ if (!value) throw new Error("FileUpload parts must be used within FileUpload");
58
+ return value;
59
+ }
60
+
61
+ export function formatBytes(bytes: number) {
62
+ if (bytes < 1024) return `${bytes} B`;
63
+ const units = ["KB", "MB", "GB"];
64
+ let size = bytes / 1024;
65
+ let unit = 0;
66
+ while (size >= 1024 && unit < units.length - 1) {
67
+ size /= 1024;
68
+ unit += 1;
69
+ }
70
+ return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
71
+ }
72
+
73
+ function matches(file: File, accept: string) {
74
+ return accept
75
+ .split(",")
76
+ .map((rule) => rule.trim().toLowerCase())
77
+ .filter(Boolean)
78
+ .some((rule) => {
79
+ if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule);
80
+ if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
81
+ return file.type === rule;
82
+ });
83
+ }
84
+
85
+ function describe(accept?: string, maxSize?: number) {
86
+ const formats = accept
87
+ ?.split(",")
88
+ .map((rule) => rule.trim().replace(/^\./, "").replace("/*", "").replace(/^.*\//, "").toUpperCase())
89
+ .filter(Boolean)
90
+ .join(", ");
91
+ return [formats, maxSize ? `up to ${formatBytes(maxSize)}` : ""].filter(Boolean).join(" · ");
92
+ }
93
+
94
+ function useImageUrls(files: File[]) {
95
+ const urls = useMemo(
96
+ () => files.map((file) => (file.type.startsWith("image/") ? URL.createObjectURL(file) : null)),
97
+ [files],
98
+ );
99
+ useEffect(() => () => urls.forEach((url) => url && URL.revokeObjectURL(url)), [urls]);
100
+ return urls;
101
+ }
102
+
103
+ const fileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
104
+
105
+ const removeClass =
106
+ "bg-bg-elevated border-border text-fg-muted hover:text-fg hover:bg-bg-subtle inline-flex cursor-pointer items-center justify-center rounded-full border shadow-[var(--shadow-sm)] transition-colors duration-[var(--motion-duration)] disabled:cursor-not-allowed disabled:opacity-50";
107
+
108
+ export function FileUpload({
109
+ accept,
110
+ multiple = false,
111
+ maxSize,
112
+ maxFiles,
113
+ disabled = false,
114
+ value,
115
+ defaultValue = [],
116
+ onValueChange,
117
+ onReject,
118
+ className,
119
+ children,
120
+ }: FileUploadProps) {
121
+ const id = useId();
122
+ const [local, setLocal] = useState(defaultValue);
123
+ const [errors, setErrors] = useState<FileRejection[]>([]);
124
+ const [viewing, setViewing] = useState<number | null>(null);
125
+ const files = value ?? local;
126
+ const limit = multiple ? (maxFiles ?? Infinity) : 1;
127
+ const single = limit === 1;
128
+ const urls = useImageUrls(files);
129
+ const images = files.flatMap((file, at) => (urls[at] ? [{ src: urls[at], name: file.name, alt: file.name }] : []));
130
+
131
+ const commit = (next: File[]) => {
132
+ setLocal(next);
133
+ onValueChange?.(next);
134
+ };
135
+
136
+ const add = (incoming: File[]) => {
137
+ const rejected: FileRejection[] = [];
138
+ const accepted = incoming.filter((file) => {
139
+ if (accept && !matches(file, accept)) rejected.push({ file, reason: "Format is not supported" });
140
+ else if (maxSize && file.size > maxSize) rejected.push({ file, reason: `Larger than ${formatBytes(maxSize)}` });
141
+ else return true;
142
+ return false;
143
+ });
144
+ const base = single ? [] : files;
145
+ const room = single ? 1 : Math.max(0, limit - base.length);
146
+ accepted.slice(room).forEach((file) => rejected.push({ file, reason: `Only ${limit} files allowed` }));
147
+ if (accepted.length) commit([...base, ...accepted.slice(0, room)]);
148
+ setErrors(rejected);
149
+ if (rejected.length) onReject?.(rejected);
150
+ };
151
+
152
+ const state: FileUploadState = {
153
+ id,
154
+ files,
155
+ urls,
156
+ errors,
157
+ accept,
158
+ maxSize,
159
+ multiple: multiple && !single,
160
+ single,
161
+ disabled,
162
+ add,
163
+ remove: (index) => commit(files.filter((_, at) => at !== index)),
164
+ preview: (index) => {
165
+ const at = images.findIndex((image) => image.src === urls[index]);
166
+ if (at >= 0) setViewing(at);
167
+ },
168
+ };
169
+
170
+ return (
171
+ <FileUploadContext.Provider value={state}>
172
+ <div className={cn("flex w-full flex-col gap-2", className)}>
173
+ {children ?? (
174
+ <>
175
+ <FileUploadDropzone />
176
+ {single ? null : <FileList />}
177
+ </>
178
+ )}
179
+ </div>
180
+ <ImageViewer
181
+ images={images}
182
+ index={viewing ?? 0}
183
+ open={viewing !== null && images.length > 0}
184
+ onOpenChange={(open) => !open && setViewing(null)}
185
+ onIndexChange={setViewing}
186
+ />
187
+ </FileUploadContext.Provider>
188
+ );
189
+ }
190
+
191
+ function SinglePreview() {
192
+ const { files, urls, disabled, remove, preview } = useFileUpload();
193
+ const file = files[0];
194
+ if (!file) return null;
195
+ return (
196
+ <div className="absolute inset-0 animate-[item-in_var(--motion-duration)_var(--motion-ease)]">
197
+ {urls[0] ? (
198
+ <button
199
+ type="button"
200
+ aria-label={`Preview ${file.name}`}
201
+ className="size-full cursor-zoom-in"
202
+ onClick={(event) => {
203
+ event.preventDefault();
204
+ preview(0);
205
+ }}
206
+ >
207
+ <img src={urls[0]} alt="" className="size-full object-cover" />
208
+ </button>
209
+ ) : (
210
+ <div className="bg-bg-elevated flex size-full flex-col items-center justify-center gap-2 px-4 text-center">
211
+ <Icon icon={File01Icon} variant="stroke" size={24} className="text-fg-muted" />
212
+ <span className="text-fg max-w-full truncate text-sm font-medium">{file.name}</span>
213
+ <span className="text-fg-subtle text-xs">{formatBytes(file.size)} · drop or click to replace</span>
214
+ </div>
215
+ )}
216
+ <button
217
+ type="button"
218
+ aria-label={`Remove ${file.name}`}
219
+ disabled={disabled}
220
+ className={cn(removeClass, "absolute top-2 right-2 size-7")}
221
+ onClick={(event) => {
222
+ event.preventDefault();
223
+ remove(0);
224
+ }}
225
+ >
226
+ <Icon icon={Cancel01Icon} variant="stroke" size={14} />
227
+ </button>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ export function FileUploadDropzone({ label = "Drop files here or browse", className }: { label?: string; className?: string }) {
233
+ const { id, files, errors, accept, maxSize, multiple, single, disabled, add } = useFileUpload();
234
+ const depth = useRef(0);
235
+ const [over, setOver] = useState(false);
236
+ const hint = describe(accept, maxSize);
237
+ const filled = single && files.length > 0;
238
+
239
+ const onDrop = (event: DragEvent<HTMLLabelElement>) => {
240
+ event.preventDefault();
241
+ depth.current = 0;
242
+ setOver(false);
243
+ if (!disabled) add([...event.dataTransfer.files]);
244
+ };
245
+
246
+ return (
247
+ <>
248
+ <label
249
+ htmlFor={id}
250
+ data-over={over || undefined}
251
+ data-disabled={disabled || undefined}
252
+ className={cn(
253
+ "border-border-strong bg-bg-elevated hover:bg-bg-subtle data-[over]:border-accent data-[over]:bg-accent-muted relative flex min-h-36 cursor-pointer flex-col items-center justify-center gap-2 overflow-hidden rounded-[var(--radius-lg)] border border-dashed px-4 py-8 text-center transition-[background-color,border-color,scale] duration-[var(--motion-duration)] ease-[var(--motion-ease)] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[over]:scale-[1.01]",
254
+ filled && "h-48 border-solid p-0",
255
+ className,
256
+ )}
257
+ onDragEnter={(event) => {
258
+ event.preventDefault();
259
+ depth.current += 1;
260
+ if (!disabled) setOver(true);
261
+ }}
262
+ onDragOver={(event) => event.preventDefault()}
263
+ onDragLeave={() => {
264
+ depth.current -= 1;
265
+ if (depth.current <= 0) setOver(false);
266
+ }}
267
+ onDrop={onDrop}
268
+ >
269
+ {filled ? (
270
+ <SinglePreview />
271
+ ) : (
272
+ <>
273
+ <span className="bg-bg-subtle text-fg flex size-10 items-center justify-center rounded-full">
274
+ <Icon icon={CloudUploadIcon} variant="stroke" size={20} />
275
+ </span>
276
+ <span className="text-fg text-sm font-medium">{label}</span>
277
+ {hint ? <span className="text-fg-subtle text-xs">{hint}</span> : null}
278
+ </>
279
+ )}
280
+ <input
281
+ id={id}
282
+ type="file"
283
+ className="sr-only"
284
+ accept={accept}
285
+ multiple={multiple}
286
+ disabled={disabled}
287
+ onChange={(event) => {
288
+ add([...(event.target.files ?? [])]);
289
+ event.target.value = "";
290
+ }}
291
+ />
292
+ </label>
293
+ {errors.map((error) => (
294
+ <p
295
+ key={`${error.file.name}-${error.reason}`}
296
+ className="text-danger animate-[item-in_var(--motion-duration)_var(--motion-ease)] text-xs"
297
+ >
298
+ {error.file.name}: {error.reason}
299
+ </p>
300
+ ))}
301
+ </>
302
+ );
303
+ }
304
+
305
+ function Thumb({ index, className }: { index: number; className: string }) {
306
+ const { files, urls, preview } = useFileUpload();
307
+ const file = files[index];
308
+ if (urls[index]) {
309
+ return (
310
+ <button
311
+ type="button"
312
+ aria-label={`Preview ${file.name}`}
313
+ className={cn("bg-bg-subtle shrink-0 cursor-zoom-in overflow-hidden", className)}
314
+ onClick={() => preview(index)}
315
+ >
316
+ <img src={urls[index]} alt="" className="size-full object-cover" />
317
+ </button>
318
+ );
319
+ }
320
+ return (
321
+ <span className={cn("bg-bg-subtle flex shrink-0 items-center justify-center", className)}>
322
+ <Icon icon={File01Icon} variant="stroke" size={18} className="text-fg-muted" />
323
+ </span>
324
+ );
325
+ }
326
+
327
+ export function FileList({ variant = "col", className }: { variant?: "col" | "row"; className?: string }) {
328
+ const { files, disabled, remove } = useFileUpload();
329
+ if (!files.length) return null;
330
+
331
+ if (variant === "row") {
332
+ return (
333
+ <ul className={cn("flex flex-wrap gap-3 pt-2", className)}>
334
+ {files.map((file, index) => (
335
+ <li
336
+ key={fileKey(file)}
337
+ className="relative flex w-20 animate-[item-in_var(--motion-duration)_var(--motion-ease)] flex-col gap-1"
338
+ >
339
+ <Thumb index={index} className="border-border size-20 rounded-[var(--radius-md)] border" />
340
+ <span className="text-fg-muted truncate text-xs">{file.name}</span>
341
+ <button
342
+ type="button"
343
+ aria-label={`Remove ${file.name}`}
344
+ disabled={disabled}
345
+ className={cn(removeClass, "absolute -top-2 -right-2 size-6")}
346
+ onClick={() => remove(index)}
347
+ >
348
+ <Icon icon={Cancel01Icon} variant="stroke" size={12} />
349
+ </button>
350
+ </li>
351
+ ))}
352
+ </ul>
353
+ );
354
+ }
355
+
356
+ return (
357
+ <ul className={cn("flex flex-col gap-1.5", className)}>
358
+ {files.map((file, index) => (
359
+ <li
360
+ key={fileKey(file)}
361
+ className="border-border bg-bg-elevated flex animate-[item-in_var(--motion-duration)_var(--motion-ease)] items-center gap-3 rounded-[var(--radius-md)] border p-2"
362
+ >
363
+ <Thumb index={index} className="size-10 rounded-[var(--radius-sm)]" />
364
+ <span className="flex min-w-0 flex-1 flex-col">
365
+ <span className="text-fg truncate text-sm">{file.name}</span>
366
+ <span className="text-fg-subtle text-xs">{formatBytes(file.size)}</span>
367
+ </span>
368
+ <button
369
+ type="button"
370
+ aria-label={`Remove ${file.name}`}
371
+ disabled={disabled}
372
+ className="text-fg-subtle hover:bg-bg-subtle hover:text-fg inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-[var(--radius-sm)]"
373
+ onClick={() => remove(index)}
374
+ >
375
+ <Icon icon={Cancel01Icon} variant="stroke" size={16} />
376
+ </button>
377
+ </li>
378
+ ))}
379
+ </ul>
380
+ );
381
+ }