@inshorts/glint-ui 0.3.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/README.md +60 -0
- package/package.json +56 -0
- package/src/alert.tsx +74 -0
- package/src/autocomplete.tsx +666 -0
- package/src/badge.tsx +60 -0
- package/src/breadcrumb.tsx +71 -0
- package/src/button.tsx +113 -0
- package/src/card.tsx +79 -0
- package/src/checkbox.tsx +42 -0
- package/src/clipboard.ts +144 -0
- package/src/collapsible.tsx +63 -0
- package/src/confirm-dialog.tsx +66 -0
- package/src/copy-button.tsx +58 -0
- package/src/data-table.tsx +190 -0
- package/src/date-range-picker/dateRange.ts +142 -0
- package/src/date-range-picker/index.tsx +366 -0
- package/src/dialogLayer.ts +142 -0
- package/src/feature-access-gate.tsx +38 -0
- package/src/field.tsx +39 -0
- package/src/image-uploader.tsx +181 -0
- package/src/index.ts +35 -0
- package/src/input.tsx +16 -0
- package/src/label.tsx +23 -0
- package/src/modal.tsx +110 -0
- package/src/page-header.tsx +75 -0
- package/src/pagination.tsx +116 -0
- package/src/segmented-control.tsx +155 -0
- package/src/select.tsx +105 -0
- package/src/separator.tsx +32 -0
- package/src/side-drawer.tsx +87 -0
- package/src/spinner.tsx +38 -0
- package/src/spreadsheet-grid.tsx +264 -0
- package/src/state-panel.tsx +46 -0
- package/src/switch.tsx +46 -0
- package/src/table.tsx +110 -0
- package/src/tabs.tsx +118 -0
- package/src/textarea.tsx +32 -0
- package/src/utils.ts +6 -0
- package/theme.css +2 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { type CellRange, type CellRef } from "./clipboard";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A column knows three things: its heading, the text it contributes to a copy
|
|
9
|
+
* or CSV export, and optionally how to draw itself.
|
|
10
|
+
*
|
|
11
|
+
* Keeping `value` and `render` together is what stops an exported sheet from
|
|
12
|
+
* drifting from the visible grid.
|
|
13
|
+
*/
|
|
14
|
+
export type GridColumn<Row> = {
|
|
15
|
+
key: string;
|
|
16
|
+
title: string;
|
|
17
|
+
/** Text used for clipboard and CSV. Never JSX. */
|
|
18
|
+
value: (row: Row) => string;
|
|
19
|
+
/** Defaults to `value`. Use for links, tiles, truncation. */
|
|
20
|
+
render?: (row: Row) => React.ReactNode;
|
|
21
|
+
/** Extra classes for the <td>. */
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface SpreadsheetGridProps<Row> {
|
|
26
|
+
columns: GridColumn<Row>[];
|
|
27
|
+
rows: Row[];
|
|
28
|
+
/** Stable React key per row. Defaults to the row index. */
|
|
29
|
+
rowKey?: (row: Row, index: number) => string;
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
emptyMessage?: string;
|
|
32
|
+
loadingMessage?: string;
|
|
33
|
+
ariaLabel?: string;
|
|
34
|
+
/** Lifted so a toolbar can copy and describe the current selection. */
|
|
35
|
+
range: CellRange | null;
|
|
36
|
+
onRangeChange: (selection: {
|
|
37
|
+
anchor: CellRef | null;
|
|
38
|
+
focus: CellRef | null;
|
|
39
|
+
}) => void;
|
|
40
|
+
onCopy: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isInRange(range: CellRange | null, r: number, c: number): boolean {
|
|
44
|
+
if (!range) return false;
|
|
45
|
+
return r >= range.r1 && r <= range.r2 && c >= range.c1 && c <= range.c2;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Spreadsheet-like grid: click-drag a range, shift+click to extend, arrows to
|
|
50
|
+
* move, click a heading to take the column, click the row number to take the
|
|
51
|
+
* row, Cmd/Ctrl+A for everything, Escape to clear, Cmd/Ctrl+C to copy.
|
|
52
|
+
*
|
|
53
|
+
* Ported from video-shorts-panel's ExcelTable, which several panels share for
|
|
54
|
+
* exactly this reason — a table people paste into a sheet.
|
|
55
|
+
*/
|
|
56
|
+
export function SpreadsheetGrid<Row>({
|
|
57
|
+
columns,
|
|
58
|
+
rows,
|
|
59
|
+
rowKey,
|
|
60
|
+
loading,
|
|
61
|
+
emptyMessage = "Nothing to show.",
|
|
62
|
+
loadingMessage = "Loading…",
|
|
63
|
+
ariaLabel = "Data grid",
|
|
64
|
+
range,
|
|
65
|
+
onRangeChange,
|
|
66
|
+
onCopy,
|
|
67
|
+
}: SpreadsheetGridProps<Row>) {
|
|
68
|
+
const gridRef = React.useRef<HTMLDivElement>(null);
|
|
69
|
+
const anchorRef = React.useRef<CellRef | null>(null);
|
|
70
|
+
const focusRef = React.useRef<CellRef | null>(null);
|
|
71
|
+
const [dragging, setDragging] = React.useState(false);
|
|
72
|
+
|
|
73
|
+
const setSelection = React.useCallback(
|
|
74
|
+
(anchor: CellRef | null, focus: CellRef | null) => {
|
|
75
|
+
anchorRef.current = anchor;
|
|
76
|
+
focusRef.current = focus;
|
|
77
|
+
onRangeChange({ anchor, focus });
|
|
78
|
+
},
|
|
79
|
+
[onRangeChange],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const selectCell = React.useCallback(
|
|
83
|
+
(r: number, c: number, extend?: boolean) => {
|
|
84
|
+
if (extend && anchorRef.current)
|
|
85
|
+
setSelection(anchorRef.current, { r, c });
|
|
86
|
+
else setSelection({ r, c }, { r, c });
|
|
87
|
+
},
|
|
88
|
+
[setSelection],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const selectColumn = React.useCallback(
|
|
92
|
+
(c: number, extend?: boolean) => {
|
|
93
|
+
const lastRow = Math.max(rows.length - 1, 0);
|
|
94
|
+
const anchorCol = extend && anchorRef.current ? anchorRef.current.c : c;
|
|
95
|
+
setSelection({ r: 0, c: anchorCol }, { r: lastRow, c });
|
|
96
|
+
},
|
|
97
|
+
[rows.length, setSelection],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const selectRow = React.useCallback(
|
|
101
|
+
(r: number, extend?: boolean) => {
|
|
102
|
+
const lastCol = columns.length - 1;
|
|
103
|
+
const anchorRow = extend && anchorRef.current ? anchorRef.current.r : r;
|
|
104
|
+
setSelection({ r: anchorRow, c: 0 }, { r, c: lastCol });
|
|
105
|
+
},
|
|
106
|
+
[columns.length, setSelection],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const selectAll = React.useCallback(() => {
|
|
110
|
+
if (!rows.length || !columns.length) return;
|
|
111
|
+
setSelection({ r: 0, c: 0 }, { r: rows.length - 1, c: columns.length - 1 });
|
|
112
|
+
}, [rows.length, columns.length, setSelection]);
|
|
113
|
+
|
|
114
|
+
const scrollCellIntoView = React.useCallback((r: number, c: number) => {
|
|
115
|
+
const cell = gridRef.current?.querySelector(`[data-cell="${r}-${c}"]`);
|
|
116
|
+
cell?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const moveFocus = React.useCallback(
|
|
120
|
+
(dr: number, dc: number, extend?: boolean) => {
|
|
121
|
+
if (!rows.length || !columns.length) return;
|
|
122
|
+
const base = focusRef.current || { r: 0, c: 0 };
|
|
123
|
+
const r = Math.min(Math.max(base.r + dr, 0), rows.length - 1);
|
|
124
|
+
const c = Math.min(Math.max(base.c + dc, 0), columns.length - 1);
|
|
125
|
+
selectCell(r, c, extend);
|
|
126
|
+
scrollCellIntoView(r, c);
|
|
127
|
+
},
|
|
128
|
+
[rows.length, columns.length, selectCell, scrollCellIntoView],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Dragging must end even if the pointer is released outside the grid.
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
if (!dragging) return;
|
|
134
|
+
const stop = () => setDragging(false);
|
|
135
|
+
window.addEventListener("mouseup", stop);
|
|
136
|
+
return () => window.removeEventListener("mouseup", stop);
|
|
137
|
+
}, [dragging]);
|
|
138
|
+
|
|
139
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
140
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
141
|
+
if (meta && event.key.toLowerCase() === "c") {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
onCopy();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (meta && event.key.toLowerCase() === "a") {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
selectAll();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
switch (event.key) {
|
|
152
|
+
case "ArrowUp":
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
moveFocus(-1, 0, event.shiftKey);
|
|
155
|
+
break;
|
|
156
|
+
case "ArrowDown":
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
moveFocus(1, 0, event.shiftKey);
|
|
159
|
+
break;
|
|
160
|
+
case "ArrowLeft":
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
moveFocus(0, -1, event.shiftKey);
|
|
163
|
+
break;
|
|
164
|
+
case "ArrowRight":
|
|
165
|
+
case "Tab":
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
moveFocus(0, 1, event.shiftKey && event.key !== "Tab");
|
|
168
|
+
break;
|
|
169
|
+
case "Escape":
|
|
170
|
+
setSelection(null, null);
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
ref={gridRef}
|
|
180
|
+
tabIndex={0}
|
|
181
|
+
role="grid"
|
|
182
|
+
aria-label={ariaLabel}
|
|
183
|
+
onKeyDown={handleKeyDown}
|
|
184
|
+
className="border-border bg-background/80 overflow-x-auto rounded-lg border outline-none"
|
|
185
|
+
>
|
|
186
|
+
<table className="w-full text-sm whitespace-nowrap select-none">
|
|
187
|
+
<thead className="text-muted-foreground border-border border-b text-left">
|
|
188
|
+
<tr>
|
|
189
|
+
<th
|
|
190
|
+
scope="col"
|
|
191
|
+
className="w-10 cursor-pointer px-2 py-2 text-center font-medium"
|
|
192
|
+
title="Select everything"
|
|
193
|
+
onMouseDown={() => selectAll()}
|
|
194
|
+
>
|
|
195
|
+
#
|
|
196
|
+
</th>
|
|
197
|
+
{columns.map((column, c) => (
|
|
198
|
+
<th
|
|
199
|
+
key={column.key}
|
|
200
|
+
scope="col"
|
|
201
|
+
className="cursor-pointer px-3 py-2 font-medium"
|
|
202
|
+
title={`Select the ${column.title} column`}
|
|
203
|
+
onMouseDown={(event) => selectColumn(c, event.shiftKey)}
|
|
204
|
+
>
|
|
205
|
+
{column.title}
|
|
206
|
+
</th>
|
|
207
|
+
))}
|
|
208
|
+
</tr>
|
|
209
|
+
</thead>
|
|
210
|
+
<tbody>
|
|
211
|
+
{rows.length === 0 ? (
|
|
212
|
+
<tr>
|
|
213
|
+
<td
|
|
214
|
+
colSpan={columns.length + 1}
|
|
215
|
+
className="text-muted-foreground px-3 py-6 text-center"
|
|
216
|
+
>
|
|
217
|
+
{loading ? loadingMessage : emptyMessage}
|
|
218
|
+
</td>
|
|
219
|
+
</tr>
|
|
220
|
+
) : (
|
|
221
|
+
rows.map((row, r) => (
|
|
222
|
+
<tr
|
|
223
|
+
key={rowKey ? rowKey(row, r) : String(r)}
|
|
224
|
+
className="border-border border-b"
|
|
225
|
+
>
|
|
226
|
+
<td
|
|
227
|
+
className="text-muted-foreground w-10 cursor-pointer px-2 py-2 text-center text-xs"
|
|
228
|
+
title="Select this row"
|
|
229
|
+
onMouseDown={(event) => selectRow(r, event.shiftKey)}
|
|
230
|
+
>
|
|
231
|
+
{r + 1}
|
|
232
|
+
</td>
|
|
233
|
+
{columns.map((column, c) => (
|
|
234
|
+
<td
|
|
235
|
+
key={column.key}
|
|
236
|
+
data-cell={`${r}-${c}`}
|
|
237
|
+
onMouseDown={(event) => {
|
|
238
|
+
selectCell(r, c, event.shiftKey);
|
|
239
|
+
setDragging(true);
|
|
240
|
+
gridRef.current?.focus();
|
|
241
|
+
}}
|
|
242
|
+
onMouseEnter={() => {
|
|
243
|
+
if (dragging) setSelection(anchorRef.current, { r, c });
|
|
244
|
+
}}
|
|
245
|
+
className={`px-3 py-2 ${column.className || ""} ${
|
|
246
|
+
isInRange(range, r, c) ? "bg-primary/15" : ""
|
|
247
|
+
}`}
|
|
248
|
+
title={
|
|
249
|
+
column.className?.includes("truncate")
|
|
250
|
+
? column.value(row)
|
|
251
|
+
: undefined
|
|
252
|
+
}
|
|
253
|
+
>
|
|
254
|
+
{column.render ? column.render(row) : column.value(row)}
|
|
255
|
+
</td>
|
|
256
|
+
))}
|
|
257
|
+
</tr>
|
|
258
|
+
))
|
|
259
|
+
)}
|
|
260
|
+
</tbody>
|
|
261
|
+
</table>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { Spinner } from "./spinner";
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The bordered block a list shows instead of rows: loading, or nothing found.
|
|
8
|
+
*
|
|
9
|
+
* Fourteen copies of the same
|
|
10
|
+
* `border-border bg-background/80 … rounded-lg border px-4 py-6 text-center`
|
|
11
|
+
* across the panels, each with its own wording — "Loading users…", "Loading
|
|
12
|
+
* taxonomy…", "Loading showcases…". The wording is worth keeping (it says what
|
|
13
|
+
* is loading), the markup is not.
|
|
14
|
+
*
|
|
15
|
+
* `loading` adds two things the hand-rolled versions did not have: a spinner,
|
|
16
|
+
* so a slow panel does not look like an empty one, and `role="status"` with
|
|
17
|
+
* `aria-live="polite"`, so a screen reader is told the list arrived instead of
|
|
18
|
+
* silently finding rows on the next tab press.
|
|
19
|
+
*/
|
|
20
|
+
export interface StatePanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
21
|
+
/** Shows a spinner and announces politely. */
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function StatePanel({
|
|
26
|
+
loading,
|
|
27
|
+
className,
|
|
28
|
+
children,
|
|
29
|
+
...props
|
|
30
|
+
}: StatePanelProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
role={loading ? "status" : undefined}
|
|
34
|
+
aria-live={loading ? "polite" : undefined}
|
|
35
|
+
className={cn(
|
|
36
|
+
"border-border bg-background/80 text-muted-foreground rounded-lg border px-4 py-6 text-center text-sm",
|
|
37
|
+
loading && "flex items-center justify-center gap-2",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
{loading ? <Spinner size="md" /> : null}
|
|
43
|
+
{children}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
package/src/switch.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "./utils";
|
|
6
|
+
|
|
7
|
+
export type SwitchProps = Omit<
|
|
8
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
9
|
+
"onChange"
|
|
10
|
+
> & {
|
|
11
|
+
checked: boolean;
|
|
12
|
+
onCheckedChange: (checked: boolean) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Compact accessible toggle — use instead of full-width checkbox inputs. */
|
|
16
|
+
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
|
17
|
+
({ checked, onCheckedChange, className, disabled, id, ...props }, ref) => (
|
|
18
|
+
<button
|
|
19
|
+
ref={ref}
|
|
20
|
+
id={id}
|
|
21
|
+
type="button"
|
|
22
|
+
role="switch"
|
|
23
|
+
aria-checked={checked}
|
|
24
|
+
disabled={disabled}
|
|
25
|
+
onClick={() => onCheckedChange(!checked)}
|
|
26
|
+
className={cn(
|
|
27
|
+
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border transition-colors",
|
|
28
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
|
29
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
30
|
+
checked ? "border-primary bg-primary" : "border-border bg-muted",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
<span
|
|
36
|
+
aria-hidden
|
|
37
|
+
className={cn(
|
|
38
|
+
"bg-background pointer-events-none block size-4 rounded-full shadow-sm transition-transform",
|
|
39
|
+
checked ? "translate-x-4" : "translate-x-0.5",
|
|
40
|
+
)}
|
|
41
|
+
/>
|
|
42
|
+
</button>
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
Switch.displayName = "Switch";
|
package/src/table.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Low-level table primitives. Prefer `DataTable` for list panels; use these
|
|
7
|
+
* when the row markup is custom (nested actions, expandable rows, etc.).
|
|
8
|
+
*/
|
|
9
|
+
export const Table = React.forwardRef<
|
|
10
|
+
HTMLTableElement,
|
|
11
|
+
React.HTMLAttributes<HTMLTableElement>
|
|
12
|
+
>(({ className, ...props }, ref) => (
|
|
13
|
+
<div className="relative w-full overflow-auto">
|
|
14
|
+
<table
|
|
15
|
+
ref={ref}
|
|
16
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
</div>
|
|
20
|
+
));
|
|
21
|
+
Table.displayName = "Table";
|
|
22
|
+
|
|
23
|
+
export const TableHeader = React.forwardRef<
|
|
24
|
+
HTMLTableSectionElement,
|
|
25
|
+
React.HTMLAttributes<HTMLTableSectionElement>
|
|
26
|
+
>(({ className, ...props }, ref) => (
|
|
27
|
+
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
|
28
|
+
));
|
|
29
|
+
TableHeader.displayName = "TableHeader";
|
|
30
|
+
|
|
31
|
+
export const TableBody = React.forwardRef<
|
|
32
|
+
HTMLTableSectionElement,
|
|
33
|
+
React.HTMLAttributes<HTMLTableSectionElement>
|
|
34
|
+
>(({ className, ...props }, ref) => (
|
|
35
|
+
<tbody
|
|
36
|
+
ref={ref}
|
|
37
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
));
|
|
41
|
+
TableBody.displayName = "TableBody";
|
|
42
|
+
|
|
43
|
+
export const TableFooter = React.forwardRef<
|
|
44
|
+
HTMLTableSectionElement,
|
|
45
|
+
React.HTMLAttributes<HTMLTableSectionElement>
|
|
46
|
+
>(({ className, ...props }, ref) => (
|
|
47
|
+
<tfoot
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={cn(
|
|
50
|
+
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
));
|
|
56
|
+
TableFooter.displayName = "TableFooter";
|
|
57
|
+
|
|
58
|
+
export const TableRow = React.forwardRef<
|
|
59
|
+
HTMLTableRowElement,
|
|
60
|
+
React.HTMLAttributes<HTMLTableRowElement>
|
|
61
|
+
>(({ className, ...props }, ref) => (
|
|
62
|
+
<tr
|
|
63
|
+
ref={ref}
|
|
64
|
+
className={cn(
|
|
65
|
+
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
));
|
|
71
|
+
TableRow.displayName = "TableRow";
|
|
72
|
+
|
|
73
|
+
export const TableHead = React.forwardRef<
|
|
74
|
+
HTMLTableCellElement,
|
|
75
|
+
React.ThHTMLAttributes<HTMLTableCellElement>
|
|
76
|
+
>(({ className, ...props }, ref) => (
|
|
77
|
+
<th
|
|
78
|
+
ref={ref}
|
|
79
|
+
className={cn(
|
|
80
|
+
"text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
));
|
|
86
|
+
TableHead.displayName = "TableHead";
|
|
87
|
+
|
|
88
|
+
export const TableCell = React.forwardRef<
|
|
89
|
+
HTMLTableCellElement,
|
|
90
|
+
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
91
|
+
>(({ className, ...props }, ref) => (
|
|
92
|
+
<td
|
|
93
|
+
ref={ref}
|
|
94
|
+
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
|
95
|
+
{...props}
|
|
96
|
+
/>
|
|
97
|
+
));
|
|
98
|
+
TableCell.displayName = "TableCell";
|
|
99
|
+
|
|
100
|
+
export const TableCaption = React.forwardRef<
|
|
101
|
+
HTMLTableCaptionElement,
|
|
102
|
+
React.HTMLAttributes<HTMLTableCaptionElement>
|
|
103
|
+
>(({ className, ...props }, ref) => (
|
|
104
|
+
<caption
|
|
105
|
+
ref={ref}
|
|
106
|
+
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
));
|
|
110
|
+
TableCaption.displayName = "TableCaption";
|
package/src/tabs.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "./utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tab strip five Managers were each drawing by hand, with the same
|
|
9
|
+
* `rounded-t-md px-3 py-1.5 text-sm font-medium` and their own idea of what an
|
|
10
|
+
* active tab looks like.
|
|
11
|
+
*
|
|
12
|
+
* It is a controlled component with no state of its own: every caller already
|
|
13
|
+
* held the active key, usually because a URL or a query depends on it.
|
|
14
|
+
*
|
|
15
|
+
* Roles are the reason to use this rather than styling buttons again. A hand
|
|
16
|
+
* -rolled strip is a row of buttons to a screen reader, which says nothing
|
|
17
|
+
* about how many tabs there are or which is selected; `tablist` / `tab` /
|
|
18
|
+
* `aria-selected` do. Arrow-key navigation comes with that expectation, so it
|
|
19
|
+
* is implemented here too.
|
|
20
|
+
*/
|
|
21
|
+
export interface TabItem<Key extends string = string> {
|
|
22
|
+
key: Key;
|
|
23
|
+
label: React.ReactNode;
|
|
24
|
+
/** Rendered after the label — a count, usually. */
|
|
25
|
+
badge?: React.ReactNode;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TabsProps<Key extends string = string> {
|
|
30
|
+
tabs: ReadonlyArray<TabItem<Key>>;
|
|
31
|
+
value: Key;
|
|
32
|
+
onChange: (key: Key) => void;
|
|
33
|
+
/** Names the strip for screen readers. */
|
|
34
|
+
ariaLabel?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Tabs<Key extends string = string>({
|
|
39
|
+
tabs,
|
|
40
|
+
value,
|
|
41
|
+
onChange,
|
|
42
|
+
ariaLabel,
|
|
43
|
+
className,
|
|
44
|
+
}: TabsProps<Key>) {
|
|
45
|
+
const refs = React.useRef(new Map<Key, HTMLButtonElement | null>());
|
|
46
|
+
|
|
47
|
+
function focusTab(key: Key) {
|
|
48
|
+
onChange(key);
|
|
49
|
+
// The newly selected tab has to take focus, or the next arrow press is
|
|
50
|
+
// still measured from the old one.
|
|
51
|
+
refs.current.get(key)?.focus();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
|
55
|
+
const step =
|
|
56
|
+
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
|
57
|
+
if (step === 0) return;
|
|
58
|
+
|
|
59
|
+
const selectable = tabs.filter((tab) => !tab.disabled);
|
|
60
|
+
if (selectable.length < 2) return;
|
|
61
|
+
|
|
62
|
+
const current = selectable.findIndex((tab) => tab.key === value);
|
|
63
|
+
// Wraps, which is what a tablist is expected to do.
|
|
64
|
+
const next =
|
|
65
|
+
selectable[(current + step + selectable.length) % selectable.length];
|
|
66
|
+
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
focusTab(next.key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
role="tablist"
|
|
74
|
+
aria-label={ariaLabel}
|
|
75
|
+
onKeyDown={onKeyDown}
|
|
76
|
+
className={cn("border-border flex flex-wrap gap-1 border-b", className)}
|
|
77
|
+
>
|
|
78
|
+
{tabs.map((tab) => {
|
|
79
|
+
const active = tab.key === value;
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
key={tab.key}
|
|
83
|
+
ref={(node) => {
|
|
84
|
+
refs.current.set(tab.key, node);
|
|
85
|
+
}}
|
|
86
|
+
type="button"
|
|
87
|
+
role="tab"
|
|
88
|
+
aria-selected={active}
|
|
89
|
+
disabled={tab.disabled}
|
|
90
|
+
// Only the active tab is reachable by Tab; arrows move within the
|
|
91
|
+
// strip. Without this a keyboard user pages through every tab to
|
|
92
|
+
// get past the strip.
|
|
93
|
+
tabIndex={active ? 0 : -1}
|
|
94
|
+
onClick={() => onChange(tab.key)}
|
|
95
|
+
className={cn(
|
|
96
|
+
"cursor-pointer rounded-t-md px-3 py-1.5 text-sm font-medium transition",
|
|
97
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
|
98
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
99
|
+
// The filled-pill active state the five hand-rolled strips
|
|
100
|
+
// already used. Adopting it keeps this a deduplication rather
|
|
101
|
+
// than a restyle of every panel that has tabs.
|
|
102
|
+
active
|
|
103
|
+
? "bg-slate-950 text-white dark:bg-white dark:text-slate-950"
|
|
104
|
+
: "text-muted-foreground hover:bg-muted",
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{tab.label}
|
|
108
|
+
{tab.badge != null ? (
|
|
109
|
+
<span className="text-muted-foreground ml-1.5 text-xs">
|
|
110
|
+
{tab.badge}
|
|
111
|
+
</span>
|
|
112
|
+
) : null}
|
|
113
|
+
</button>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/textarea.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The multi-line counterpart to `Input`.
|
|
7
|
+
*
|
|
8
|
+
* There was no shared textarea, so sixteen of them were styled individually
|
|
9
|
+
* and no two agreed — different borders, different focus rings, some with no
|
|
10
|
+
* focus ring at all, which is a keyboard user losing their place.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately not `inputClass`: that fixes `h-9`, which is a one-line
|
|
13
|
+
* control. Everything else is shared so a textarea and an input sitting in the
|
|
14
|
+
* same form look related.
|
|
15
|
+
*/
|
|
16
|
+
export const textareaClass =
|
|
17
|
+
"border-border bg-background focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50";
|
|
18
|
+
|
|
19
|
+
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
20
|
+
|
|
21
|
+
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
22
|
+
({ className, rows = 3, ...props }, ref) => (
|
|
23
|
+
<textarea
|
|
24
|
+
ref={ref}
|
|
25
|
+
rows={rows}
|
|
26
|
+
className={cn(textareaClass, className)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
Textarea.displayName = "Textarea";
|
package/src/utils.ts
ADDED
package/theme.css
ADDED