@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.
@@ -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
+ }
@@ -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
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
package/theme.css ADDED
@@ -0,0 +1,2 @@
1
+ /* Re-export shared tokens. Prefer `@import "@inshorts/glint-theme";` in new panels. */
2
+ @import "@inshorts/glint-theme/theme.css";