@sixthshift/design-system 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/components/DataTable/DataTable.d.ts +89 -0
  2. package/dist/components/DataTable/DataTable.d.ts.map +1 -0
  3. package/dist/components/DataTable/DataTable.js +107 -0
  4. package/dist/components/DataTable/DataTable.js.map +1 -0
  5. package/dist/components/DataTable/index.d.ts +2 -0
  6. package/dist/components/DataTable/index.d.ts.map +1 -0
  7. package/dist/components/DataTable/index.js +2 -0
  8. package/dist/components/DataTable/index.js.map +1 -0
  9. package/dist/components/FileDropzone/FileDropzone.d.ts +87 -0
  10. package/dist/components/FileDropzone/FileDropzone.d.ts.map +1 -0
  11. package/dist/components/FileDropzone/FileDropzone.js +146 -0
  12. package/dist/components/FileDropzone/FileDropzone.js.map +1 -0
  13. package/dist/components/FileDropzone/index.d.ts +2 -0
  14. package/dist/components/FileDropzone/index.d.ts.map +1 -0
  15. package/dist/components/FileDropzone/index.js +2 -0
  16. package/dist/components/FileDropzone/index.js.map +1 -0
  17. package/dist/components/ImageGallery/ImageGallery.d.ts +93 -0
  18. package/dist/components/ImageGallery/ImageGallery.d.ts.map +1 -0
  19. package/dist/components/ImageGallery/ImageGallery.js +121 -0
  20. package/dist/components/ImageGallery/ImageGallery.js.map +1 -0
  21. package/dist/components/ImageGallery/index.d.ts +2 -0
  22. package/dist/components/ImageGallery/index.d.ts.map +1 -0
  23. package/dist/components/ImageGallery/index.js +2 -0
  24. package/dist/components/ImageGallery/index.js.map +1 -0
  25. package/dist/components/NumberStepper/NumberStepper.d.ts +78 -0
  26. package/dist/components/NumberStepper/NumberStepper.d.ts.map +1 -0
  27. package/dist/components/NumberStepper/NumberStepper.js +129 -0
  28. package/dist/components/NumberStepper/NumberStepper.js.map +1 -0
  29. package/dist/components/NumberStepper/index.d.ts +2 -0
  30. package/dist/components/NumberStepper/index.d.ts.map +1 -0
  31. package/dist/components/NumberStepper/index.js +2 -0
  32. package/dist/components/NumberStepper/index.js.map +1 -0
  33. package/dist/components/Steps/Steps.d.ts +65 -0
  34. package/dist/components/Steps/Steps.d.ts.map +1 -0
  35. package/dist/components/Steps/Steps.js +62 -0
  36. package/dist/components/Steps/Steps.js.map +1 -0
  37. package/dist/components/Steps/index.d.ts +2 -0
  38. package/dist/components/Steps/index.d.ts.map +1 -0
  39. package/dist/components/Steps/index.js +2 -0
  40. package/dist/components/Steps/index.js.map +1 -0
  41. package/package.json +21 -1
  42. package/src/components/DataTable/DataTable.tsx +261 -0
  43. package/src/components/DataTable/data-table.recipe.css +28 -0
  44. package/src/components/DataTable/index.ts +10 -0
  45. package/src/components/FileDropzone/FileDropzone.tsx +239 -0
  46. package/src/components/FileDropzone/file-dropzone.recipe.css +38 -0
  47. package/src/components/FileDropzone/index.ts +1 -0
  48. package/src/components/ImageGallery/ImageGallery.tsx +285 -0
  49. package/src/components/ImageGallery/image-gallery.recipe.css +50 -0
  50. package/src/components/ImageGallery/index.ts +7 -0
  51. package/src/components/NumberStepper/NumberStepper.tsx +227 -0
  52. package/src/components/NumberStepper/index.ts +1 -0
  53. package/src/components/NumberStepper/number-stepper.recipe.css +31 -0
  54. package/src/components/Steps/Steps.tsx +149 -0
  55. package/src/components/Steps/index.ts +1 -0
  56. package/src/components/Steps/steps.recipe.css +51 -0
  57. package/src/theming/tailwind.css +5 -0
