@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.
- package/dist/components/DataTable/DataTable.d.ts +89 -0
- package/dist/components/DataTable/DataTable.d.ts.map +1 -0
- package/dist/components/DataTable/DataTable.js +107 -0
- package/dist/components/DataTable/DataTable.js.map +1 -0
- package/dist/components/DataTable/index.d.ts +2 -0
- package/dist/components/DataTable/index.d.ts.map +1 -0
- package/dist/components/DataTable/index.js +2 -0
- package/dist/components/DataTable/index.js.map +1 -0
- package/dist/components/FileDropzone/FileDropzone.d.ts +87 -0
- package/dist/components/FileDropzone/FileDropzone.d.ts.map +1 -0
- package/dist/components/FileDropzone/FileDropzone.js +146 -0
- package/dist/components/FileDropzone/FileDropzone.js.map +1 -0
- package/dist/components/FileDropzone/index.d.ts +2 -0
- package/dist/components/FileDropzone/index.d.ts.map +1 -0
- package/dist/components/FileDropzone/index.js +2 -0
- package/dist/components/FileDropzone/index.js.map +1 -0
- package/dist/components/ImageGallery/ImageGallery.d.ts +93 -0
- package/dist/components/ImageGallery/ImageGallery.d.ts.map +1 -0
- package/dist/components/ImageGallery/ImageGallery.js +121 -0
- package/dist/components/ImageGallery/ImageGallery.js.map +1 -0
- package/dist/components/ImageGallery/index.d.ts +2 -0
- package/dist/components/ImageGallery/index.d.ts.map +1 -0
- package/dist/components/ImageGallery/index.js +2 -0
- package/dist/components/ImageGallery/index.js.map +1 -0
- package/dist/components/NumberStepper/NumberStepper.d.ts +78 -0
- package/dist/components/NumberStepper/NumberStepper.d.ts.map +1 -0
- package/dist/components/NumberStepper/NumberStepper.js +129 -0
- package/dist/components/NumberStepper/NumberStepper.js.map +1 -0
- package/dist/components/NumberStepper/index.d.ts +2 -0
- package/dist/components/NumberStepper/index.d.ts.map +1 -0
- package/dist/components/NumberStepper/index.js +2 -0
- package/dist/components/NumberStepper/index.js.map +1 -0
- package/dist/components/Steps/Steps.d.ts +65 -0
- package/dist/components/Steps/Steps.d.ts.map +1 -0
- package/dist/components/Steps/Steps.js +62 -0
- package/dist/components/Steps/Steps.js.map +1 -0
- package/dist/components/Steps/index.d.ts +2 -0
- package/dist/components/Steps/index.d.ts.map +1 -0
- package/dist/components/Steps/index.js +2 -0
- package/dist/components/Steps/index.js.map +1 -0
- package/package.json +21 -1
- package/src/components/DataTable/DataTable.tsx +261 -0
- package/src/components/DataTable/data-table.recipe.css +28 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/FileDropzone/FileDropzone.tsx +239 -0
- package/src/components/FileDropzone/file-dropzone.recipe.css +38 -0
- package/src/components/FileDropzone/index.ts +1 -0
- package/src/components/ImageGallery/ImageGallery.tsx +285 -0
- package/src/components/ImageGallery/image-gallery.recipe.css +50 -0
- package/src/components/ImageGallery/index.ts +7 -0
- package/src/components/NumberStepper/NumberStepper.tsx +227 -0
- package/src/components/NumberStepper/index.ts +1 -0
- package/src/components/NumberStepper/number-stepper.recipe.css +31 -0
- package/src/components/Steps/Steps.tsx +149 -0
- package/src/components/Steps/index.ts +1 -0
- package/src/components/Steps/steps.recipe.css +51 -0
- 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,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";
|