@burtson-labs/ui 0.9.0 → 0.10.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 +1 -1
- package/dist/components/attachment.d.ts +56 -0
- package/dist/components/attachment.js +176 -0
- package/dist/components/attachment.js.map +1 -0
- package/dist/components/combobox.js +1 -1
- package/dist/components/composer.d.ts +12 -2
- package/dist/components/composer.js +2 -2
- package/dist/components/composer.js.map +1 -1
- package/dist/components/connection-status.d.ts +44 -0
- package/dist/components/connection-status.js +167 -0
- package/dist/components/connection-status.js.map +1 -0
- package/dist/components/data-table.d.ts +62 -0
- package/dist/components/data-table.js +264 -0
- package/dist/components/data-table.js.map +1 -0
- package/dist/components/editor-tabs.d.ts +5 -1
- package/dist/components/editor-tabs.js +58 -15
- package/dist/components/editor-tabs.js.map +1 -1
- package/dist/components/mobile-nav.d.ts +32 -0
- package/dist/components/mobile-nav.js +72 -0
- package/dist/components/mobile-nav.js.map +1 -0
- package/dist/components/sheet.d.ts +9 -3
- package/dist/components/sheet.js +18 -10
- package/dist/components/sheet.js.map +1 -1
- package/dist/components/source.d.ts +31 -0
- package/dist/components/source.js +119 -0
- package/dist/components/source.js.map +1 -0
- package/dist/components/tree-view.d.ts +16 -1
- package/dist/components/tree-view.js +11 -3
- package/dist/components/tree-view.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +13 -8
- package/dist/styles.css +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
export interface DataTableColumn<T> {
|
|
3
|
+
id: string;
|
|
4
|
+
header: React.ReactNode;
|
|
5
|
+
cell: (row: T) => React.ReactNode;
|
|
6
|
+
sortable?: boolean;
|
|
7
|
+
/** Right-aligned tabular figures. */
|
|
8
|
+
numeric?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
export type SortDirection = 'asc' | 'desc';
|
|
12
|
+
export interface DataTableSort {
|
|
13
|
+
columnId: string;
|
|
14
|
+
direction: SortDirection;
|
|
15
|
+
}
|
|
16
|
+
export interface DataTableProps<T> extends Omit<React.ComponentProps<'div'>, 'children'> {
|
|
17
|
+
/** Required: what the table lists, e.g. "Workspace members". */
|
|
18
|
+
'aria-label': string;
|
|
19
|
+
columns: DataTableColumn<T>[];
|
|
20
|
+
/** The rows to show now: already sorted, filtered and paged by the app or server. */
|
|
21
|
+
rows: T[];
|
|
22
|
+
getRowId: (row: T) => string;
|
|
23
|
+
/** Names a row for screen readers ("Select Ada Lovelace"). Defaults to its id. */
|
|
24
|
+
getRowLabel?: (row: T) => string;
|
|
25
|
+
/** Controlled sort. A sortable header cycles ascending, descending, none. */
|
|
26
|
+
sort?: DataTableSort | null;
|
|
27
|
+
onSortChange?: (sort: DataTableSort | null) => void;
|
|
28
|
+
/** Controlled filter text; a search box appears when onFilterChange is set. */
|
|
29
|
+
filter?: string;
|
|
30
|
+
onFilterChange?: (value: string) => void;
|
|
31
|
+
filterPlaceholder?: string;
|
|
32
|
+
/** Controlled selection by row id; checkboxes appear when onSelectedChange is set. */
|
|
33
|
+
selected?: string[];
|
|
34
|
+
onSelectedChange?: (ids: string[]) => void;
|
|
35
|
+
/** Enter or a click on the row, e.g. open the record. */
|
|
36
|
+
onRowAction?: (row: T) => void;
|
|
37
|
+
/** Per-row controls in a trailing column, e.g. a DropdownMenu. */
|
|
38
|
+
rowActions?: (row: T) => React.ReactNode;
|
|
39
|
+
/** 1-based; Pagination appears when onPageChange is set. */
|
|
40
|
+
page?: number;
|
|
41
|
+
pageCount?: number;
|
|
42
|
+
onPageChange?: (page: number) => void;
|
|
43
|
+
/** e.g. "1–25 of 312". */
|
|
44
|
+
pageSummary?: React.ReactNode;
|
|
45
|
+
loading?: boolean;
|
|
46
|
+
/** A message shown in place of the rows. */
|
|
47
|
+
error?: React.ReactNode;
|
|
48
|
+
onRetry?: () => void;
|
|
49
|
+
/** Shown when there are no rows and nothing failed. */
|
|
50
|
+
empty?: React.ReactNode;
|
|
51
|
+
/** Extra controls beside the search box, e.g. filters or a bulk action. */
|
|
52
|
+
toolbar?: React.ReactNode;
|
|
53
|
+
density?: 'compact' | 'default';
|
|
54
|
+
stickyHeader?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A table recipe for records: sort, search, select, page, and row actions,
|
|
58
|
+
* with loading, empty and error states. Fully controlled, so the data can
|
|
59
|
+
* come from a server; the table never reorders rows itself.
|
|
60
|
+
*/
|
|
61
|
+
declare function DataTable<T>({ 'aria-label': ariaLabel, columns, rows, getRowId, getRowLabel, sort, onSortChange, filter, onFilterChange, filterPlaceholder, selected, onSelectedChange, onRowAction, rowActions, page, pageCount, onPageChange, pageSummary, loading, error, onRetry, empty, toolbar, density, stickyHeader, className, ...props }: DataTableProps<T>): React.JSX.Element;
|
|
62
|
+
export { DataTable };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { cn } from "../lib/utils.js";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
import { Checkbox } from "./checkbox.js";
|
|
4
|
+
import { Input } from "./input.js";
|
|
5
|
+
import { Pagination } from "./pagination.js";
|
|
6
|
+
import { Skeleton } from "./skeleton.js";
|
|
7
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table.js";
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import ChevronsUpDown from "@burtson-labs/icons/react/chevrons-up-down";
|
|
11
|
+
import Search from "@burtson-labs/icons/react/search";
|
|
12
|
+
import ArrowUp from "@burtson-labs/icons/react/arrow-up";
|
|
13
|
+
import ArrowDown from "@burtson-labs/icons/react/arrow-down";
|
|
14
|
+
//#region src/components/data-table.tsx
|
|
15
|
+
var nextSort = (current, id) => {
|
|
16
|
+
if (!current || current.columnId !== id) return {
|
|
17
|
+
columnId: id,
|
|
18
|
+
direction: "asc"
|
|
19
|
+
};
|
|
20
|
+
return current.direction === "asc" ? {
|
|
21
|
+
columnId: id,
|
|
22
|
+
direction: "desc"
|
|
23
|
+
} : null;
|
|
24
|
+
};
|
|
25
|
+
var INTERACTIVE = "a,button,input,select,textarea,[role=\"checkbox\"],[role=\"menuitem\"]";
|
|
26
|
+
/**
|
|
27
|
+
* A table recipe for records: sort, search, select, page, and row actions,
|
|
28
|
+
* with loading, empty and error states. Fully controlled, so the data can
|
|
29
|
+
* come from a server; the table never reorders rows itself.
|
|
30
|
+
*/
|
|
31
|
+
function DataTable({ "aria-label": ariaLabel, columns, rows, getRowId, getRowLabel, sort, onSortChange, filter, onFilterChange, filterPlaceholder = "Search", selected, onSelectedChange, onRowAction, rowActions, page, pageCount, onPageChange, pageSummary, loading = false, error, onRetry, empty = "No results.", toolbar, density = "default", stickyHeader, className, ...props }) {
|
|
32
|
+
const selectable = Boolean(onSelectedChange);
|
|
33
|
+
const selectedSet = React.useMemo(() => new Set(selected ?? []), [selected]);
|
|
34
|
+
const ids = rows.map(getRowId);
|
|
35
|
+
const pageSelected = ids.filter((id) => selectedSet.has(id)).length;
|
|
36
|
+
const allState = ids.length > 0 && pageSelected === ids.length ? true : pageSelected > 0 ? "indeterminate" : false;
|
|
37
|
+
const colCount = columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0);
|
|
38
|
+
const interactive = Boolean(onRowAction) || selectable;
|
|
39
|
+
const [active, setActive] = React.useState(0);
|
|
40
|
+
const frameRef = React.useRef(null);
|
|
41
|
+
const activeRow = Math.min(active, Math.max(0, rows.length - 1));
|
|
42
|
+
const toggle = (id) => {
|
|
43
|
+
if (!onSelectedChange) return;
|
|
44
|
+
const next = new Set(selectedSet);
|
|
45
|
+
if (next.has(id)) next.delete(id);
|
|
46
|
+
else next.add(id);
|
|
47
|
+
onSelectedChange([...next]);
|
|
48
|
+
};
|
|
49
|
+
const toggleAll = () => {
|
|
50
|
+
if (!onSelectedChange) return;
|
|
51
|
+
const next = new Set(selectedSet);
|
|
52
|
+
if (allState === true) ids.forEach((id) => next.delete(id));
|
|
53
|
+
else ids.forEach((id) => next.add(id));
|
|
54
|
+
onSelectedChange([...next]);
|
|
55
|
+
};
|
|
56
|
+
const focusRow = (i) => {
|
|
57
|
+
const clamped = Math.min(Math.max(i, 0), rows.length - 1);
|
|
58
|
+
setActive(clamped);
|
|
59
|
+
frameRef.current?.querySelectorAll("[data-slot=\"table-body\"] > [data-slot=\"table-row\"]")[clamped]?.focus();
|
|
60
|
+
};
|
|
61
|
+
const onRowKeyDown = (event, i, row) => {
|
|
62
|
+
if (event.target !== event.currentTarget) return;
|
|
63
|
+
switch (event.key) {
|
|
64
|
+
case "ArrowDown":
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
focusRow(i + 1);
|
|
67
|
+
break;
|
|
68
|
+
case "ArrowUp":
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
focusRow(i - 1);
|
|
71
|
+
break;
|
|
72
|
+
case "Home":
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
focusRow(0);
|
|
75
|
+
break;
|
|
76
|
+
case "End":
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
focusRow(rows.length - 1);
|
|
79
|
+
break;
|
|
80
|
+
case "Enter":
|
|
81
|
+
if (onRowAction) {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
onRowAction(row);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
case " ": if (selectable) {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
toggle(getRowId(row));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const showToolbar = Boolean(onFilterChange || toolbar || selectable && selectedSet.size);
|
|
93
|
+
let body;
|
|
94
|
+
if (error) body = /* @__PURE__ */ jsx(TableRow, {
|
|
95
|
+
className: "hover:bg-transparent",
|
|
96
|
+
children: /* @__PURE__ */ jsx(TableCell, {
|
|
97
|
+
colSpan: colCount,
|
|
98
|
+
className: "h-auto py-8 text-center whitespace-normal",
|
|
99
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
100
|
+
role: "alert",
|
|
101
|
+
className: "grid justify-items-center gap-3 text-sm",
|
|
102
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
103
|
+
className: "text-destructive",
|
|
104
|
+
children: error
|
|
105
|
+
}), onRetry && /* @__PURE__ */ jsx(Button, {
|
|
106
|
+
type: "button",
|
|
107
|
+
size: "sm",
|
|
108
|
+
variant: "outline",
|
|
109
|
+
onClick: onRetry,
|
|
110
|
+
children: "Try again"
|
|
111
|
+
})]
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
});
|
|
115
|
+
else if (loading && rows.length === 0) body = Array.from({ length: 3 }, (_, r) => /* @__PURE__ */ jsx(TableRow, {
|
|
116
|
+
className: "hover:bg-transparent",
|
|
117
|
+
"aria-hidden": true,
|
|
118
|
+
children: Array.from({ length: colCount }, (_, c) => /* @__PURE__ */ jsx(TableCell, { children: /* @__PURE__ */ jsx(Skeleton, { className: "h-3.5 w-full max-w-40" }) }, c))
|
|
119
|
+
}, `loading-${r}`));
|
|
120
|
+
else if (rows.length === 0) body = /* @__PURE__ */ jsx(TableRow, {
|
|
121
|
+
className: "hover:bg-transparent",
|
|
122
|
+
children: /* @__PURE__ */ jsx(TableCell, {
|
|
123
|
+
colSpan: colCount,
|
|
124
|
+
className: "h-auto py-10 text-center whitespace-normal text-muted-foreground",
|
|
125
|
+
children: empty
|
|
126
|
+
})
|
|
127
|
+
});
|
|
128
|
+
else body = rows.map((row, i) => {
|
|
129
|
+
const id = getRowId(row);
|
|
130
|
+
const isSelected = selectedSet.has(id);
|
|
131
|
+
const label = getRowLabel?.(row) ?? id;
|
|
132
|
+
return /* @__PURE__ */ jsxs(TableRow, {
|
|
133
|
+
selected: isSelected,
|
|
134
|
+
tabIndex: interactive ? i === activeRow ? 0 : -1 : void 0,
|
|
135
|
+
onFocus: () => setActive(i),
|
|
136
|
+
onKeyDown: interactive ? (e) => onRowKeyDown(e, i, row) : void 0,
|
|
137
|
+
onClick: onRowAction ? (e) => {
|
|
138
|
+
if (e.target.closest(INTERACTIVE)) return;
|
|
139
|
+
onRowAction(row);
|
|
140
|
+
} : void 0,
|
|
141
|
+
className: cn(interactive && "outline-none focus-visible:bg-muted/60 focus-visible:shadow-[inset_2px_0_0_var(--ring)]", onRowAction && "cursor-pointer"),
|
|
142
|
+
children: [
|
|
143
|
+
selectable && /* @__PURE__ */ jsx(TableCell, {
|
|
144
|
+
className: "w-10",
|
|
145
|
+
children: /* @__PURE__ */ jsx(Checkbox, {
|
|
146
|
+
checked: isSelected,
|
|
147
|
+
onCheckedChange: () => toggle(id),
|
|
148
|
+
"aria-label": `Select ${label}`,
|
|
149
|
+
tabIndex: -1
|
|
150
|
+
})
|
|
151
|
+
}),
|
|
152
|
+
columns.map((col) => /* @__PURE__ */ jsx(TableCell, {
|
|
153
|
+
numeric: col.numeric,
|
|
154
|
+
className: col.className,
|
|
155
|
+
children: col.cell(row)
|
|
156
|
+
}, col.id)),
|
|
157
|
+
rowActions && /* @__PURE__ */ jsx(TableCell, {
|
|
158
|
+
className: "w-12 text-right",
|
|
159
|
+
children: rowActions(row)
|
|
160
|
+
})
|
|
161
|
+
]
|
|
162
|
+
}, id);
|
|
163
|
+
});
|
|
164
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
165
|
+
"data-slot": "data-table",
|
|
166
|
+
className: cn("grid min-w-0 gap-3", className),
|
|
167
|
+
...props,
|
|
168
|
+
children: [
|
|
169
|
+
showToolbar && /* @__PURE__ */ jsxs("div", {
|
|
170
|
+
className: "flex flex-wrap items-center gap-2",
|
|
171
|
+
children: [
|
|
172
|
+
onFilterChange && /* @__PURE__ */ jsxs("div", {
|
|
173
|
+
className: "relative w-full max-w-xs min-w-0 flex-1",
|
|
174
|
+
children: [/* @__PURE__ */ jsx(Search, {
|
|
175
|
+
className: "pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground",
|
|
176
|
+
"aria-hidden": true
|
|
177
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
178
|
+
type: "search",
|
|
179
|
+
value: filter ?? "",
|
|
180
|
+
onChange: (e) => onFilterChange(e.target.value),
|
|
181
|
+
placeholder: filterPlaceholder,
|
|
182
|
+
"aria-label": `Search ${ariaLabel.toLowerCase()}`,
|
|
183
|
+
className: "pl-8"
|
|
184
|
+
})]
|
|
185
|
+
}),
|
|
186
|
+
selectable && /* @__PURE__ */ jsx("span", {
|
|
187
|
+
"aria-live": "polite",
|
|
188
|
+
className: "text-xs text-muted-foreground tabular-nums",
|
|
189
|
+
children: selectedSet.size ? `${selectedSet.size} selected` : ""
|
|
190
|
+
}),
|
|
191
|
+
toolbar && /* @__PURE__ */ jsx("div", {
|
|
192
|
+
className: "ml-auto flex flex-wrap items-center gap-2",
|
|
193
|
+
children: toolbar
|
|
194
|
+
})
|
|
195
|
+
]
|
|
196
|
+
}),
|
|
197
|
+
/* @__PURE__ */ jsx("div", {
|
|
198
|
+
ref: frameRef,
|
|
199
|
+
className: "rounded-lg border bg-surface",
|
|
200
|
+
children: /* @__PURE__ */ jsxs(Table, {
|
|
201
|
+
"aria-label": ariaLabel,
|
|
202
|
+
"aria-busy": loading || void 0,
|
|
203
|
+
density,
|
|
204
|
+
stickyHeader,
|
|
205
|
+
className: cn(loading && rows.length > 0 && "opacity-60 transition-opacity"),
|
|
206
|
+
children: [/* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, {
|
|
207
|
+
className: "hover:bg-transparent",
|
|
208
|
+
children: [
|
|
209
|
+
selectable && /* @__PURE__ */ jsx(TableHead, {
|
|
210
|
+
className: "w-10",
|
|
211
|
+
children: /* @__PURE__ */ jsx(Checkbox, {
|
|
212
|
+
checked: allState,
|
|
213
|
+
onCheckedChange: toggleAll,
|
|
214
|
+
disabled: rows.length === 0,
|
|
215
|
+
"aria-label": "Select all rows on this page"
|
|
216
|
+
})
|
|
217
|
+
}),
|
|
218
|
+
columns.map((col) => {
|
|
219
|
+
const sorted = sort?.columnId === col.id ? sort.direction : void 0;
|
|
220
|
+
return /* @__PURE__ */ jsx(TableHead, {
|
|
221
|
+
numeric: col.numeric,
|
|
222
|
+
className: col.className,
|
|
223
|
+
"aria-sort": col.sortable ? sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none" : void 0,
|
|
224
|
+
children: col.sortable && onSortChange ? /* @__PURE__ */ jsxs("button", {
|
|
225
|
+
type: "button",
|
|
226
|
+
onClick: () => onSortChange(nextSort(sort, col.id)),
|
|
227
|
+
className: cn("-mx-1.5 inline-flex h-7 items-center gap-1 rounded-sm px-1.5 uppercase outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/20", sorted && "text-foreground", col.numeric && "flex-row-reverse"),
|
|
228
|
+
children: [col.header, sorted === "asc" ? /* @__PURE__ */ jsx(ArrowUp, {
|
|
229
|
+
className: "size-3.5",
|
|
230
|
+
"aria-hidden": true
|
|
231
|
+
}) : sorted === "desc" ? /* @__PURE__ */ jsx(ArrowDown, {
|
|
232
|
+
className: "size-3.5",
|
|
233
|
+
"aria-hidden": true
|
|
234
|
+
}) : /* @__PURE__ */ jsx(ChevronsUpDown, {
|
|
235
|
+
className: "size-3.5 opacity-50",
|
|
236
|
+
"aria-hidden": true
|
|
237
|
+
})]
|
|
238
|
+
}) : col.header
|
|
239
|
+
}, col.id);
|
|
240
|
+
}),
|
|
241
|
+
rowActions && /* @__PURE__ */ jsx(TableHead, {
|
|
242
|
+
className: "w-12",
|
|
243
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
244
|
+
className: "sr-only",
|
|
245
|
+
children: "Actions"
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
]
|
|
249
|
+
}) }), /* @__PURE__ */ jsx(TableBody, { children: body })]
|
|
250
|
+
})
|
|
251
|
+
}),
|
|
252
|
+
onPageChange && page !== void 0 && pageCount !== void 0 && pageCount > 0 && /* @__PURE__ */ jsx(Pagination, {
|
|
253
|
+
page,
|
|
254
|
+
pageCount,
|
|
255
|
+
onPageChange,
|
|
256
|
+
summary: pageSummary
|
|
257
|
+
})
|
|
258
|
+
]
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
export { DataTable };
|
|
263
|
+
|
|
264
|
+
//# sourceMappingURL=data-table.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"data-table.js","names":[],"sources":["../../src/components/data-table.tsx"],"sourcesContent":["import ArrowDown from '@burtson-labs/icons/react/arrow-down';\nimport ArrowUp from '@burtson-labs/icons/react/arrow-up';\nimport ChevronsUpDown from '@burtson-labs/icons/react/chevrons-up-down';\nimport Search from '@burtson-labs/icons/react/search';\nimport * as React from 'react';\n\nimport { cn } from '../lib/utils';\n\nimport { Button } from './button';\nimport { Checkbox } from './checkbox';\nimport { Input } from './input';\nimport { Pagination } from './pagination';\nimport { Skeleton } from './skeleton';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';\n\nexport interface DataTableColumn<T> {\n id: string;\n header: React.ReactNode;\n cell: (row: T) => React.ReactNode;\n sortable?: boolean;\n /** Right-aligned tabular figures. */\n numeric?: boolean;\n className?: string;\n}\n\nexport type SortDirection = 'asc' | 'desc';\n\nexport interface DataTableSort {\n columnId: string;\n direction: SortDirection;\n}\n\nexport interface DataTableProps<T> extends Omit<React.ComponentProps<'div'>, 'children'> {\n /** Required: what the table lists, e.g. \"Workspace members\". */\n 'aria-label': string;\n columns: DataTableColumn<T>[];\n /** The rows to show now: already sorted, filtered and paged by the app or server. */\n rows: T[];\n getRowId: (row: T) => string;\n /** Names a row for screen readers (\"Select Ada Lovelace\"). Defaults to its id. */\n getRowLabel?: (row: T) => string;\n\n /** Controlled sort. A sortable header cycles ascending, descending, none. */\n sort?: DataTableSort | null;\n onSortChange?: (sort: DataTableSort | null) => void;\n\n /** Controlled filter text; a search box appears when onFilterChange is set. */\n filter?: string;\n onFilterChange?: (value: string) => void;\n filterPlaceholder?: string;\n\n /** Controlled selection by row id; checkboxes appear when onSelectedChange is set. */\n selected?: string[];\n onSelectedChange?: (ids: string[]) => void;\n\n /** Enter or a click on the row, e.g. open the record. */\n onRowAction?: (row: T) => void;\n /** Per-row controls in a trailing column, e.g. a DropdownMenu. */\n rowActions?: (row: T) => React.ReactNode;\n\n /** 1-based; Pagination appears when onPageChange is set. */\n page?: number;\n pageCount?: number;\n onPageChange?: (page: number) => void;\n /** e.g. \"1–25 of 312\". */\n pageSummary?: React.ReactNode;\n\n loading?: boolean;\n /** A message shown in place of the rows. */\n error?: React.ReactNode;\n onRetry?: () => void;\n /** Shown when there are no rows and nothing failed. */\n empty?: React.ReactNode;\n /** Extra controls beside the search box, e.g. filters or a bulk action. */\n toolbar?: React.ReactNode;\n density?: 'compact' | 'default';\n stickyHeader?: boolean;\n}\n\nconst nextSort = (current: DataTableSort | null | undefined, id: string): DataTableSort | null => {\n if (!current || current.columnId !== id) return { columnId: id, direction: 'asc' };\n return current.direction === 'asc' ? { columnId: id, direction: 'desc' } : null;\n};\n\n// Clicks and keys aimed at these belong to them, not to the row.\nconst INTERACTIVE = 'a,button,input,select,textarea,[role=\"checkbox\"],[role=\"menuitem\"]';\n\n/**\n * A table recipe for records: sort, search, select, page, and row actions,\n * with loading, empty and error states. Fully controlled, so the data can\n * come from a server; the table never reorders rows itself.\n */\nfunction DataTable<T>({\n 'aria-label': ariaLabel,\n columns,\n rows,\n getRowId,\n getRowLabel,\n sort,\n onSortChange,\n filter,\n onFilterChange,\n filterPlaceholder = 'Search',\n selected,\n onSelectedChange,\n onRowAction,\n rowActions,\n page,\n pageCount,\n onPageChange,\n pageSummary,\n loading = false,\n error,\n onRetry,\n empty = 'No results.',\n toolbar,\n density = 'default',\n stickyHeader,\n className,\n ...props\n}: DataTableProps<T>) {\n const selectable = Boolean(onSelectedChange);\n const selectedSet = React.useMemo(() => new Set(selected ?? []), [selected]);\n const ids = rows.map(getRowId);\n const pageSelected = ids.filter((id) => selectedSet.has(id)).length;\n const allState: boolean | 'indeterminate' =\n ids.length > 0 && pageSelected === ids.length\n ? true\n : pageSelected > 0\n ? 'indeterminate'\n : false;\n const colCount = columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0);\n const interactive = Boolean(onRowAction) || selectable;\n\n // One tab stop in the body: the active row. Arrows move it.\n const [active, setActive] = React.useState(0);\n const frameRef = React.useRef<HTMLDivElement>(null);\n // Rows can shrink under the active index (a new page, a filter).\n const activeRow = Math.min(active, Math.max(0, rows.length - 1));\n\n const toggle = (id: string) => {\n if (!onSelectedChange) return;\n const next = new Set(selectedSet);\n if (next.has(id)) next.delete(id);\n else next.add(id);\n onSelectedChange([...next]);\n };\n const toggleAll = () => {\n if (!onSelectedChange) return;\n const next = new Set(selectedSet);\n if (allState === true) ids.forEach((id) => next.delete(id));\n else ids.forEach((id) => next.add(id));\n onSelectedChange([...next]);\n };\n const focusRow = (i: number) => {\n const clamped = Math.min(Math.max(i, 0), rows.length - 1);\n setActive(clamped);\n frameRef.current\n ?.querySelectorAll<HTMLElement>('[data-slot=\"table-body\"] > [data-slot=\"table-row\"]')\n [clamped]?.focus();\n };\n\n const onRowKeyDown = (event: React.KeyboardEvent<HTMLTableRowElement>, i: number, row: T) => {\n if (event.target !== event.currentTarget) return;\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n focusRow(i + 1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n focusRow(i - 1);\n break;\n case 'Home':\n event.preventDefault();\n focusRow(0);\n break;\n case 'End':\n event.preventDefault();\n focusRow(rows.length - 1);\n break;\n case 'Enter':\n if (onRowAction) {\n event.preventDefault();\n onRowAction(row);\n }\n break;\n case ' ':\n if (selectable) {\n event.preventDefault();\n toggle(getRowId(row));\n }\n break;\n }\n };\n\n const showToolbar = Boolean(onFilterChange || toolbar || (selectable && selectedSet.size));\n let body: React.ReactNode;\n if (error) {\n body = (\n <TableRow className=\"hover:bg-transparent\">\n <TableCell colSpan={colCount} className=\"h-auto py-8 text-center whitespace-normal\">\n <div role=\"alert\" className=\"grid justify-items-center gap-3 text-sm\">\n <span className=\"text-destructive\">{error}</span>\n {onRetry && (\n <Button type=\"button\" size=\"sm\" variant=\"outline\" onClick={onRetry}>\n Try again\n </Button>\n )}\n </div>\n </TableCell>\n </TableRow>\n );\n } else if (loading && rows.length === 0) {\n body = Array.from({ length: 3 }, (_, r) => (\n <TableRow key={`loading-${r}`} className=\"hover:bg-transparent\" aria-hidden>\n {Array.from({ length: colCount }, (_, c) => (\n <TableCell key={c}>\n <Skeleton className=\"h-3.5 w-full max-w-40\" />\n </TableCell>\n ))}\n </TableRow>\n ));\n } else if (rows.length === 0) {\n body = (\n <TableRow className=\"hover:bg-transparent\">\n <TableCell\n colSpan={colCount}\n className=\"h-auto py-10 text-center whitespace-normal text-muted-foreground\"\n >\n {empty}\n </TableCell>\n </TableRow>\n );\n } else {\n body = rows.map((row, i) => {\n const id = getRowId(row);\n const isSelected = selectedSet.has(id);\n const label = getRowLabel?.(row) ?? id;\n return (\n <TableRow\n key={id}\n selected={isSelected}\n tabIndex={interactive ? (i === activeRow ? 0 : -1) : undefined}\n onFocus={() => setActive(i)}\n onKeyDown={interactive ? (e) => onRowKeyDown(e, i, row) : undefined}\n onClick={\n onRowAction\n ? (e) => {\n if ((e.target as HTMLElement).closest(INTERACTIVE)) return;\n onRowAction(row);\n }\n : undefined\n }\n className={cn(\n interactive &&\n 'outline-none focus-visible:bg-muted/60 focus-visible:shadow-[inset_2px_0_0_var(--ring)]',\n onRowAction && 'cursor-pointer',\n )}\n >\n {selectable && (\n <TableCell className=\"w-10\">\n <Checkbox\n checked={isSelected}\n onCheckedChange={() => toggle(id)}\n aria-label={`Select ${label}`}\n tabIndex={-1}\n />\n </TableCell>\n )}\n {columns.map((col) => (\n <TableCell key={col.id} numeric={col.numeric} className={col.className}>\n {col.cell(row)}\n </TableCell>\n ))}\n {rowActions && <TableCell className=\"w-12 text-right\">{rowActions(row)}</TableCell>}\n </TableRow>\n );\n });\n }\n\n return (\n <div data-slot=\"data-table\" className={cn('grid min-w-0 gap-3', className)} {...props}>\n {showToolbar && (\n <div className=\"flex flex-wrap items-center gap-2\">\n {onFilterChange && (\n <div className=\"relative w-full max-w-xs min-w-0 flex-1\">\n <Search\n className=\"pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground\"\n aria-hidden\n />\n <Input\n type=\"search\"\n value={filter ?? ''}\n onChange={(e) => onFilterChange(e.target.value)}\n placeholder={filterPlaceholder}\n aria-label={`Search ${ariaLabel.toLowerCase()}`}\n className=\"pl-8\"\n />\n </div>\n )}\n {selectable && (\n <span aria-live=\"polite\" className=\"text-xs text-muted-foreground tabular-nums\">\n {selectedSet.size ? `${selectedSet.size} selected` : ''}\n </span>\n )}\n {toolbar && <div className=\"ml-auto flex flex-wrap items-center gap-2\">{toolbar}</div>}\n </div>\n )}\n <div ref={frameRef} className=\"rounded-lg border bg-surface\">\n <Table\n aria-label={ariaLabel}\n aria-busy={loading || undefined}\n density={density}\n stickyHeader={stickyHeader}\n className={cn(loading && rows.length > 0 && 'opacity-60 transition-opacity')}\n >\n <TableHeader>\n <TableRow className=\"hover:bg-transparent\">\n {selectable && (\n <TableHead className=\"w-10\">\n <Checkbox\n checked={allState}\n onCheckedChange={toggleAll}\n disabled={rows.length === 0}\n aria-label=\"Select all rows on this page\"\n />\n </TableHead>\n )}\n {columns.map((col) => {\n const sorted = sort?.columnId === col.id ? sort.direction : undefined;\n return (\n <TableHead\n key={col.id}\n numeric={col.numeric}\n className={col.className}\n aria-sort={\n col.sortable\n ? sorted === 'asc'\n ? 'ascending'\n : sorted === 'desc'\n ? 'descending'\n : 'none'\n : undefined\n }\n >\n {col.sortable && onSortChange ? (\n <button\n type=\"button\"\n onClick={() => onSortChange(nextSort(sort, col.id))}\n className={cn(\n '-mx-1.5 inline-flex h-7 items-center gap-1 rounded-sm px-1.5 uppercase outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/20',\n sorted && 'text-foreground',\n col.numeric && 'flex-row-reverse',\n )}\n >\n {col.header}\n {sorted === 'asc' ? (\n <ArrowUp className=\"size-3.5\" aria-hidden />\n ) : sorted === 'desc' ? (\n <ArrowDown className=\"size-3.5\" aria-hidden />\n ) : (\n <ChevronsUpDown className=\"size-3.5 opacity-50\" aria-hidden />\n )}\n </button>\n ) : (\n col.header\n )}\n </TableHead>\n );\n })}\n {rowActions && (\n <TableHead className=\"w-12\">\n <span className=\"sr-only\">Actions</span>\n </TableHead>\n )}\n </TableRow>\n </TableHeader>\n <TableBody>{body}</TableBody>\n </Table>\n </div>\n {onPageChange && page !== undefined && pageCount !== undefined && pageCount > 0 && (\n <Pagination\n page={page}\n pageCount={pageCount}\n onPageChange={onPageChange}\n summary={pageSummary}\n />\n )}\n </div>\n );\n}\n\nexport { DataTable };\n"],"mappings":"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"}
|
|
@@ -24,7 +24,11 @@ export interface EditorTabsProps extends Omit<React.ComponentProps<'div'>, 'chil
|
|
|
24
24
|
* tab, ask the person (save, discard, cancel) and then update `tabs`.
|
|
25
25
|
*/
|
|
26
26
|
onClose?: (id: string) => void;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Reorder by drag or Ctrl+Shift+PageUp/PageDown. `toIndex` is the new position.
|
|
29
|
+
* Dragging uses pointer events, not HTML5 drag and drop, so it also works in
|
|
30
|
+
* desktop webviews (Tauri, Electron) that intercept native drags.
|
|
31
|
+
*/
|
|
28
32
|
onMove?: (id: string, toIndex: number) => void;
|
|
29
33
|
/** Required: e.g. "Open editors". */
|
|
30
34
|
'aria-label': string;
|
|
@@ -15,6 +15,10 @@ function EditorTabs({ tabs, activeId, onActiveChange, onClose, onMove, actions,
|
|
|
15
15
|
const listRef = React.useRef(null);
|
|
16
16
|
const [focusedId, setFocusedId] = React.useState(null);
|
|
17
17
|
const [dragId, setDragId] = React.useState(null);
|
|
18
|
+
const [dropGap, setDropGap] = React.useState(null);
|
|
19
|
+
const dragged = React.useRef(false);
|
|
20
|
+
const dragCleanup = React.useRef(null);
|
|
21
|
+
React.useEffect(() => () => dragCleanup.current?.(), []);
|
|
18
22
|
const lastIds = React.useRef([]);
|
|
19
23
|
const hadFocus = React.useRef(false);
|
|
20
24
|
const ids = tabs.map((t) => t.id);
|
|
@@ -39,6 +43,51 @@ function EditorTabs({ tabs, activeId, onActiveChange, onClose, onMove, actions,
|
|
|
39
43
|
idsKey,
|
|
40
44
|
activeId
|
|
41
45
|
]);
|
|
46
|
+
/** Which gap the pointer is over, measured from the rendered tabs. */
|
|
47
|
+
const gapAt = (clientX) => {
|
|
48
|
+
const els = Array.from(listRef.current?.querySelectorAll("[data-slot=\"editor-tab\"]") ?? []);
|
|
49
|
+
const rtl = listRef.current ? getComputedStyle(listRef.current).direction === "rtl" : false;
|
|
50
|
+
for (let i = 0; i < els.length; i++) {
|
|
51
|
+
const rect = els[i].getBoundingClientRect();
|
|
52
|
+
const mid = rect.left + rect.width / 2;
|
|
53
|
+
if (rtl ? clientX > mid : clientX < mid) return i;
|
|
54
|
+
}
|
|
55
|
+
return els.length;
|
|
56
|
+
};
|
|
57
|
+
const startDrag = (event, id) => {
|
|
58
|
+
if (!onMove || event.button !== 0) return;
|
|
59
|
+
dragCleanup.current?.();
|
|
60
|
+
dragged.current = false;
|
|
61
|
+
const downX = event.clientX;
|
|
62
|
+
const move = (e) => {
|
|
63
|
+
if (!dragged.current && Math.abs(e.clientX - downX) < 4) return;
|
|
64
|
+
if (!dragged.current) {
|
|
65
|
+
dragged.current = true;
|
|
66
|
+
setDragId(id);
|
|
67
|
+
}
|
|
68
|
+
setDropGap(gapAt(e.clientX));
|
|
69
|
+
};
|
|
70
|
+
const stop = (e) => {
|
|
71
|
+
window.removeEventListener("pointermove", move);
|
|
72
|
+
window.removeEventListener("pointerup", up);
|
|
73
|
+
window.removeEventListener("pointercancel", cancel);
|
|
74
|
+
dragCleanup.current = null;
|
|
75
|
+
if (e && dragged.current) {
|
|
76
|
+
const from = lastIds.current.indexOf(id);
|
|
77
|
+
const gap = gapAt(e.clientX);
|
|
78
|
+
const to = gap > from ? gap - 1 : gap;
|
|
79
|
+
if (from !== -1 && to !== from) onMove(id, to);
|
|
80
|
+
}
|
|
81
|
+
setDragId(null);
|
|
82
|
+
setDropGap(null);
|
|
83
|
+
};
|
|
84
|
+
const up = (e) => stop(e);
|
|
85
|
+
const cancel = () => stop(null);
|
|
86
|
+
window.addEventListener("pointermove", move);
|
|
87
|
+
window.addEventListener("pointerup", up);
|
|
88
|
+
window.addEventListener("pointercancel", cancel);
|
|
89
|
+
dragCleanup.current = () => stop(null);
|
|
90
|
+
};
|
|
42
91
|
const focusTab = (id) => {
|
|
43
92
|
if (!id) return;
|
|
44
93
|
setFocusedId(id);
|
|
@@ -82,20 +131,14 @@ function EditorTabs({ tabs, activeId, onActiveChange, onClose, onMove, actions,
|
|
|
82
131
|
"data-state": active ? "active" : "inactive",
|
|
83
132
|
"data-dirty": tab.dirty || void 0,
|
|
84
133
|
"data-dragging": dragId === tab.id || void 0,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (dragId) event.preventDefault();
|
|
94
|
-
},
|
|
95
|
-
onDrop: (event) => {
|
|
96
|
-
event.preventDefault();
|
|
97
|
-
if (dragId && dragId !== tab.id) onMove?.(dragId, i);
|
|
98
|
-
setDragId(null);
|
|
134
|
+
"data-drop": dragId === null ? void 0 : dropGap === i ? "before" : dropGap === tabs.length && i === tabs.length - 1 ? "after" : void 0,
|
|
135
|
+
onPointerDown: (event) => startDrag(event, tab.id),
|
|
136
|
+
onClickCapture: (event) => {
|
|
137
|
+
if (dragged.current) {
|
|
138
|
+
dragged.current = false;
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
}
|
|
99
142
|
},
|
|
100
143
|
onAuxClick: (event) => {
|
|
101
144
|
if (event.button === 1 && closeable) {
|
|
@@ -104,7 +147,7 @@ function EditorTabs({ tabs, activeId, onActiveChange, onClose, onMove, actions,
|
|
|
104
147
|
}
|
|
105
148
|
},
|
|
106
149
|
onContextMenu: onTabContextMenu ? (event) => onTabContextMenu(tab.id, event) : void 0,
|
|
107
|
-
className: cn("group/tab relative flex max-w-60 shrink-0 items-center border-e text-[13px] text-muted-foreground", "hover:bg-muted/60 data-[state=active]:bg-background data-[state=active]:text-foreground", "data-[state=active]:before:absolute data-[state=active]:before:inset-x-0 data-[state=active]:before:top-0 data-[state=active]:before:h-0.5 data-[state=active]:before:bg-brand data-[state=active]:before:content-[\"\"]", "data-[dragging]:opacity-50"),
|
|
150
|
+
className: cn("group/tab relative flex max-w-60 shrink-0 items-center border-e text-[13px] text-muted-foreground", "hover:bg-muted/60 data-[state=active]:bg-background data-[state=active]:text-foreground", "data-[state=active]:before:absolute data-[state=active]:before:inset-x-0 data-[state=active]:before:top-0 data-[state=active]:before:h-0.5 data-[state=active]:before:bg-brand data-[state=active]:before:content-[\"\"]", "data-[dragging]:opacity-50 data-[dragging]:select-none", "data-[drop=before]:shadow-[inset_2px_0_0_var(--brand)] data-[drop=after]:shadow-[inset_-2px_0_0_var(--brand)]"),
|
|
108
151
|
children: [
|
|
109
152
|
/* @__PURE__ */ jsxs("button", {
|
|
110
153
|
ref: (el) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"editor-tabs.js","names":[],"sources":["../../src/components/editor-tabs.tsx"],"sourcesContent":["import X from '@burtson-labs/icons/react/x';\nimport * as React from 'react';\n\nimport { cn } from '../lib/utils';\n\nexport interface EditorTab {\n /** Stable identity, usually the file path. */\n id: string;\n label: string;\n /** Full path or context; the tooltip and the accessible description. */\n description?: string;\n icon?: React.ReactNode;\n /** Unsaved changes: a dot replaces the close button until hover. */\n dirty?: boolean;\n /** Default true. */\n closeable?: boolean;\n /** A preview tab that the next opened file replaces; shown in italics. */\n preview?: boolean;\n /** The id of the panel this tab shows, for aria-controls. */\n controls?: string;\n}\n\nexport interface EditorTabsProps extends Omit<\n React.ComponentProps<'div'>,\n 'children' | 'aria-label'\n> {\n tabs: EditorTab[];\n activeId: string | null;\n onActiveChange: (id: string) => void;\n /**\n * A request to close. The component never removes a tab itself: for a dirty\n * tab, ask the person (save, discard, cancel) and then update `tabs`.\n */\n onClose?: (id: string) => void;\n /** Reorder by drag or Ctrl+Shift+PageUp/PageDown. `toIndex` is the new position. */\n onMove?: (id: string, toIndex: number) => void;\n /** Required: e.g. \"Open editors\". */\n 'aria-label': string;\n /** Right-hand slot for split, more and similar buttons. */\n actions?: React.ReactNode;\n /** Right-click on a tab. */\n onTabContextMenu?: (id: string, event: React.MouseEvent) => void;\n}\n\n/**\n * The strip of open files above an editor. One tab stop; Left/Right/Home/End\n * move focus, Enter or Space opens, Delete asks to close, Ctrl+Shift+PageUp\n * and PageDown move the tab. Middle-click closes. The strip scrolls when the\n * tabs overflow and keeps the active tab in view.\n */\nfunction EditorTabs({\n tabs,\n activeId,\n onActiveChange,\n onClose,\n onMove,\n actions,\n onTabContextMenu,\n className,\n 'aria-label': ariaLabel,\n ...props\n}: EditorTabsProps) {\n const baseId = React.useId();\n const tabRefs = React.useRef(new Map<string, HTMLButtonElement>());\n const listRef = React.useRef<HTMLDivElement>(null);\n const [focusedId, setFocusedId] = React.useState<string | null>(null);\n const [dragId, setDragId] = React.useState<string | null>(null);\n const lastIds = React.useRef<string[]>([]);\n const hadFocus = React.useRef(false);\n\n const ids = tabs.map((t) => t.id);\n const idsKey = ids.join('\\u0000');\n const tabStop =\n (focusedId && ids.includes(focusedId) ? focusedId : null) ??\n (activeId && ids.includes(activeId) ? activeId : null) ??\n ids[0] ??\n null;\n\n React.useEffect(() => {\n if (activeId)\n tabRefs.current.get(activeId)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });\n }, [activeId]);\n\n // After a close, keep keyboard focus in the strip: the active tab if the\n // app picked one, else the neighbour at the closed tab's position.\n React.useLayoutEffect(() => {\n if (focusedId && !ids.includes(focusedId)) {\n const next =\n (activeId && ids.includes(activeId) ? activeId : null) ??\n ids[Math.min(Math.max(0, lastIds.current.indexOf(focusedId)), ids.length - 1)] ??\n null;\n setFocusedId(next);\n const active = document.activeElement;\n if (next && hadFocus.current && (!active || active === document.body))\n tabRefs.current.get(next)?.focus();\n }\n lastIds.current = ids;\n // ids is derived from tabs each render; idsKey is its stable form.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [focusedId, idsKey, activeId]);\n\n const focusTab = (id: string | undefined) => {\n if (!id) return;\n setFocusedId(id);\n tabRefs.current.get(id)?.focus();\n };\n\n const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, tab: EditorTab) => {\n const i = ids.indexOf(tab.id);\n const rtl = listRef.current ? getComputedStyle(listRef.current).direction === 'rtl' : false;\n const [prev, next] = rtl ? ['ArrowRight', 'ArrowLeft'] : ['ArrowLeft', 'ArrowRight'];\n let handled = true;\n if (event.ctrlKey && event.shiftKey && (event.key === 'PageUp' || event.key === 'PageDown')) {\n const to = event.key === 'PageUp' ? i - 1 : i + 1;\n if (onMove && to >= 0 && to < ids.length) onMove(tab.id, to);\n } else if (event.key === prev) focusTab(ids[(i - 1 + ids.length) % ids.length]);\n else if (event.key === next) focusTab(ids[(i + 1) % ids.length]);\n else if (event.key === 'Home') focusTab(ids[0]);\n else if (event.key === 'End') focusTab(ids[ids.length - 1]);\n else if (event.key === 'Delete' && tab.closeable !== false && onClose) onClose(tab.id);\n else handled = false;\n if (handled) event.preventDefault();\n };\n\n return (\n <div\n data-slot=\"editor-tabs\"\n className={cn('flex h-9 min-w-0 items-stretch border-b bg-surface-muted', className)}\n {...props}\n >\n <div\n ref={listRef}\n role=\"tablist\"\n aria-label={ariaLabel}\n aria-orientation=\"horizontal\"\n className=\"flex min-w-0 flex-1 items-stretch overflow-x-auto [scrollbar-width:thin]\"\n onFocus={() => (hadFocus.current = true)}\n onBlur={(event) => {\n if (!listRef.current?.contains(event.relatedTarget)) hadFocus.current = false;\n }}\n >\n {tabs.map((tab, i) => {\n const active = tab.id === activeId;\n const closeable = tab.closeable !== false && Boolean(onClose);\n const tabId = `${baseId}-tab-${i}`;\n return (\n <div\n key={tab.id}\n data-slot=\"editor-tab\"\n data-state={active ? 'active' : 'inactive'}\n data-dirty={tab.dirty || undefined}\n data-dragging={dragId === tab.id || undefined}\n draggable={Boolean(onMove)}\n onDragStart={(event) => {\n setDragId(tab.id);\n event.dataTransfer.effectAllowed = 'move';\n event.dataTransfer.setData('text/plain', tab.id);\n }}\n onDragEnd={() => setDragId(null)}\n onDragOver={(event) => {\n if (dragId) event.preventDefault();\n }}\n onDrop={(event) => {\n event.preventDefault();\n if (dragId && dragId !== tab.id) onMove?.(dragId, i);\n setDragId(null);\n }}\n onAuxClick={(event) => {\n if (event.button === 1 && closeable) {\n event.preventDefault();\n onClose?.(tab.id);\n }\n }}\n onContextMenu={\n onTabContextMenu ? (event) => onTabContextMenu(tab.id, event) : undefined\n }\n className={cn(\n 'group/tab relative flex max-w-60 shrink-0 items-center border-e text-[13px] text-muted-foreground',\n 'hover:bg-muted/60 data-[state=active]:bg-background data-[state=active]:text-foreground',\n // The active tab's top edge carries the brand colour.\n 'data-[state=active]:before:absolute data-[state=active]:before:inset-x-0 data-[state=active]:before:top-0 data-[state=active]:before:h-0.5 data-[state=active]:before:bg-brand data-[state=active]:before:content-[\"\"]',\n 'data-[dragging]:opacity-50',\n )}\n >\n <button\n ref={(el) => {\n if (el) tabRefs.current.set(tab.id, el);\n else tabRefs.current.delete(tab.id);\n }}\n type=\"button\"\n role=\"tab\"\n id={tabId}\n aria-selected={active}\n aria-controls={tab.controls}\n aria-describedby={tab.description ? `${tabId}-desc` : undefined}\n title={tab.description}\n tabIndex={tab.id === tabStop ? 0 : -1}\n onFocus={() => setFocusedId(tab.id)}\n onClick={() => onActiveChange(tab.id)}\n onKeyDown={(event) => onKeyDown(event, tab)}\n className={cn(\n 'flex h-full min-w-0 items-center gap-1.5 ps-3 outline-none focus-visible:shadow-[inset_0_0_0_1px_var(--ring)]',\n closeable ? 'pe-1' : 'pe-3',\n )}\n >\n {tab.icon && (\n <span aria-hidden className=\"flex shrink-0 [&_svg]:size-4\">\n {tab.icon}\n </span>\n )}\n <span className={cn('truncate', tab.preview && 'italic')}>{tab.label}</span>\n {tab.dirty && <span className=\"sr-only\">, unsaved changes</span>}\n </button>\n {tab.description && (\n <span id={`${tabId}-desc`} hidden>\n {tab.description}\n </span>\n )}\n {closeable ? (\n <button\n type=\"button\"\n tabIndex={-1}\n aria-label={`Close ${tab.label}`}\n title=\"Close (Delete)\"\n onClick={() => onClose?.(tab.id)}\n className=\"me-1 flex size-5 shrink-0 items-center justify-center rounded-xs text-muted-foreground outline-none hover:bg-muted hover:text-foreground\"\n >\n {tab.dirty && (\n <span\n aria-hidden\n className=\"size-2 rounded-full bg-foreground/70 group-hover/tab:hidden\"\n />\n )}\n <X\n aria-hidden\n className={cn(\n 'size-3.5',\n tab.dirty\n ? 'hidden group-hover/tab:block'\n : 'opacity-0 group-hover/tab:opacity-100 group-data-[state=active]/tab:opacity-100 group-focus-within/tab:opacity-100',\n )}\n />\n </button>\n ) : tab.dirty ? (\n <span aria-hidden className=\"me-3 size-2 shrink-0 rounded-full bg-foreground/70\" />\n ) : null}\n </div>\n );\n })}\n </div>\n {actions && (\n <div data-slot=\"editor-tabs-actions\" className=\"flex shrink-0 items-center gap-0.5 px-1\">\n {actions}\n </div>\n )}\n </div>\n );\n}\n\nexport { EditorTabs };\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"editor-tabs.js","names":[],"sources":["../../src/components/editor-tabs.tsx"],"sourcesContent":["import X from '@burtson-labs/icons/react/x';\nimport * as React from 'react';\n\nimport { cn } from '../lib/utils';\n\nexport interface EditorTab {\n /** Stable identity, usually the file path. */\n id: string;\n label: string;\n /** Full path or context; the tooltip and the accessible description. */\n description?: string;\n icon?: React.ReactNode;\n /** Unsaved changes: a dot replaces the close button until hover. */\n dirty?: boolean;\n /** Default true. */\n closeable?: boolean;\n /** A preview tab that the next opened file replaces; shown in italics. */\n preview?: boolean;\n /** The id of the panel this tab shows, for aria-controls. */\n controls?: string;\n}\n\nexport interface EditorTabsProps extends Omit<\n React.ComponentProps<'div'>,\n 'children' | 'aria-label'\n> {\n tabs: EditorTab[];\n activeId: string | null;\n onActiveChange: (id: string) => void;\n /**\n * A request to close. The component never removes a tab itself: for a dirty\n * tab, ask the person (save, discard, cancel) and then update `tabs`.\n */\n onClose?: (id: string) => void;\n /**\n * Reorder by drag or Ctrl+Shift+PageUp/PageDown. `toIndex` is the new position.\n * Dragging uses pointer events, not HTML5 drag and drop, so it also works in\n * desktop webviews (Tauri, Electron) that intercept native drags.\n */\n onMove?: (id: string, toIndex: number) => void;\n /** Required: e.g. \"Open editors\". */\n 'aria-label': string;\n /** Right-hand slot for split, more and similar buttons. */\n actions?: React.ReactNode;\n /** Right-click on a tab. */\n onTabContextMenu?: (id: string, event: React.MouseEvent) => void;\n}\n\n/**\n * The strip of open files above an editor. One tab stop; Left/Right/Home/End\n * move focus, Enter or Space opens, Delete asks to close, Ctrl+Shift+PageUp\n * and PageDown move the tab. Middle-click closes. The strip scrolls when the\n * tabs overflow and keeps the active tab in view.\n */\nfunction EditorTabs({\n tabs,\n activeId,\n onActiveChange,\n onClose,\n onMove,\n actions,\n onTabContextMenu,\n className,\n 'aria-label': ariaLabel,\n ...props\n}: EditorTabsProps) {\n const baseId = React.useId();\n const tabRefs = React.useRef(new Map<string, HTMLButtonElement>());\n const listRef = React.useRef<HTMLDivElement>(null);\n const [focusedId, setFocusedId] = React.useState<string | null>(null);\n const [dragId, setDragId] = React.useState<string | null>(null);\n // The gap a dragged tab would land in: 0 is before the first tab,\n // tabs.length after the last.\n const [dropGap, setDropGap] = React.useState<number | null>(null);\n // Set once a press becomes a drag, so the click that ends it does nothing.\n const dragged = React.useRef(false);\n const dragCleanup = React.useRef<(() => void) | null>(null);\n React.useEffect(() => () => dragCleanup.current?.(), []);\n const lastIds = React.useRef<string[]>([]);\n const hadFocus = React.useRef(false);\n\n const ids = tabs.map((t) => t.id);\n const idsKey = ids.join('\\u0000');\n const tabStop =\n (focusedId && ids.includes(focusedId) ? focusedId : null) ??\n (activeId && ids.includes(activeId) ? activeId : null) ??\n ids[0] ??\n null;\n\n React.useEffect(() => {\n if (activeId)\n tabRefs.current.get(activeId)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });\n }, [activeId]);\n\n // After a close, keep keyboard focus in the strip: the active tab if the\n // app picked one, else the neighbour at the closed tab's position.\n React.useLayoutEffect(() => {\n if (focusedId && !ids.includes(focusedId)) {\n const next =\n (activeId && ids.includes(activeId) ? activeId : null) ??\n ids[Math.min(Math.max(0, lastIds.current.indexOf(focusedId)), ids.length - 1)] ??\n null;\n setFocusedId(next);\n const active = document.activeElement;\n if (next && hadFocus.current && (!active || active === document.body))\n tabRefs.current.get(next)?.focus();\n }\n lastIds.current = ids;\n // ids is derived from tabs each render; idsKey is its stable form.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [focusedId, idsKey, activeId]);\n\n /** Which gap the pointer is over, measured from the rendered tabs. */\n const gapAt = (clientX: number) => {\n const els = Array.from(\n listRef.current?.querySelectorAll<HTMLElement>('[data-slot=\"editor-tab\"]') ?? [],\n );\n const rtl = listRef.current ? getComputedStyle(listRef.current).direction === 'rtl' : false;\n for (let i = 0; i < els.length; i++) {\n const rect = els[i]!.getBoundingClientRect();\n const mid = rect.left + rect.width / 2;\n if (rtl ? clientX > mid : clientX < mid) return i;\n }\n return els.length;\n };\n\n const startDrag = (event: React.PointerEvent, id: string) => {\n if (!onMove || event.button !== 0) return;\n dragCleanup.current?.();\n dragged.current = false;\n const downX = event.clientX;\n const move = (e: PointerEvent) => {\n // A few pixels of slop so an ordinary click is not read as a drag.\n if (!dragged.current && Math.abs(e.clientX - downX) < 4) return;\n if (!dragged.current) {\n dragged.current = true;\n setDragId(id);\n }\n setDropGap(gapAt(e.clientX));\n };\n const stop = (e: PointerEvent | null) => {\n window.removeEventListener('pointermove', move);\n window.removeEventListener('pointerup', up);\n window.removeEventListener('pointercancel', cancel);\n dragCleanup.current = null;\n if (e && dragged.current) {\n const from = lastIds.current.indexOf(id);\n const gap = gapAt(e.clientX);\n const to = gap > from ? gap - 1 : gap;\n if (from !== -1 && to !== from) onMove(id, to);\n }\n setDragId(null);\n setDropGap(null);\n };\n const up = (e: PointerEvent) => stop(e);\n const cancel = () => stop(null);\n window.addEventListener('pointermove', move);\n window.addEventListener('pointerup', up);\n window.addEventListener('pointercancel', cancel);\n dragCleanup.current = () => stop(null);\n };\n\n const focusTab = (id: string | undefined) => {\n if (!id) return;\n setFocusedId(id);\n tabRefs.current.get(id)?.focus();\n };\n\n const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, tab: EditorTab) => {\n const i = ids.indexOf(tab.id);\n const rtl = listRef.current ? getComputedStyle(listRef.current).direction === 'rtl' : false;\n const [prev, next] = rtl ? ['ArrowRight', 'ArrowLeft'] : ['ArrowLeft', 'ArrowRight'];\n let handled = true;\n if (event.ctrlKey && event.shiftKey && (event.key === 'PageUp' || event.key === 'PageDown')) {\n const to = event.key === 'PageUp' ? i - 1 : i + 1;\n if (onMove && to >= 0 && to < ids.length) onMove(tab.id, to);\n } else if (event.key === prev) focusTab(ids[(i - 1 + ids.length) % ids.length]);\n else if (event.key === next) focusTab(ids[(i + 1) % ids.length]);\n else if (event.key === 'Home') focusTab(ids[0]);\n else if (event.key === 'End') focusTab(ids[ids.length - 1]);\n else if (event.key === 'Delete' && tab.closeable !== false && onClose) onClose(tab.id);\n else handled = false;\n if (handled) event.preventDefault();\n };\n\n return (\n <div\n data-slot=\"editor-tabs\"\n className={cn('flex h-9 min-w-0 items-stretch border-b bg-surface-muted', className)}\n {...props}\n >\n <div\n ref={listRef}\n role=\"tablist\"\n aria-label={ariaLabel}\n aria-orientation=\"horizontal\"\n className=\"flex min-w-0 flex-1 items-stretch overflow-x-auto [scrollbar-width:thin]\"\n onFocus={() => (hadFocus.current = true)}\n onBlur={(event) => {\n if (!listRef.current?.contains(event.relatedTarget)) hadFocus.current = false;\n }}\n >\n {tabs.map((tab, i) => {\n const active = tab.id === activeId;\n const closeable = tab.closeable !== false && Boolean(onClose);\n const tabId = `${baseId}-tab-${i}`;\n return (\n <div\n key={tab.id}\n data-slot=\"editor-tab\"\n data-state={active ? 'active' : 'inactive'}\n data-dirty={tab.dirty || undefined}\n data-dragging={dragId === tab.id || undefined}\n data-drop={\n dragId === null\n ? undefined\n : dropGap === i\n ? 'before'\n : dropGap === tabs.length && i === tabs.length - 1\n ? 'after'\n : undefined\n }\n onPointerDown={(event) => startDrag(event, tab.id)}\n onClickCapture={(event) => {\n // Swallow the click that ends a drag.\n if (dragged.current) {\n dragged.current = false;\n event.stopPropagation();\n event.preventDefault();\n }\n }}\n onAuxClick={(event) => {\n if (event.button === 1 && closeable) {\n event.preventDefault();\n onClose?.(tab.id);\n }\n }}\n onContextMenu={\n onTabContextMenu ? (event) => onTabContextMenu(tab.id, event) : undefined\n }\n className={cn(\n 'group/tab relative flex max-w-60 shrink-0 items-center border-e text-[13px] text-muted-foreground',\n 'hover:bg-muted/60 data-[state=active]:bg-background data-[state=active]:text-foreground',\n // The active tab's top edge carries the brand colour.\n 'data-[state=active]:before:absolute data-[state=active]:before:inset-x-0 data-[state=active]:before:top-0 data-[state=active]:before:h-0.5 data-[state=active]:before:bg-brand data-[state=active]:before:content-[\"\"]',\n 'data-[dragging]:opacity-50 data-[dragging]:select-none',\n // Where a dragged tab will land.\n 'data-[drop=before]:shadow-[inset_2px_0_0_var(--brand)] data-[drop=after]:shadow-[inset_-2px_0_0_var(--brand)]',\n )}\n >\n <button\n ref={(el) => {\n if (el) tabRefs.current.set(tab.id, el);\n else tabRefs.current.delete(tab.id);\n }}\n type=\"button\"\n role=\"tab\"\n id={tabId}\n aria-selected={active}\n aria-controls={tab.controls}\n aria-describedby={tab.description ? `${tabId}-desc` : undefined}\n title={tab.description}\n tabIndex={tab.id === tabStop ? 0 : -1}\n onFocus={() => setFocusedId(tab.id)}\n onClick={() => onActiveChange(tab.id)}\n onKeyDown={(event) => onKeyDown(event, tab)}\n className={cn(\n 'flex h-full min-w-0 items-center gap-1.5 ps-3 outline-none focus-visible:shadow-[inset_0_0_0_1px_var(--ring)]',\n closeable ? 'pe-1' : 'pe-3',\n )}\n >\n {tab.icon && (\n <span aria-hidden className=\"flex shrink-0 [&_svg]:size-4\">\n {tab.icon}\n </span>\n )}\n <span className={cn('truncate', tab.preview && 'italic')}>{tab.label}</span>\n {tab.dirty && <span className=\"sr-only\">, unsaved changes</span>}\n </button>\n {tab.description && (\n <span id={`${tabId}-desc`} hidden>\n {tab.description}\n </span>\n )}\n {closeable ? (\n <button\n type=\"button\"\n tabIndex={-1}\n aria-label={`Close ${tab.label}`}\n title=\"Close (Delete)\"\n onClick={() => onClose?.(tab.id)}\n className=\"me-1 flex size-5 shrink-0 items-center justify-center rounded-xs text-muted-foreground outline-none hover:bg-muted hover:text-foreground\"\n >\n {tab.dirty && (\n <span\n aria-hidden\n className=\"size-2 rounded-full bg-foreground/70 group-hover/tab:hidden\"\n />\n )}\n <X\n aria-hidden\n className={cn(\n 'size-3.5',\n tab.dirty\n ? 'hidden group-hover/tab:block'\n : 'opacity-0 group-hover/tab:opacity-100 group-data-[state=active]/tab:opacity-100 group-focus-within/tab:opacity-100',\n )}\n />\n </button>\n ) : tab.dirty ? (\n <span aria-hidden className=\"me-3 size-2 shrink-0 rounded-full bg-foreground/70\" />\n ) : null}\n </div>\n );\n })}\n </div>\n {actions && (\n <div data-slot=\"editor-tabs-actions\" className=\"flex shrink-0 items-center gap-0.5 px-1\">\n {actions}\n </div>\n )}\n </div>\n );\n}\n\nexport { EditorTabs };\n"],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
export interface MobileNavItem {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** An icon element, e.g. <MessageSquare />; sized to 20px. */
|
|
6
|
+
icon: React.ReactNode;
|
|
7
|
+
/** Renders a link; leave out for a button that only calls onValueChange. */
|
|
8
|
+
href?: string;
|
|
9
|
+
/** A count, or true for a dot. */
|
|
10
|
+
badge?: number | boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface MobileNavProps extends Omit<React.ComponentProps<'nav'>, 'onChange'> {
|
|
13
|
+
/** Three to five destinations the app defines. */
|
|
14
|
+
items: MobileNavItem[];
|
|
15
|
+
/** The current destination's id. */
|
|
16
|
+
value: string;
|
|
17
|
+
/**
|
|
18
|
+
* Called on every press. For a link, call `event.preventDefault()` and
|
|
19
|
+
* route yourself to keep a single-page app from reloading.
|
|
20
|
+
*/
|
|
21
|
+
onValueChange?: (id: string, event: React.MouseEvent<HTMLElement>) => void;
|
|
22
|
+
/** `fixed` pins it to the bottom of the viewport. */
|
|
23
|
+
position?: 'fixed' | 'static';
|
|
24
|
+
'aria-label'?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The phone's bottom tab bar: icon over a visible label, 44px+ targets, and
|
|
28
|
+
* room for the home indicator (safe-area insets). Destinations come from the
|
|
29
|
+
* app; don't pad it with empty sections.
|
|
30
|
+
*/
|
|
31
|
+
declare function MobileNav({ items, value, onValueChange, position, className, 'aria-label': ariaLabel, ...props }: MobileNavProps): React.JSX.Element;
|
|
32
|
+
export { MobileNav };
|