@@ -0,0 +1,261 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@sixthshift/design-system/hooks";
4
+ import { cn } from "@sixthshift/design-system/utils";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
7
+ import { forwardRef, type HTMLAttributes, type KeyboardEvent, type ReactElement, type ReactNode, type Ref, useMemo } from "react";
8
+
9
+ /**
10
+ * Geometry only. Every colour reads a `--data-table-*` component token whose
11
+ * value is decided by src/components/DataTable/data-table.recipe.css.
12
+ *
13
+ * `size` is cell density: `sm` for back-office lists scanned dozens of rows at
14
+ * a time, `md` for tables that are the page's main content.
15
+ */
16
+ const cellVariants = cva("px-3 align-middle", {
17
+ variants: {
18
+ size: {
19
+ sm: "h-9 py-1.5 text-xs",
20
+ md: "h-11 py-2 text-sm",
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ size: "md",
25
+ },
26
+ });
27
+
28
+ export type DataTableSize = NonNullable<VariantProps<typeof cellVariants>["size"]>;
29
+
30
+ export type DataTableAlign = "start" | "center" | "end";
31
+
32
+ export type DataTableColumn<Row> = {
33
+ /** Stable key — also what `sort.columnId` refers to. */
34
+ id: string;
35
+ /** Header content. */
36
+ header: ReactNode;
37
+ /** Cell content for one row. */
38
+ cell: (row: Row) => ReactNode;
39
+ /**
40
+ * Makes the column sortable. The returned value is what rows are compared
41
+ * by — return cents, not a formatted price, and an ISO date, not "Tue 3rd".
42
+ */
43
+ sortValue?: (row: Row) => string | number;
44
+ /** Text alignment for header and cells. Numbers read best at `end`. */
45
+ align?: DataTableAlign;
46
+ /** A CSS width for the column, e.g. `"8rem"` or `"20%"`. */
47
+ width?: string;
48
+ /** Extra classes for this column's `<td>`s. */
49
+ className?: string;
50
+ };
51
+
52
+ export type DataTableSortDirection = "asc" | "desc";
53
+
54
+ export type DataTableSort = {
55
+ columnId: string;
56
+ direction: DataTableSortDirection;
57
+ };
58
+
59
+ export type DataTableProps<Row> = Omit<HTMLAttributes<HTMLDivElement>, "children"> &
60
+ VariantProps<typeof cellVariants> & {
61
+ columns: readonly DataTableColumn<Row>[];
62
+ rows: readonly Row[];
63
+ /** Stable id per row, used as the React key. */
64
+ getRowId: (row: Row) => string;
65
+ /** Controlled sort. `null` is unsorted — rows in the order given. */
66
+ sort?: DataTableSort | null;
67
+ /** Initial sort in uncontrolled mode. */
68
+ defaultSort?: DataTableSort | null;
69
+ /** Called when a header is clicked: ascending, then descending, then unsorted. */
70
+ onSortChange?: (sort: DataTableSort | null) => void;
71
+ /**
72
+ * Report sort changes without reordering `rows`, for data sorted where it
73
+ * is fetched. The headers still show the sort state.
74
+ */
75
+ manualSorting?: boolean;
76
+ /** Makes every row activatable by pointer, `Enter` or `Space`. */
77
+ onRowClick?: (row: Row) => void;
78
+ /** Shown in place of the body when `rows` is empty. */
79
+ empty?: ReactNode;
80
+ /** Names the table for assistive tech. Visually hidden unless `showCaption`. */
81
+ caption?: ReactNode;
82
+ showCaption?: boolean;
83
+ /** Keep the header row visible while the wrapper scrolls vertically. Give the wrapper a height via `className`. */
84
+ stickyHeader?: boolean;
85
+ };
86
+
87
+ const alignClass: Record<DataTableAlign, string> = {
88
+ start: "text-start",
89
+ center: "text-center",
90
+ end: "text-end",
91
+ };
92
+
93
+ const ariaSort = { asc: "ascending", desc: "descending" } as const;
94
+
95
+ function nextSort(current: DataTableSort | null, columnId: string): DataTableSort | null {
96
+ if (current?.columnId !== columnId) return { columnId, direction: "asc" };
97
+ return current.direction === "asc" ? { columnId, direction: "desc" } : null;
98
+ }
99
+
100
+ function compare(a: string | number, b: string | number): number {
101
+ if (typeof a === "number" && typeof b === "number") return a - b;
102
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
103
+ }
104
+
105
+ /**
106
+ * `forwardRef` erases the generic, so the implementation takes the ref as an
107
+ * argument and the export below restores `<Row>` — the same arrangement
108
+ * Select.tsx uses for `<T>`.
109
+ */
110
+ const DataTableRoot = forwardRef(function DataTableRoot<Row>(
111
+ {
112
+ className,
113
+ columns,
114
+ rows,
115
+ getRowId,
116
+ size = "md",
117
+ sort: controlledSort,
118
+ defaultSort = null,
119
+ onSortChange,
120
+ manualSorting = false,
121
+ onRowClick,
122
+ empty,
123
+ caption,
124
+ showCaption = false,
125
+ stickyHeader = false,
126
+ ...props
127
+ }: DataTableProps<Row>,
128
+ ref: Ref<HTMLDivElement>
129
+ ) {
130
+ const [sort, setSort] = useControllableState<DataTableSort | null>({
131
+ value: controlledSort,
132
+ defaultValue: defaultSort,
133
+ onChange: onSortChange,
134
+ });
135
+
136
+ const sortedRows = useMemo(() => {
137
+ if (manualSorting || sort === null) return rows;
138
+ const sortValue = columns.find((column) => column.id === sort.columnId)?.sortValue;
139
+ if (!sortValue) return rows;
140
+ const factor = sort.direction === "asc" ? 1 : -1;
141
+ // Array.prototype.sort is stable, so equal keys keep the caller's order.
142
+ return [...rows].sort((a, b) => factor * compare(sortValue(a), sortValue(b)));
143
+ }, [rows, columns, sort, manualSorting]);
144
+
145
+ const interactive = onRowClick !== undefined;
146
+
147
+ const handleRowKeyDown = (event: KeyboardEvent<HTMLTableRowElement>, row: Row) => {
148
+ if (event.target !== event.currentTarget) return; // a button inside the row keeps its own keys
149
+ if (event.key === "Enter" || event.key === " ") {
150
+ event.preventDefault();
151
+ onRowClick?.(row);
152
+ }
153
+ };
154
+
155
+ return (
156
+ <div
157
+ ref={ref}
158
+ className={cn(
159
+ "data-table border-(color:--data-table-border) w-full overflow-auto rounded-lg border bg-(--data-table-bg) text-(--data-table-fg)",
160
+ className
161
+ )}
162
+ {...props}
163
+ >
164
+ <table className="w-full border-collapse">
165
+ {caption !== undefined && <caption className={cn(showCaption ? "px-3 py-2 text-start font-medium text-sm" : "sr-only")}>{caption}</caption>}
166
+ <thead className={cn("bg-(--data-table-header-bg)", stickyHeader && "sticky top-0 z-10")}>
167
+ <tr className="border-(color:--data-table-border) border-b">
168
+ {columns.map((column) => {
169
+ const align = column.align ?? "start";
170
+ const sorted = sort?.columnId === column.id ? sort.direction : undefined;
171
+ const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ChevronsUpDown;
172
+ return (
173
+ <th
174
+ key={column.id}
175
+ scope="col"
176
+ aria-sort={column.sortValue ? (sorted ? ariaSort[sorted] : "none") : undefined}
177
+ style={column.width ? { width: column.width } : undefined}
178
+ className={cn(cellVariants({ size }), alignClass[align], "whitespace-nowrap font-medium text-(--data-table-header-fg)")}
179
+ >
180
+ {column.sortValue ? (
181
+ <button
182
+ type="button"
183
+ onClick={() => setSort(nextSort(sort, column.id))}
184
+ className={cn(
185
+ "focus-visible:ring-(color:--data-table-ring) -mx-1 inline-flex cursor-pointer items-center gap-1 rounded-sm px-1 hover:text-(--data-table-fg) focus-visible:outline-hidden focus-visible:ring-2",
186
+ align === "end" && "flex-row-reverse"
187
+ )}
188
+ >
189
+ {column.header}
190
+ <Icon aria-hidden="true" className={cn("size-3.5 shrink-0", sorted === undefined && "opacity-50")} />
191
+ </button>
192
+ ) : (
193
+ column.header
194
+ )}
195
+ </th>
196
+ );
197
+ })}
198
+ </tr>
199
+ </thead>
200
+ <tbody>
201
+ {sortedRows.length === 0 ? (
202
+ <tr>
203
+ <td colSpan={columns.length} className="px-3 py-10 text-center text-(--data-table-empty-fg) text-sm">
204
+ {empty ?? "Nothing to show"}
205
+ </td>
206
+ </tr>
207
+ ) : (
208
+ sortedRows.map((row) => (
209
+ <tr
210
+ key={getRowId(row)}
211
+ data-interactive={interactive || undefined}
212
+ onClick={interactive ? () => onRowClick(row) : undefined}
213
+ onKeyDown={interactive ? (event) => handleRowKeyDown(event, row) : undefined}
214
+ // A row is not a native control, but a clickable table row is the
215
+ // established back-office pattern. It stays a `row` so the table
216
+ // semantics survive, and gains a tab stop and Enter/Space like
217
+ // Card does when it is clickable.
218
+ tabIndex={interactive ? 0 : undefined}
219
+ className={cn(
220
+ "border-(color:--data-table-border) border-b last:border-b-0",
221
+ interactive &&
222
+ "focus-visible:outline-(color:--data-table-ring) cursor-pointer hover:bg-(--data-table-row-bg-hovered) focus-visible:outline-2 focus-visible:-outline-offset-2"
223
+ )}
224
+ >
225
+ {columns.map((column) => (
226
+ <td key={column.id} className={cn(cellVariants({ size }), alignClass[column.align ?? "start"], column.className)}>
227
+ {column.cell(row)}
228
+ </td>
229
+ ))}
230
+ </tr>
231
+ ))
232
+ )}
233
+ </tbody>
234
+ </table>
235
+ </div>
236
+ );
237
+ });
238
+
239
+ /**
240
+ * A data table for back-office lists — orders, products, customers.
241
+ *
242
+ * Props-based, per docs/component-api-design.md: rows are uniform, so they
243
+ * arrive as a `rows` array and a `columns` config, and each column's `cell`
244
+ * renders one row's content. Anything interactive in a cell — a status
245
+ * Badge, an action Button — is just what `cell` returns.
246
+ *
247
+ * A column with `sortValue` gets a header button cycling ascending →
248
+ * descending → unsorted, and `aria-sort` on its `<th>`. Sorting is
249
+ * client-side and stable; pass `manualSorting` to only report the change when
250
+ * the data is sorted where it is fetched. Sort state is controlled or
251
+ * uncontrolled through `sort`/`defaultSort`/`onSortChange`.
252
+ *
253
+ * `onRowClick` makes every row a tab stop activated by pointer, `Enter` or
254
+ * `Space` — keys pressed on a control inside the row are left to that control.
255
+ *
256
+ * The wrapper scrolls, so a wide table on a phone scrolls sideways inside its
257
+ * border rather than widening the page.
258
+ */
259
+ export const DataTable = DataTableRoot as <Row>(props: DataTableProps<Row> & { ref?: Ref<HTMLDivElement> }) => ReactElement;
260
+
261
+ export { cellVariants as dataTableCellVariants };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * DataTable recipe — the component-token layer for DataTable.
3
+ *
4
+ * No variant/intent axis: status colour inside a table belongs to what the
5
+ * cells render (a Badge), not to the table. The table itself is the Elevated
6
+ * tier's container — the same surface and rule colours as card.css — through
7
+ * its own tokens, so a consumer can re-point one without the other.
8
+ *
9
+ * The grammar is `--data-table-[{part}-]{context}[-{state}]`. Parts are the
10
+ * `header` row, a `row` (only its hover, which applies only when rows are
11
+ * clickable), and the `empty` state's text. `--data-table-border` draws the
12
+ * outer frame and the rules between rows alike.
13
+ *
14
+ * `@layer components` is load-bearing, not tidiness — see button.css.
15
+ */
16
+
17
+ @layer components {
18
+ .data-table {
19
+ --data-table-bg: var(--bg-normal);
20
+ --data-table-fg: var(--fg-normal);
21
+ --data-table-border: var(--border-normal);
22
+ --data-table-ring: var(--focus-ring);
23
+ --data-table-header-bg: var(--bg-subtle);
24
+ --data-table-header-fg: var(--fg-subtle);
25
+ --data-table-row-bg-hovered: var(--bg-normal-hovered);
26
+ --data-table-empty-fg: var(--fg-subtle);
27
+ }
28
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ DataTable,
3
+ type DataTableAlign,
4
+ type DataTableColumn,
5
+ type DataTableProps,
6
+ type DataTableSize,
7
+ type DataTableSort,
8
+ type DataTableSortDirection,
9
+ dataTableCellVariants,
10
+ } from "./DataTable";
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { Upload } from "lucide-react";
5
+ import * as React from "react";
6
+
7
+ /**
8
+ * Geometry only. Every colour reads a `--file-dropzone-*` component token
9
+ * whose value is decided by src/components/FileDropzone/file-dropzone.recipe.css.
10
+ *
11
+ * `size` is the target's footprint: `sm` sits inline in a form row beside other
12
+ * fields, `md` is a panel of its own (an image manager's "add photos" tile).
13
+ */
14
+ const fileDropzoneVariants = cva(
15
+ // One literal, deliberately — see Button.tsx for why a `+` concatenation is a trap.
16
+ "file-dropzone border-(color:--file-dropzone-border) focus-visible:ring-(color:--file-dropzone-ring) relative flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed bg-(--file-dropzone-bg) text-center text-(--file-dropzone-fg) transition-colors hover:bg-(--file-dropzone-bg-hovered) focus-visible:outline-hidden focus-visible:ring-2 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
17
+ {
18
+ variants: {
19
+ size: {
20
+ sm: "min-h-20 gap-1 px-4 py-3 text-xs [&_svg]:size-4",
21
+ md: "min-h-36 gap-2 px-6 py-6 text-sm [&_svg]:size-6",
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ size: "md",
26
+ },
27
+ }
28
+ );
29
+
30
+ export type FileDropzoneSize = NonNullable<VariantProps<typeof fileDropzoneVariants>["size"]>;
31
+
32
+ export type FileDropzoneRejection = {
33
+ file: File;
34
+ /** `type` — not in `accept`; `size` — over `maxSize`. */
35
+ reason: "type" | "size";
36
+ };
37
+
38
+ export type FileDropzoneProps = Omit<React.HTMLAttributes<HTMLDivElement>, "onDrop" | "children"> &
39
+ VariantProps<typeof fileDropzoneVariants> & {
40
+ /** Called with the accepted files from a drop or the file picker. Never called with an empty list. */
41
+ onFilesChange: (files: File[]) => void;
42
+ /** Called with files that failed `accept` or `maxSize`, when any did. */
43
+ onFilesRejected?: (rejections: FileDropzoneRejection[]) => void;
44
+ /** Same syntax as the native `accept` attribute: `"image/*"`, `".pdf,.docx"`. Also checked on drop. */
45
+ accept?: string;
46
+ /** Allow more than one file per drop or pick. */
47
+ multiple?: boolean;
48
+ /** Largest accepted file, in bytes. */
49
+ maxSize?: number;
50
+ disabled?: boolean;
51
+ /** Shows a busy state (e.g. while uploading) and blocks new files without disabling the control. */
52
+ pending?: boolean;
53
+ /** The main line. Default: "Drop files here or click to browse". */
54
+ label?: React.ReactNode;
55
+ /** A secondary line, e.g. accepted types and size limit. */
56
+ hint?: React.ReactNode;
57
+ /** Replaces the default upload icon. Pass `null` for none. */
58
+ icon?: React.ReactNode;
59
+ /** Input name, for native form submission of the picked files. */
60
+ name?: string;
61
+ };
62
+
63
+ function matchesAccept(file: File, accept: string | undefined): boolean {
64
+ if (!accept) return true;
65
+ const rules = accept
66
+ .split(",")
67
+ .map((rule) => rule.trim().toLowerCase())
68
+ .filter(Boolean);
69
+ if (rules.length === 0) return true;
70
+ const name = file.name.toLowerCase();
71
+ const type = file.type.toLowerCase();
72
+ return rules.some((rule) => {
73
+ if (rule.startsWith(".")) return name.endsWith(rule);
74
+ if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
75
+ return type === rule;
76
+ });
77
+ }
78
+
79
+ /**
80
+ * A drop target for files that doubles as a file-picker button — product
81
+ * photos, attachments, imports.
82
+ *
83
+ * The whole surface is one control: `role="button"`, a tab stop, and `Enter`
84
+ * or `Space` opens the native picker, the same as a click. Dragging files over
85
+ * it sets `data-state="active"`, which the recipe styles. The hidden `<input
86
+ * type="file">` is the real picker, so `accept`, `multiple` and `name` behave
87
+ * natively; `accept` and `maxSize` are also enforced on drop, where the
88
+ * browser does not, and failures go to `onFilesRejected` rather than being
89
+ * silently dropped. Without `multiple`, a drop of several files keeps the first.
90
+ *
91
+ * `pending` shows a spinner-free busy state (`aria-busy`) and ignores new
92
+ * files while an upload runs, without leaving the tab order — the same split
93
+ * Switch makes between `pending` and `disabled`.
94
+ *
95
+ * The component holds no file list of its own: it reports files and the
96
+ * caller decides what to show, so there is no value/defaultValue triad.
97
+ */
98
+ export const FileDropzone = React.forwardRef<HTMLDivElement, FileDropzoneProps>(
99
+ (
100
+ {
101
+ className,
102
+ size = "md",
103
+ onFilesChange,
104
+ onFilesRejected,
105
+ accept,
106
+ multiple = false,
107
+ maxSize,
108
+ disabled = false,
109
+ pending = false,
110
+ label = "Drop files here or click to browse",
111
+ hint,
112
+ icon,
113
+ name,
114
+ onKeyDown,
115
+ onClick,
116
+ ...props
117
+ },
118
+ ref
119
+ ) => {
120
+ const inputRef = React.useRef<HTMLInputElement>(null);
121
+ const [active, setActive] = React.useState(false);
122
+ // dragenter/dragleave fire for every child crossed; count them so the state does not flicker.
123
+ const depth = React.useRef(0);
124
+ const labelId = React.useId();
125
+ const hintId = React.useId();
126
+
127
+ const interactive = !disabled && !pending;
128
+
129
+ const handleFiles = (list: FileList | null) => {
130
+ if (!list || !interactive) return;
131
+ const files = Array.from(list);
132
+ const accepted: File[] = [];
133
+ const rejected: FileDropzoneRejection[] = [];
134
+ for (const file of files) {
135
+ if (!matchesAccept(file, accept)) rejected.push({ file, reason: "type" });
136
+ else if (maxSize !== undefined && file.size > maxSize) rejected.push({ file, reason: "size" });
137
+ else accepted.push(file);
138
+ }
139
+ const kept = multiple ? accepted : accepted.slice(0, 1);
140
+ if (rejected.length > 0) onFilesRejected?.(rejected);
141
+ if (kept.length > 0) onFilesChange(kept);
142
+ };
143
+
144
+ const openPicker = () => {
145
+ if (interactive) inputRef.current?.click();
146
+ };
147
+
148
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
149
+ onKeyDown?.(event);
150
+ if (event.defaultPrevented || event.target !== event.currentTarget) return;
151
+ if (event.key === "Enter" || event.key === " ") {
152
+ event.preventDefault();
153
+ openPicker();
154
+ }
155
+ };
156
+
157
+ const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
158
+ event.preventDefault();
159
+ if (!interactive) return;
160
+ depth.current += 1;
161
+ setActive(true);
162
+ };
163
+
164
+ const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
165
+ event.preventDefault();
166
+ depth.current = Math.max(0, depth.current - 1);
167
+ if (depth.current === 0) setActive(false);
168
+ };
169
+
170
+ const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
171
+ event.preventDefault();
172
+ event.dataTransfer.dropEffect = interactive ? "copy" : "none";
173
+ };
174
+
175
+ const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
176
+ event.preventDefault();
177
+ depth.current = 0;
178
+ setActive(false);
179
+ handleFiles(event.dataTransfer.files);
180
+ };
181
+
182
+ return (
183
+ // biome-ignore lint/a11y/useSemanticElements: the target holds the native file <input>, and interactive content is not allowed inside a <button>; role="button" with Enter/Space handling is the WAI-ARIA button pattern
184
+ <div
185
+ ref={ref}
186
+ role="button"
187
+ tabIndex={disabled ? -1 : 0}
188
+ aria-disabled={disabled || undefined}
189
+ aria-busy={pending || undefined}
190
+ aria-labelledby={labelId}
191
+ aria-describedby={hint ? hintId : undefined}
192
+ data-state={active ? "active" : "idle"}
193
+ data-disabled={disabled}
194
+ className={fileDropzoneVariants({ size, className })}
195
+ onClick={(event) => {
196
+ onClick?.(event);
197
+ if (!event.defaultPrevented) openPicker();
198
+ }}
199
+ onKeyDown={handleKeyDown}
200
+ onDragEnter={handleDragEnter}
201
+ onDragLeave={handleDragLeave}
202
+ onDragOver={handleDragOver}
203
+ onDrop={handleDrop}
204
+ {...props}
205
+ >
206
+ {icon === undefined ? <Upload aria-hidden="true" className="text-(--file-dropzone-icon-fg)" /> : icon}
207
+ <span id={labelId} className="font-medium">
208
+ {label}
209
+ </span>
210
+ {hint && (
211
+ <span id={hintId} className="text-(--file-dropzone-hint-fg)">
212
+ {hint}
213
+ </span>
214
+ )}
215
+ <input
216
+ ref={inputRef}
217
+ type="file"
218
+ // `hidden`, not `sr-only`: a rendered input inside role="button" is a nested
219
+ // interactive control (axe `nested-interactive`). `click()` and form submission
220
+ // still work on a display:none file input.
221
+ hidden
222
+ accept={accept}
223
+ multiple={multiple}
224
+ name={name}
225
+ disabled={disabled}
226
+ onClick={(event) => event.stopPropagation()}
227
+ onChange={(event) => {
228
+ handleFiles(event.target.files);
229
+ // Reset so picking the same file again still fires change.
230
+ event.target.value = "";
231
+ }}
232
+ />
233
+ </div>
234
+ );
235
+ }
236
+ );
237
+ FileDropzone.displayName = "FileDropzone";
238
+
239
+ export { fileDropzoneVariants };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * FileDropzone recipe — the component-token layer for FileDropzone.
3
+ *
4
+ * No variant/intent axis: a dropzone is a form control, so like Input every
5
+ * colour is a fixed semantic token. The one state that changes colour is a
6
+ * drag in progress, which the component renders as `data-state="active"`
7
+ * and the recipe keys a cell on — the same way steps.css keys on `data-state`.
8
+ *
9
+ * The grammar is `--file-dropzone-[{part}-]{context}[-{state}]`. Parts are
10
+ * the `icon` and the `hint` line. The surface reads the subtle ground so the
11
+ * dashed target stands apart from the fields around it, and a drag over it
12
+ * takes the brand wash — the same pairing Tabs uses for its selected cell.
13
+ *
14
+ * `@layer components` is load-bearing, not tidiness — see button.css.
15
+ */
16
+
17
+ @layer components {
18
+ .file-dropzone {
19
+ --file-dropzone-bg: var(--bg-subtle);
20
+ --file-dropzone-bg-hovered: var(--bg-subtle-hovered);
21
+ --file-dropzone-fg: var(--fg-normal);
22
+ --file-dropzone-border: var(--border-normal);
23
+ --file-dropzone-ring: var(--focus-ring);
24
+ --file-dropzone-icon-fg: var(--fg-subtle);
25
+ --file-dropzone-hint-fg: var(--fg-subtle);
26
+ }
27
+
28
+ /* A drag in progress: the brand wash, hover included, so the target does not
29
+ flicker back to subtle as the pointer moves across it. */
30
+ .file-dropzone[data-state="active"] {
31
+ --file-dropzone-bg: var(--bg-brand-subtle);
32
+ --file-dropzone-bg-hovered: var(--bg-brand-subtle);
33
+ --file-dropzone-border: var(--border-brand);
34
+ --file-dropzone-icon-fg: var(--fg-brand);
35
+ /* fg-subtle drops below 4.5:1 on the brand wash in light mode. */
36
+ --file-dropzone-hint-fg: var(--fg-normal);
37
+ }
38
+ }
@@ -0,0 +1 @@
1
+ export { FileDropzone, type FileDropzoneProps, type FileDropzoneRejection, type FileDropzoneSize, fileDropzoneVariants } from "./FileDropzone";