@lablup/ui-common 0.1.0-alpha.11 → 0.1.0-alpha.13
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.
|
@@ -115,6 +115,19 @@ export interface DataTableProps<T> {
|
|
|
115
115
|
* with a clickable affordance (cursor: pointer + keyboard binding).
|
|
116
116
|
*/
|
|
117
117
|
onRowClick?: (row: T) => void;
|
|
118
|
+
/**
|
|
119
|
+
* Narrows `onRowClick` to the rows that can actually act on it, for a table
|
|
120
|
+
* that mixes actionable and informational rows. A row this returns `false`
|
|
121
|
+
* for gets no pointer affordance, no tab stop, and no keyboard binding, so it
|
|
122
|
+
* is not announced as a button it cannot be.
|
|
123
|
+
*/
|
|
124
|
+
isRowClickable?: (row: T, index: number) => boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Extra class for one row, for flagging a specific row without forking the
|
|
127
|
+
* table: a transient deep-link highlight, a stale entry, a row being removed.
|
|
128
|
+
* A falsy return adds nothing.
|
|
129
|
+
*/
|
|
130
|
+
rowClassName?: (row: T, index: number) => string | undefined | false | null;
|
|
118
131
|
/**
|
|
119
132
|
* Controlled — the column currently being sorted. Pass `undefined` to
|
|
120
133
|
* indicate no active sort. When this prop is present, the component
|
|
@@ -139,6 +152,6 @@ export interface DataTableProps<T> {
|
|
|
139
152
|
*/
|
|
140
153
|
onSortChange?: (columnId: string | undefined, direction: SortDirection | undefined) => void;
|
|
141
154
|
}
|
|
142
|
-
declare function DataTableInner<T>({ columns, rows, getRowKey, emptyState, loadingState, loading, columnState, onColumnStateChange, className, ariaLabel, testId, onRowClick, sortColumnId: controlledSortColumnId, sortDirection: controlledSortDirection, onSortChange, }: DataTableProps<T>): import("react").JSX.Element;
|
|
155
|
+
declare function DataTableInner<T>({ columns, rows, getRowKey, emptyState, loadingState, loading, columnState, onColumnStateChange, className, ariaLabel, testId, onRowClick, isRowClickable, rowClassName, sortColumnId: controlledSortColumnId, sortDirection: controlledSortDirection, onSortChange, }: DataTableProps<T>): import("react").JSX.Element;
|
|
143
156
|
export declare const DataTable: typeof DataTableInner;
|
|
144
157
|
export default DataTable;
|
|
@@ -90,36 +90,36 @@ function f({ direction: e }) {
|
|
|
90
90
|
})
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
|
-
function p({ columns: e, rows: p, getRowKey: m, emptyState: h, loadingState: g, loading: _ = !1, columnState: v, onColumnStateChange: y, className: b = "", ariaLabel: x = "Data table", testId: S, onRowClick: C,
|
|
94
|
-
let [
|
|
93
|
+
function p({ columns: e, rows: p, getRowKey: m, emptyState: h, loadingState: g, loading: _ = !1, columnState: v, onColumnStateChange: y, className: b = "", ariaLabel: x = "Data table", testId: S, onRowClick: C, isRowClickable: w, rowClassName: T, sortColumnId: E, sortDirection: D, onSortChange: O }) {
|
|
94
|
+
let [k, A] = a(() => v ?? c);
|
|
95
95
|
n(() => {
|
|
96
|
-
v &&
|
|
96
|
+
v && A(v);
|
|
97
97
|
}, [v]);
|
|
98
|
-
let
|
|
98
|
+
let j = E !== void 0 || D !== void 0, [M, N] = a(void 0), [P, F] = a(void 0), I = j ? E : M, L = j ? D : P, R = t((e) => {
|
|
99
99
|
if (!e.sortable) return;
|
|
100
100
|
let t, n;
|
|
101
|
-
|
|
101
|
+
I === e.id ? L === (e.defaultSortDirection ?? "asc") ? (t = e.id, n = L === "asc" ? "desc" : "asc") : (t = void 0, n = void 0) : (t = e.id, n = e.defaultSortDirection ?? "asc"), j || (N(t), F(n)), O?.(t, n);
|
|
102
102
|
}, [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
]),
|
|
108
|
-
let t =
|
|
103
|
+
I,
|
|
104
|
+
L,
|
|
105
|
+
j,
|
|
106
|
+
O
|
|
107
|
+
]), z = r(() => e.filter((e) => e.alwaysVisible ? !0 : k.visibility[e.id] !== !1), [e, k.visibility]), B = t((e) => {
|
|
108
|
+
let t = k.widths[e.id];
|
|
109
109
|
return typeof t == "number" && t > 0 ? t : e.initialWidth;
|
|
110
|
-
}, [
|
|
110
|
+
}, [k.widths]), V = i(null), H = t((e, t, n) => {
|
|
111
111
|
if (t.noResize) return;
|
|
112
|
-
e.preventDefault(), e.stopPropagation(),
|
|
112
|
+
e.preventDefault(), e.stopPropagation(), V.current = {
|
|
113
113
|
columnId: t.id,
|
|
114
114
|
startX: e.clientX,
|
|
115
115
|
startWidth: n,
|
|
116
116
|
minWidth: t.minWidth ?? 80
|
|
117
117
|
};
|
|
118
118
|
let r = (e) => {
|
|
119
|
-
let t =
|
|
119
|
+
let t = V.current;
|
|
120
120
|
if (!t) return;
|
|
121
121
|
let n = e.clientX - t.startX, r = Math.max(t.minWidth, t.startWidth + n);
|
|
122
|
-
|
|
122
|
+
A((e) => ({
|
|
123
123
|
...e,
|
|
124
124
|
widths: {
|
|
125
125
|
...e.widths,
|
|
@@ -127,25 +127,25 @@ function p({ columns: e, rows: p, getRowKey: m, emptyState: h, loadingState: g,
|
|
|
127
127
|
}
|
|
128
128
|
}));
|
|
129
129
|
}, i = () => {
|
|
130
|
-
|
|
130
|
+
V.current = null, window.removeEventListener("mousemove", r), window.removeEventListener("mouseup", i);
|
|
131
131
|
};
|
|
132
132
|
window.addEventListener("mousemove", r), window.addEventListener("mouseup", i);
|
|
133
|
-
}, []),
|
|
133
|
+
}, []), U = i(k);
|
|
134
134
|
n(() => {
|
|
135
|
-
|
|
136
|
-
}, [
|
|
137
|
-
let
|
|
138
|
-
if (!
|
|
139
|
-
let t = e.find((e) => e.id ===
|
|
140
|
-
return t?.sortable ? l(p, u(t,
|
|
135
|
+
U.current !== k && (U.current = k, y?.(k));
|
|
136
|
+
}, [k, y]);
|
|
137
|
+
let W = r(() => {
|
|
138
|
+
if (!I || !L) return p;
|
|
139
|
+
let t = e.find((e) => e.id === I);
|
|
140
|
+
return t?.sortable ? l(p, u(t, L)) : p;
|
|
141
141
|
}, [
|
|
142
142
|
p,
|
|
143
143
|
e,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
]),
|
|
144
|
+
I,
|
|
145
|
+
L
|
|
146
|
+
]), G = ["data-table", b].filter(Boolean).join(" ");
|
|
147
147
|
return /* @__PURE__ */ o("div", {
|
|
148
|
-
className:
|
|
148
|
+
className: G,
|
|
149
149
|
"data-testid": S,
|
|
150
150
|
children: /* @__PURE__ */ s("table", {
|
|
151
151
|
className: "data-table__table",
|
|
@@ -155,8 +155,8 @@ function p({ columns: e, rows: p, getRowKey: m, emptyState: h, loadingState: g,
|
|
|
155
155
|
className: "data-table__head",
|
|
156
156
|
children: /* @__PURE__ */ o("tr", {
|
|
157
157
|
className: "data-table__row data-table__row--head",
|
|
158
|
-
children:
|
|
159
|
-
let t =
|
|
158
|
+
children: z.map((e) => {
|
|
159
|
+
let t = B(e), n = e.sortable && e.id === I;
|
|
160
160
|
return /* @__PURE__ */ s("th", {
|
|
161
161
|
scope: "col",
|
|
162
162
|
className: [
|
|
@@ -168,12 +168,12 @@ function p({ columns: e, rows: p, getRowKey: m, emptyState: h, loadingState: g,
|
|
|
168
168
|
e.align && `data-table__cell--align-${e.align}`
|
|
169
169
|
].filter(Boolean).join(" "),
|
|
170
170
|
style: t ? { width: `${String(t)}px` } : void 0,
|
|
171
|
-
"aria-sort": d(e,
|
|
171
|
+
"aria-sort": d(e, I, L),
|
|
172
172
|
onClick: e.sortable ? () => {
|
|
173
|
-
|
|
173
|
+
R(e);
|
|
174
174
|
} : void 0,
|
|
175
175
|
onKeyDown: e.sortable ? (t) => {
|
|
176
|
-
(t.key === "Enter" || t.key === " ") && (t.preventDefault(),
|
|
176
|
+
(t.key === "Enter" || t.key === " ") && (t.preventDefault(), R(e));
|
|
177
177
|
} : void 0,
|
|
178
178
|
tabIndex: e.sortable ? 0 : void 0,
|
|
179
179
|
children: [
|
|
@@ -181,13 +181,13 @@ function p({ columns: e, rows: p, getRowKey: m, emptyState: h, loadingState: g,
|
|
|
181
181
|
className: "data-table__head-label",
|
|
182
182
|
children: e.header
|
|
183
183
|
}),
|
|
184
|
-
e.sortable && /* @__PURE__ */ o(f, { direction: n &&
|
|
184
|
+
e.sortable && /* @__PURE__ */ o(f, { direction: n && L ? L : "none" }),
|
|
185
185
|
!e.noResize && /* @__PURE__ */ o("div", {
|
|
186
186
|
role: "separator",
|
|
187
187
|
"aria-orientation": "vertical",
|
|
188
188
|
className: "data-table__resize-handle",
|
|
189
189
|
onMouseDown: (n) => {
|
|
190
|
-
|
|
190
|
+
H(n, e, t ?? e.minWidth ?? 120);
|
|
191
191
|
}
|
|
192
192
|
})
|
|
193
193
|
]
|
|
@@ -199,30 +199,34 @@ function p({ columns: e, rows: p, getRowKey: m, emptyState: h, loadingState: g,
|
|
|
199
199
|
children: _ ? /* @__PURE__ */ o("tr", {
|
|
200
200
|
className: "data-table__row data-table__row--state",
|
|
201
201
|
children: /* @__PURE__ */ o("td", {
|
|
202
|
-
colSpan:
|
|
202
|
+
colSpan: z.length || 1,
|
|
203
203
|
className: "data-table__state-cell",
|
|
204
204
|
children: g ?? /* @__PURE__ */ o("div", { className: "data-table__loading" })
|
|
205
205
|
})
|
|
206
|
-
}) :
|
|
206
|
+
}) : W.length === 0 ? /* @__PURE__ */ o("tr", {
|
|
207
207
|
className: "data-table__row data-table__row--state",
|
|
208
208
|
children: /* @__PURE__ */ o("td", {
|
|
209
|
-
colSpan:
|
|
209
|
+
colSpan: z.length || 1,
|
|
210
210
|
className: "data-table__state-cell",
|
|
211
211
|
children: h
|
|
212
212
|
})
|
|
213
|
-
}) :
|
|
214
|
-
let n = m(e, t);
|
|
213
|
+
}) : W.map((e, t) => {
|
|
214
|
+
let n = m(e, t), r = !!(C && (w?.(e, t) ?? !0)), i = T?.(e, t);
|
|
215
215
|
return /* @__PURE__ */ o("tr", {
|
|
216
|
-
className: [
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
className: [
|
|
217
|
+
"data-table__row",
|
|
218
|
+
r && "data-table__row--clickable",
|
|
219
|
+
i
|
|
220
|
+
].filter(Boolean).join(" "),
|
|
221
|
+
onClick: r ? () => {
|
|
222
|
+
C?.(e);
|
|
219
223
|
} : void 0,
|
|
220
|
-
onKeyDown:
|
|
221
|
-
(t.key === "Enter" || t.key === " ") && (t.preventDefault(), C(e));
|
|
224
|
+
onKeyDown: r ? (t) => {
|
|
225
|
+
(t.key === "Enter" || t.key === " ") && (t.preventDefault(), C?.(e));
|
|
222
226
|
} : void 0,
|
|
223
|
-
tabIndex:
|
|
224
|
-
role:
|
|
225
|
-
children:
|
|
227
|
+
tabIndex: r ? 0 : void 0,
|
|
228
|
+
role: r ? "button" : void 0,
|
|
229
|
+
children: z.map((n) => /* @__PURE__ */ o("td", {
|
|
226
230
|
className: [
|
|
227
231
|
"data-table__cell",
|
|
228
232
|
n.className,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DataTable2.js","names":[],"sources":["../../../src/components/DataTable/DataTable.tsx"],"sourcesContent":["/**\n * DataTable Component (epic #2730 / issue #2738 · sort: epic #2859 / issue #2861)\n *\n * Generic, type-safe table primitive modeled after the Backend.AI WebUI\n * `BAITable`. Designed so future menus (Models, Sessions, Data, ...)\n * can share one tabular surface instead of inventing a new one each\n * time.\n *\n * Features (V1 — additional capabilities planned for follow-up issues):\n * - Type-safe column definitions with custom cell renderers\n * - Optional column-visibility persistence via `localStorage` when\n * `persistKey` is supplied\n * - Optional resizable columns (drag the right edge of a header cell)\n * with widths persisted alongside visibility\n * - Empty-state slot rendered when `rows` is empty\n * - Loading-state slot rendered when `loading` is `true`\n * - Stable `getRowKey` accessor so rows with the same identifier remain\n * referentially identical across event-driven updates\n *\n * Sorting (V2 — epic #2859 / issue #2861):\n * - Optional per-column sortable flag with sort value accessor or\n * custom comparator\n * - Optional defaultSortDirection per column\n * - Controlled (`sortColumnId` + `sortDirection` + `onSortChange`) and\n * uncontrolled (internal state) modes\n * - Accessible `aria-sort` attributes on header cells\n * - Keyboard activation (Enter / Space) for sortable headers\n * - Stable sort — equal-valued rows retain their original order\n * - Sorting never mutates the original `rows` prop\n *\n * The component is intentionally domain-agnostic — it MUST NOT carry\n * Session, Model, or Agent-specific knowledge. The first consumer is\n * `SessionListTable` (epic #2730 / #2738) but the API is generic.\n */\n\nimport {\n memo,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport \"./DataTable.css\";\n\n// ============================================================================\n// Public types\n// ============================================================================\n\n/** Direction a column is currently sorted. */\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface DataTableColumn<T> {\n /** Unique identifier for the column. Used for visibility + width persistence. */\n id: string;\n /** Header label. */\n header: ReactNode;\n /**\n * Cell renderer. Receives the row and returns a React node. Pure\n * functions are encouraged so memoization stays effective.\n */\n render: (row: T, index: number) => ReactNode;\n /** Optional minimum column width in pixels. Defaults to 80. */\n minWidth?: number;\n /** Optional initial width in pixels (only honored on the first paint). */\n initialWidth?: number;\n /** When `true`, the column cannot be resized. Defaults to `false`. */\n noResize?: boolean;\n /** When `true`, the column cannot be hidden by the user. Defaults to `false`. */\n alwaysVisible?: boolean;\n /** Extra `class` for the column's `<th>` and `<td>` cells. */\n className?: string;\n /**\n * When provided, the value is exposed via the `align` style on the\n * column's cells. Useful for right-aligned numeric columns.\n */\n align?: \"left\" | \"right\" | \"center\";\n\n // ---- Sorting API (added epic #2859 / issue #2861) -------------------------\n\n /**\n * When `true`, the column header renders a sort affordance and clicking\n * (or pressing Enter / Space) cycles through ascending → descending →\n * unsorted states. Defaults to `false`.\n */\n sortable?: boolean;\n /**\n * Extracts a primitive comparable value from a row for sorting purposes.\n * Used when the default ascending/descending behavior is sufficient.\n * Mutually exclusive with `sortComparator` — if both are provided,\n * `sortComparator` takes precedence.\n *\n * @example\n * ```tsx\n * sortValueAccessor: (row) => row.name.toLowerCase()\n * ```\n */\n sortValueAccessor?: (row: T) => string | number | boolean | null | undefined;\n /**\n * Custom comparator used for sorting. Receives two rows and the active\n * sort direction. Return a negative number if `a` should come first, a\n * positive number if `b` should come first, or `0` if they are equal.\n * The table applies a stable fallback on `0` to preserve original order.\n *\n * @example\n * ```tsx\n * sortComparator: (a, b, dir) =>\n * dir === \"asc\"\n * ? a.createdAt - b.createdAt\n * : b.createdAt - a.createdAt\n * ```\n */\n sortComparator?: (a: T, b: T, direction: SortDirection) => number;\n /**\n * The sort direction applied when this column is first activated.\n * Defaults to `\"asc\"`.\n */\n defaultSortDirection?: SortDirection;\n}\n\nexport interface DataTablePersistedState {\n /** column id → user-overridden width in pixels. */\n widths: Record<string, number>;\n /** column id → visibility flag. Missing keys count as visible. */\n visibility: Record<string, boolean>;\n}\n\nexport interface DataTableProps<T> {\n /** Column definitions, in display order. */\n columns: DataTableColumn<T>[];\n /** Row data. */\n rows: T[];\n /**\n * Stable identifier accessor — used as the React key for each row.\n * Must be deterministic for the lifetime of the row.\n */\n getRowKey: (row: T, index: number) => string;\n /** Render this node when `rows` is empty. */\n emptyState?: ReactNode;\n /** Render this node when `loading` is `true`. */\n loadingState?: ReactNode;\n /** When `true`, the loading slot replaces the table body. */\n loading?: boolean;\n /**\n * Column widths and visibility, controlled by the caller.\n *\n * This component used to take a `persistKey` and write to `localStorage`\n * itself. Storage is a policy decision that belongs to the consumer: where\n * it goes, under which key, whether it is per user or per workspace, and\n * whether it exists at all in a host that has no `localStorage`. A\n * rendering component that answers those questions on its own cannot be\n * reused by a consumer that answers them differently.\n */\n columnState?: DataTablePersistedState;\n /**\n * Called whenever the user resizes a column or toggles its visibility.\n * Pair it with `columnState` to persist wherever the consumer keeps\n * preferences; omit both to get a table that forgets on unmount.\n */\n onColumnStateChange?: (state: DataTablePersistedState) => void;\n /** Extra class for the table's wrapping element. */\n className?: string;\n /** ARIA label for the table. Defaults to \"Data table\". */\n ariaLabel?: string;\n /** Test ID for testing harnesses. */\n testId?: string;\n /**\n * Callback invoked when a row is clicked. When provided, rows render\n * with a clickable affordance (cursor: pointer + keyboard binding).\n */\n onRowClick?: (row: T) => void;\n\n // ---- Sorting props (added epic #2859 / issue #2861) -----------------------\n\n /**\n * Controlled — the column currently being sorted. Pass `undefined` to\n * indicate no active sort. When this prop is present, the component\n * operates in controlled mode: the caller is responsible for updating\n * the sort state via `onSortChange`.\n */\n sortColumnId?: string;\n /**\n * Controlled — the direction of the active sort. Ignored when\n * `sortColumnId` is `undefined`.\n */\n sortDirection?: SortDirection;\n /**\n * Called when the user activates a sortable column header. In\n * controlled mode the caller must propagate this back as\n * `sortColumnId` / `sortDirection`. In uncontrolled mode this prop is\n * purely observational.\n *\n * @param columnId The column being sorted, or `undefined` when the\n * sort is cleared.\n * @param direction The new direction.\n */\n onSortChange?: (\n columnId: string | undefined,\n direction: SortDirection | undefined,\n ) => void;\n}\n\nconst EMPTY_COLUMN_STATE: DataTablePersistedState = { widths: {}, visibility: {} };\n\n// ============================================================================\n// Sorting helpers\n// ============================================================================\n\n/**\n * Stable sort — rows with equal comparison result keep their original\n * relative order. We achieve stability by augmenting each row with its\n * original index and using that as a tie-breaker.\n *\n * The input array is never mutated.\n */\nfunction stableSort<T>(rows: T[], comparator: (a: T, b: T) => number): T[] {\n const indexed = rows.map((row, i) => ({ row, i }));\n indexed.sort((a, b) => {\n const result = comparator(a.row, b.row);\n return result !== 0 ? result : a.i - b.i;\n });\n return indexed.map(({ row }) => row);\n}\n\n/**\n * Builds a comparator from a `DataTableColumn` + active sort direction.\n * Falls back to string comparison when neither `sortComparator` nor\n * `sortValueAccessor` is provided on the column.\n */\nfunction buildComparator<T>(\n col: DataTableColumn<T>,\n direction: SortDirection,\n): (a: T, b: T) => number {\n const { sortComparator } = col;\n if (sortComparator) {\n return (a, b) => sortComparator(a, b, direction);\n }\n const accessor = col.sortValueAccessor;\n if (accessor) {\n return (a, b) => {\n const av = accessor(a);\n const bv = accessor(b);\n // Nullish values sort last regardless of direction.\n if (av == null && bv == null) return 0;\n if (av == null) return 1;\n if (bv == null) return -1;\n let cmp: number;\n if (typeof av === \"number\" && typeof bv === \"number\") {\n cmp = av - bv;\n } else {\n cmp = String(av).localeCompare(String(bv));\n }\n return direction === \"asc\" ? cmp : -cmp;\n };\n }\n // No accessor — unsortable in practice (column should not be sortable).\n return () => 0;\n}\n\n/**\n * Returns the `aria-sort` attribute value for a column header cell.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-sort\n */\nfunction ariaSortValue(\n col: DataTableColumn<unknown>,\n activeSortColumnId: string | undefined,\n activeSortDirection: SortDirection | undefined,\n): \"ascending\" | \"descending\" | \"none\" | undefined {\n if (!col.sortable) return undefined;\n if (col.id === activeSortColumnId) {\n return activeSortDirection === \"asc\" ? \"ascending\" : \"descending\";\n }\n return \"none\";\n}\n\n// Sort indicator icons rendered as inline SVG so there is no external\n// asset dependency. They intentionally reuse design-token colours so they\n// automatically adapt to light / dark themes.\n\nfunction SortIcon({ direction }: { direction: SortDirection | \"none\" }) {\n if (direction === \"none\") {\n // Neutral chevron stack — both arrows in muted colour\n return (\n <span\n className=\"data-table__sort-icon data-table__sort-icon--none\"\n aria-hidden=\"true\"\n >\n <svg\n width=\"10\"\n height=\"14\"\n viewBox=\"0 0 10 14\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M5 1L1 5H9L5 1Z\" fill=\"currentColor\" opacity=\"0.35\" />\n <path d=\"M5 13L9 9H1L5 13Z\" fill=\"currentColor\" opacity=\"0.35\" />\n </svg>\n </span>\n );\n }\n if (direction === \"asc\") {\n return (\n <span\n className=\"data-table__sort-icon data-table__sort-icon--asc\"\n aria-hidden=\"true\"\n >\n <svg\n width=\"10\"\n height=\"14\"\n viewBox=\"0 0 10 14\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M5 1L1 5H9L5 1Z\" fill=\"currentColor\" />\n <path d=\"M5 13L9 9H1L5 13Z\" fill=\"currentColor\" opacity=\"0.25\" />\n </svg>\n </span>\n );\n }\n return (\n <span\n className=\"data-table__sort-icon data-table__sort-icon--desc\"\n aria-hidden=\"true\"\n >\n <svg\n width=\"10\"\n height=\"14\"\n viewBox=\"0 0 10 14\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M5 1L1 5H9L5 1Z\" fill=\"currentColor\" opacity=\"0.25\" />\n <path d=\"M5 13L9 9H1L5 13Z\" fill=\"currentColor\" />\n </svg>\n </span>\n );\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nfunction DataTableInner<T>({\n columns,\n rows,\n getRowKey,\n emptyState,\n loadingState,\n loading = false,\n columnState,\n onColumnStateChange,\n className = \"\",\n ariaLabel = \"Data table\",\n testId,\n onRowClick,\n // Sorting (controlled)\n sortColumnId: controlledSortColumnId,\n sortDirection: controlledSortDirection,\n onSortChange,\n}: DataTableProps<T>) {\n // ---- Column state (widths + visibility) ----------------------------------\n //\n // Held internally so a caller that does not care about persistence gets a\n // working table, and re-seeded whenever the caller supplies a new one.\n const [persisted, setPersisted] = useState<DataTablePersistedState>(\n () => columnState ?? EMPTY_COLUMN_STATE,\n );\n\n useEffect(() => {\n if (columnState) setPersisted(columnState);\n }, [columnState]);\n\n // ---- Sorting state (uncontrolled fallback) -------------------------------\n //\n // Controlled mode is detected by the presence of `sortColumnId` as a prop\n // (the caller explicitly manages the sort column). Using only this prop as\n // the signal (rather than OR-ing with `sortDirection`) avoids a\n // pathological state where `sortDirection` is set but `sortColumnId` is\n // not, which would enter controlled mode with no sort effect.\n //\n // Note: TypeScript cannot distinguish \"prop not passed\" from \"prop passed\n // as undefined\", so we rely on the convention that controlled callers\n // always pass both `sortColumnId` and `sortDirection` together.\n const isControlled =\n controlledSortColumnId !== undefined || controlledSortDirection !== undefined;\n\n const [internalSortColumnId, setInternalSortColumnId] = useState<string | undefined>(\n undefined,\n );\n const [internalSortDirection, setInternalSortDirection] = useState<\n SortDirection | undefined\n >(undefined);\n\n const activeSortColumnId = isControlled\n ? controlledSortColumnId\n : internalSortColumnId;\n const activeSortDirection = isControlled\n ? controlledSortDirection\n : internalSortDirection;\n\n /** Handle a click / keypress on a sortable column header. */\n const handleSortToggle = useCallback(\n (col: DataTableColumn<T>) => {\n if (!col.sortable) return;\n\n let nextColumnId: string | undefined;\n let nextDirection: SortDirection | undefined;\n\n if (activeSortColumnId !== col.id) {\n // Activate this column in its default direction.\n nextColumnId = col.id;\n nextDirection = col.defaultSortDirection ?? \"asc\";\n } else if (activeSortDirection === (col.defaultSortDirection ?? \"asc\")) {\n // Flip to the opposite direction.\n nextColumnId = col.id;\n nextDirection = activeSortDirection === \"asc\" ? \"desc\" : \"asc\";\n } else {\n // Already in the non-default direction — clear the sort.\n nextColumnId = undefined;\n nextDirection = undefined;\n }\n\n if (!isControlled) {\n setInternalSortColumnId(nextColumnId);\n setInternalSortDirection(nextDirection);\n }\n onSortChange?.(nextColumnId, nextDirection);\n },\n [activeSortColumnId, activeSortDirection, isControlled, onSortChange],\n );\n\n // ---- Visibility computation (column always-visible wins) -----------------\n const visibleColumns = useMemo(\n () =>\n columns.filter((col) => {\n if (col.alwaysVisible) return true;\n const flag = persisted.visibility[col.id];\n return flag !== false;\n }),\n [columns, persisted.visibility],\n );\n\n // ---- Width resolution ----------------------------------------------------\n const resolveWidth = useCallback(\n (col: DataTableColumn<T>): number | undefined => {\n const persistedWidth = persisted.widths[col.id];\n if (typeof persistedWidth === \"number\" && persistedWidth > 0) {\n return persistedWidth;\n }\n return col.initialWidth;\n },\n [persisted.widths],\n );\n\n // ---- Resize handling -----------------------------------------------------\n const resizeRef = useRef<{\n columnId: string;\n startX: number;\n startWidth: number;\n minWidth: number;\n } | null>(null);\n\n const beginResize = useCallback(\n (\n ev: React.MouseEvent<HTMLDivElement>,\n col: DataTableColumn<T>,\n currentWidth: number,\n ) => {\n if (col.noResize) return;\n ev.preventDefault();\n ev.stopPropagation();\n resizeRef.current = {\n columnId: col.id,\n startX: ev.clientX,\n startWidth: currentWidth,\n minWidth: col.minWidth ?? 80,\n };\n\n const onMove = (e: MouseEvent) => {\n const ref = resizeRef.current;\n if (!ref) return;\n const delta = e.clientX - ref.startX;\n const next = Math.max(ref.minWidth, ref.startWidth + delta);\n setPersisted((prev) => ({\n ...prev,\n widths: { ...prev.widths, [ref.columnId]: next },\n }));\n };\n const onUp = () => {\n resizeRef.current = null;\n window.removeEventListener(\"mousemove\", onMove);\n window.removeEventListener(\"mouseup\", onUp);\n };\n window.addEventListener(\"mousemove\", onMove);\n window.addEventListener(\"mouseup\", onUp);\n },\n [],\n );\n\n // Report changes so the caller can persist them. Skipped while `persisted`\n // still holds what the caller last handed in, so echoing the callback back\n // through `columnState` does not loop.\n const reported = useRef(persisted);\n useEffect(() => {\n if (reported.current === persisted) return;\n reported.current = persisted;\n onColumnStateChange?.(persisted);\n }, [persisted, onColumnStateChange]);\n\n // ---- Sorted rows --------------------------------------------------------\n const sortedRows = useMemo(() => {\n if (!activeSortColumnId || !activeSortDirection) return rows;\n const col = columns.find((c) => c.id === activeSortColumnId);\n if (!col?.sortable) return rows;\n return stableSort(rows, buildComparator(col, activeSortDirection));\n }, [rows, columns, activeSortColumnId, activeSortDirection]);\n\n // ---- Body rendering ------------------------------------------------------\n const composedClassName = [\"data-table\", className].filter(Boolean).join(\" \");\n\n const renderBody = () => {\n if (loading) {\n return (\n <tr className=\"data-table__row data-table__row--state\">\n <td colSpan={visibleColumns.length || 1} className=\"data-table__state-cell\">\n {loadingState ?? <div className=\"data-table__loading\" />}\n </td>\n </tr>\n );\n }\n if (sortedRows.length === 0) {\n return (\n <tr className=\"data-table__row data-table__row--state\">\n <td colSpan={visibleColumns.length || 1} className=\"data-table__state-cell\">\n {emptyState}\n </td>\n </tr>\n );\n }\n return sortedRows.map((row, index) => {\n const key = getRowKey(row, index);\n return (\n <tr\n key={key}\n className={[\"data-table__row\", onRowClick && \"data-table__row--clickable\"]\n .filter(Boolean)\n .join(\" \")}\n onClick={\n onRowClick\n ? () => {\n onRowClick(row);\n }\n : undefined\n }\n onKeyDown={\n onRowClick\n ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onRowClick(row);\n }\n }\n : undefined\n }\n tabIndex={onRowClick ? 0 : undefined}\n role={onRowClick ? \"button\" : undefined}\n >\n {visibleColumns.map((col) => (\n <td\n key={col.id}\n className={[\n \"data-table__cell\",\n col.className,\n col.align && `data-table__cell--align-${col.align}`,\n ]\n .filter(Boolean)\n .join(\" \")}\n >\n {col.render(row, index)}\n </td>\n ))}\n </tr>\n );\n });\n };\n\n return (\n <div className={composedClassName} data-testid={testId}>\n <table className=\"data-table__table\" aria-label={ariaLabel} role=\"table\">\n <thead className=\"data-table__head\">\n <tr className=\"data-table__row data-table__row--head\">\n {visibleColumns.map((col) => {\n const width = resolveWidth(col);\n const isSortActive = col.sortable && col.id === activeSortColumnId;\n\n return (\n <th\n key={col.id}\n scope=\"col\"\n className={[\n \"data-table__cell\",\n \"data-table__cell--head\",\n col.sortable && \"data-table__cell--sortable\",\n isSortActive && \"data-table__cell--sort-active\",\n col.className,\n col.align && `data-table__cell--align-${col.align}`,\n ]\n .filter(Boolean)\n .join(\" \")}\n style={width ? { width: `${String(width)}px` } : undefined}\n aria-sort={ariaSortValue(\n col as DataTableColumn<unknown>,\n activeSortColumnId,\n activeSortDirection,\n )}\n onClick={\n col.sortable\n ? () => {\n handleSortToggle(col);\n }\n : undefined\n }\n onKeyDown={\n col.sortable\n ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n handleSortToggle(col);\n }\n }\n : undefined\n }\n tabIndex={col.sortable ? 0 : undefined}\n >\n <span className=\"data-table__head-label\">{col.header}</span>\n {col.sortable && (\n <SortIcon\n direction={\n isSortActive && activeSortDirection\n ? activeSortDirection\n : \"none\"\n }\n />\n )}\n {!col.noResize && (\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n className=\"data-table__resize-handle\"\n onMouseDown={(e) => {\n beginResize(e, col, width ?? col.minWidth ?? 120);\n }}\n />\n )}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody className=\"data-table__body\">{renderBody()}</tbody>\n </table>\n </div>\n );\n}\n\n// Memoize while preserving the generic signature.\nexport const DataTable = memo(DataTableInner) as typeof DataTableInner;\n\nexport default DataTable;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"DataTable2.js","names":[],"sources":["../../../src/components/DataTable/DataTable.tsx"],"sourcesContent":["/**\n * DataTable Component (epic #2730 / issue #2738 · sort: epic #2859 / issue #2861)\n *\n * Generic, type-safe table primitive modeled after the Backend.AI WebUI\n * `BAITable`. Designed so future menus (Models, Sessions, Data, ...)\n * can share one tabular surface instead of inventing a new one each\n * time.\n *\n * Features (V1 — additional capabilities planned for follow-up issues):\n * - Type-safe column definitions with custom cell renderers\n * - Optional column-visibility persistence via `localStorage` when\n * `persistKey` is supplied\n * - Optional resizable columns (drag the right edge of a header cell)\n * with widths persisted alongside visibility\n * - Empty-state slot rendered when `rows` is empty\n * - Loading-state slot rendered when `loading` is `true`\n * - Stable `getRowKey` accessor so rows with the same identifier remain\n * referentially identical across event-driven updates\n *\n * Sorting (V2 — epic #2859 / issue #2861):\n * - Optional per-column sortable flag with sort value accessor or\n * custom comparator\n * - Optional defaultSortDirection per column\n * - Controlled (`sortColumnId` + `sortDirection` + `onSortChange`) and\n * uncontrolled (internal state) modes\n * - Accessible `aria-sort` attributes on header cells\n * - Keyboard activation (Enter / Space) for sortable headers\n * - Stable sort — equal-valued rows retain their original order\n * - Sorting never mutates the original `rows` prop\n *\n * The component is intentionally domain-agnostic — it MUST NOT carry\n * Session, Model, or Agent-specific knowledge. The first consumer is\n * `SessionListTable` (epic #2730 / #2738) but the API is generic.\n */\n\nimport {\n memo,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport \"./DataTable.css\";\n\n// ============================================================================\n// Public types\n// ============================================================================\n\n/** Direction a column is currently sorted. */\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface DataTableColumn<T> {\n /** Unique identifier for the column. Used for visibility + width persistence. */\n id: string;\n /** Header label. */\n header: ReactNode;\n /**\n * Cell renderer. Receives the row and returns a React node. Pure\n * functions are encouraged so memoization stays effective.\n */\n render: (row: T, index: number) => ReactNode;\n /** Optional minimum column width in pixels. Defaults to 80. */\n minWidth?: number;\n /** Optional initial width in pixels (only honored on the first paint). */\n initialWidth?: number;\n /** When `true`, the column cannot be resized. Defaults to `false`. */\n noResize?: boolean;\n /** When `true`, the column cannot be hidden by the user. Defaults to `false`. */\n alwaysVisible?: boolean;\n /** Extra `class` for the column's `<th>` and `<td>` cells. */\n className?: string;\n /**\n * When provided, the value is exposed via the `align` style on the\n * column's cells. Useful for right-aligned numeric columns.\n */\n align?: \"left\" | \"right\" | \"center\";\n\n // ---- Sorting API (added epic #2859 / issue #2861) -------------------------\n\n /**\n * When `true`, the column header renders a sort affordance and clicking\n * (or pressing Enter / Space) cycles through ascending → descending →\n * unsorted states. Defaults to `false`.\n */\n sortable?: boolean;\n /**\n * Extracts a primitive comparable value from a row for sorting purposes.\n * Used when the default ascending/descending behavior is sufficient.\n * Mutually exclusive with `sortComparator` — if both are provided,\n * `sortComparator` takes precedence.\n *\n * @example\n * ```tsx\n * sortValueAccessor: (row) => row.name.toLowerCase()\n * ```\n */\n sortValueAccessor?: (row: T) => string | number | boolean | null | undefined;\n /**\n * Custom comparator used for sorting. Receives two rows and the active\n * sort direction. Return a negative number if `a` should come first, a\n * positive number if `b` should come first, or `0` if they are equal.\n * The table applies a stable fallback on `0` to preserve original order.\n *\n * @example\n * ```tsx\n * sortComparator: (a, b, dir) =>\n * dir === \"asc\"\n * ? a.createdAt - b.createdAt\n * : b.createdAt - a.createdAt\n * ```\n */\n sortComparator?: (a: T, b: T, direction: SortDirection) => number;\n /**\n * The sort direction applied when this column is first activated.\n * Defaults to `\"asc\"`.\n */\n defaultSortDirection?: SortDirection;\n}\n\nexport interface DataTablePersistedState {\n /** column id → user-overridden width in pixels. */\n widths: Record<string, number>;\n /** column id → visibility flag. Missing keys count as visible. */\n visibility: Record<string, boolean>;\n}\n\nexport interface DataTableProps<T> {\n /** Column definitions, in display order. */\n columns: DataTableColumn<T>[];\n /** Row data. */\n rows: T[];\n /**\n * Stable identifier accessor — used as the React key for each row.\n * Must be deterministic for the lifetime of the row.\n */\n getRowKey: (row: T, index: number) => string;\n /** Render this node when `rows` is empty. */\n emptyState?: ReactNode;\n /** Render this node when `loading` is `true`. */\n loadingState?: ReactNode;\n /** When `true`, the loading slot replaces the table body. */\n loading?: boolean;\n /**\n * Column widths and visibility, controlled by the caller.\n *\n * This component used to take a `persistKey` and write to `localStorage`\n * itself. Storage is a policy decision that belongs to the consumer: where\n * it goes, under which key, whether it is per user or per workspace, and\n * whether it exists at all in a host that has no `localStorage`. A\n * rendering component that answers those questions on its own cannot be\n * reused by a consumer that answers them differently.\n */\n columnState?: DataTablePersistedState;\n /**\n * Called whenever the user resizes a column or toggles its visibility.\n * Pair it with `columnState` to persist wherever the consumer keeps\n * preferences; omit both to get a table that forgets on unmount.\n */\n onColumnStateChange?: (state: DataTablePersistedState) => void;\n /** Extra class for the table's wrapping element. */\n className?: string;\n /** ARIA label for the table. Defaults to \"Data table\". */\n ariaLabel?: string;\n /** Test ID for testing harnesses. */\n testId?: string;\n /**\n * Callback invoked when a row is clicked. When provided, rows render\n * with a clickable affordance (cursor: pointer + keyboard binding).\n */\n onRowClick?: (row: T) => void;\n /**\n * Narrows `onRowClick` to the rows that can actually act on it, for a table\n * that mixes actionable and informational rows. A row this returns `false`\n * for gets no pointer affordance, no tab stop, and no keyboard binding, so it\n * is not announced as a button it cannot be.\n */\n isRowClickable?: (row: T, index: number) => boolean;\n /**\n * Extra class for one row, for flagging a specific row without forking the\n * table: a transient deep-link highlight, a stale entry, a row being removed.\n * A falsy return adds nothing.\n */\n rowClassName?: (row: T, index: number) => string | undefined | false | null;\n\n // ---- Sorting props (added epic #2859 / issue #2861) -----------------------\n\n /**\n * Controlled — the column currently being sorted. Pass `undefined` to\n * indicate no active sort. When this prop is present, the component\n * operates in controlled mode: the caller is responsible for updating\n * the sort state via `onSortChange`.\n */\n sortColumnId?: string;\n /**\n * Controlled — the direction of the active sort. Ignored when\n * `sortColumnId` is `undefined`.\n */\n sortDirection?: SortDirection;\n /**\n * Called when the user activates a sortable column header. In\n * controlled mode the caller must propagate this back as\n * `sortColumnId` / `sortDirection`. In uncontrolled mode this prop is\n * purely observational.\n *\n * @param columnId The column being sorted, or `undefined` when the\n * sort is cleared.\n * @param direction The new direction.\n */\n onSortChange?: (\n columnId: string | undefined,\n direction: SortDirection | undefined,\n ) => void;\n}\n\nconst EMPTY_COLUMN_STATE: DataTablePersistedState = { widths: {}, visibility: {} };\n\n// ============================================================================\n// Sorting helpers\n// ============================================================================\n\n/**\n * Stable sort — rows with equal comparison result keep their original\n * relative order. We achieve stability by augmenting each row with its\n * original index and using that as a tie-breaker.\n *\n * The input array is never mutated.\n */\nfunction stableSort<T>(rows: T[], comparator: (a: T, b: T) => number): T[] {\n const indexed = rows.map((row, i) => ({ row, i }));\n indexed.sort((a, b) => {\n const result = comparator(a.row, b.row);\n return result !== 0 ? result : a.i - b.i;\n });\n return indexed.map(({ row }) => row);\n}\n\n/**\n * Builds a comparator from a `DataTableColumn` + active sort direction.\n * Falls back to string comparison when neither `sortComparator` nor\n * `sortValueAccessor` is provided on the column.\n */\nfunction buildComparator<T>(\n col: DataTableColumn<T>,\n direction: SortDirection,\n): (a: T, b: T) => number {\n const { sortComparator } = col;\n if (sortComparator) {\n return (a, b) => sortComparator(a, b, direction);\n }\n const accessor = col.sortValueAccessor;\n if (accessor) {\n return (a, b) => {\n const av = accessor(a);\n const bv = accessor(b);\n // Nullish values sort last regardless of direction.\n if (av == null && bv == null) return 0;\n if (av == null) return 1;\n if (bv == null) return -1;\n let cmp: number;\n if (typeof av === \"number\" && typeof bv === \"number\") {\n cmp = av - bv;\n } else {\n cmp = String(av).localeCompare(String(bv));\n }\n return direction === \"asc\" ? cmp : -cmp;\n };\n }\n // No accessor — unsortable in practice (column should not be sortable).\n return () => 0;\n}\n\n/**\n * Returns the `aria-sort` attribute value for a column header cell.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-sort\n */\nfunction ariaSortValue(\n col: DataTableColumn<unknown>,\n activeSortColumnId: string | undefined,\n activeSortDirection: SortDirection | undefined,\n): \"ascending\" | \"descending\" | \"none\" | undefined {\n if (!col.sortable) return undefined;\n if (col.id === activeSortColumnId) {\n return activeSortDirection === \"asc\" ? \"ascending\" : \"descending\";\n }\n return \"none\";\n}\n\n// Sort indicator icons rendered as inline SVG so there is no external\n// asset dependency. They intentionally reuse design-token colours so they\n// automatically adapt to light / dark themes.\n\nfunction SortIcon({ direction }: { direction: SortDirection | \"none\" }) {\n if (direction === \"none\") {\n // Neutral chevron stack — both arrows in muted colour\n return (\n <span\n className=\"data-table__sort-icon data-table__sort-icon--none\"\n aria-hidden=\"true\"\n >\n <svg\n width=\"10\"\n height=\"14\"\n viewBox=\"0 0 10 14\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M5 1L1 5H9L5 1Z\" fill=\"currentColor\" opacity=\"0.35\" />\n <path d=\"M5 13L9 9H1L5 13Z\" fill=\"currentColor\" opacity=\"0.35\" />\n </svg>\n </span>\n );\n }\n if (direction === \"asc\") {\n return (\n <span\n className=\"data-table__sort-icon data-table__sort-icon--asc\"\n aria-hidden=\"true\"\n >\n <svg\n width=\"10\"\n height=\"14\"\n viewBox=\"0 0 10 14\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M5 1L1 5H9L5 1Z\" fill=\"currentColor\" />\n <path d=\"M5 13L9 9H1L5 13Z\" fill=\"currentColor\" opacity=\"0.25\" />\n </svg>\n </span>\n );\n }\n return (\n <span\n className=\"data-table__sort-icon data-table__sort-icon--desc\"\n aria-hidden=\"true\"\n >\n <svg\n width=\"10\"\n height=\"14\"\n viewBox=\"0 0 10 14\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path d=\"M5 1L1 5H9L5 1Z\" fill=\"currentColor\" opacity=\"0.25\" />\n <path d=\"M5 13L9 9H1L5 13Z\" fill=\"currentColor\" />\n </svg>\n </span>\n );\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nfunction DataTableInner<T>({\n columns,\n rows,\n getRowKey,\n emptyState,\n loadingState,\n loading = false,\n columnState,\n onColumnStateChange,\n className = \"\",\n ariaLabel = \"Data table\",\n testId,\n onRowClick,\n isRowClickable,\n rowClassName,\n // Sorting (controlled)\n sortColumnId: controlledSortColumnId,\n sortDirection: controlledSortDirection,\n onSortChange,\n}: DataTableProps<T>) {\n // ---- Column state (widths + visibility) ----------------------------------\n //\n // Held internally so a caller that does not care about persistence gets a\n // working table, and re-seeded whenever the caller supplies a new one.\n const [persisted, setPersisted] = useState<DataTablePersistedState>(\n () => columnState ?? EMPTY_COLUMN_STATE,\n );\n\n useEffect(() => {\n if (columnState) setPersisted(columnState);\n }, [columnState]);\n\n // ---- Sorting state (uncontrolled fallback) -------------------------------\n //\n // Controlled mode is detected by the presence of `sortColumnId` as a prop\n // (the caller explicitly manages the sort column). Using only this prop as\n // the signal (rather than OR-ing with `sortDirection`) avoids a\n // pathological state where `sortDirection` is set but `sortColumnId` is\n // not, which would enter controlled mode with no sort effect.\n //\n // Note: TypeScript cannot distinguish \"prop not passed\" from \"prop passed\n // as undefined\", so we rely on the convention that controlled callers\n // always pass both `sortColumnId` and `sortDirection` together.\n const isControlled =\n controlledSortColumnId !== undefined || controlledSortDirection !== undefined;\n\n const [internalSortColumnId, setInternalSortColumnId] = useState<string | undefined>(\n undefined,\n );\n const [internalSortDirection, setInternalSortDirection] = useState<\n SortDirection | undefined\n >(undefined);\n\n const activeSortColumnId = isControlled\n ? controlledSortColumnId\n : internalSortColumnId;\n const activeSortDirection = isControlled\n ? controlledSortDirection\n : internalSortDirection;\n\n /** Handle a click / keypress on a sortable column header. */\n const handleSortToggle = useCallback(\n (col: DataTableColumn<T>) => {\n if (!col.sortable) return;\n\n let nextColumnId: string | undefined;\n let nextDirection: SortDirection | undefined;\n\n if (activeSortColumnId !== col.id) {\n // Activate this column in its default direction.\n nextColumnId = col.id;\n nextDirection = col.defaultSortDirection ?? \"asc\";\n } else if (activeSortDirection === (col.defaultSortDirection ?? \"asc\")) {\n // Flip to the opposite direction.\n nextColumnId = col.id;\n nextDirection = activeSortDirection === \"asc\" ? \"desc\" : \"asc\";\n } else {\n // Already in the non-default direction — clear the sort.\n nextColumnId = undefined;\n nextDirection = undefined;\n }\n\n if (!isControlled) {\n setInternalSortColumnId(nextColumnId);\n setInternalSortDirection(nextDirection);\n }\n onSortChange?.(nextColumnId, nextDirection);\n },\n [activeSortColumnId, activeSortDirection, isControlled, onSortChange],\n );\n\n // ---- Visibility computation (column always-visible wins) -----------------\n const visibleColumns = useMemo(\n () =>\n columns.filter((col) => {\n if (col.alwaysVisible) return true;\n const flag = persisted.visibility[col.id];\n return flag !== false;\n }),\n [columns, persisted.visibility],\n );\n\n // ---- Width resolution ----------------------------------------------------\n const resolveWidth = useCallback(\n (col: DataTableColumn<T>): number | undefined => {\n const persistedWidth = persisted.widths[col.id];\n if (typeof persistedWidth === \"number\" && persistedWidth > 0) {\n return persistedWidth;\n }\n return col.initialWidth;\n },\n [persisted.widths],\n );\n\n // ---- Resize handling -----------------------------------------------------\n const resizeRef = useRef<{\n columnId: string;\n startX: number;\n startWidth: number;\n minWidth: number;\n } | null>(null);\n\n const beginResize = useCallback(\n (\n ev: React.MouseEvent<HTMLDivElement>,\n col: DataTableColumn<T>,\n currentWidth: number,\n ) => {\n if (col.noResize) return;\n ev.preventDefault();\n ev.stopPropagation();\n resizeRef.current = {\n columnId: col.id,\n startX: ev.clientX,\n startWidth: currentWidth,\n minWidth: col.minWidth ?? 80,\n };\n\n const onMove = (e: MouseEvent) => {\n const ref = resizeRef.current;\n if (!ref) return;\n const delta = e.clientX - ref.startX;\n const next = Math.max(ref.minWidth, ref.startWidth + delta);\n setPersisted((prev) => ({\n ...prev,\n widths: { ...prev.widths, [ref.columnId]: next },\n }));\n };\n const onUp = () => {\n resizeRef.current = null;\n window.removeEventListener(\"mousemove\", onMove);\n window.removeEventListener(\"mouseup\", onUp);\n };\n window.addEventListener(\"mousemove\", onMove);\n window.addEventListener(\"mouseup\", onUp);\n },\n [],\n );\n\n // Report changes so the caller can persist them. Skipped while `persisted`\n // still holds what the caller last handed in, so echoing the callback back\n // through `columnState` does not loop.\n const reported = useRef(persisted);\n useEffect(() => {\n if (reported.current === persisted) return;\n reported.current = persisted;\n onColumnStateChange?.(persisted);\n }, [persisted, onColumnStateChange]);\n\n // ---- Sorted rows --------------------------------------------------------\n const sortedRows = useMemo(() => {\n if (!activeSortColumnId || !activeSortDirection) return rows;\n const col = columns.find((c) => c.id === activeSortColumnId);\n if (!col?.sortable) return rows;\n return stableSort(rows, buildComparator(col, activeSortDirection));\n }, [rows, columns, activeSortColumnId, activeSortDirection]);\n\n // ---- Body rendering ------------------------------------------------------\n const composedClassName = [\"data-table\", className].filter(Boolean).join(\" \");\n\n const renderBody = () => {\n if (loading) {\n return (\n <tr className=\"data-table__row data-table__row--state\">\n <td colSpan={visibleColumns.length || 1} className=\"data-table__state-cell\">\n {loadingState ?? <div className=\"data-table__loading\" />}\n </td>\n </tr>\n );\n }\n if (sortedRows.length === 0) {\n return (\n <tr className=\"data-table__row data-table__row--state\">\n <td colSpan={visibleColumns.length || 1} className=\"data-table__state-cell\">\n {emptyState}\n </td>\n </tr>\n );\n }\n return sortedRows.map((row, index) => {\n const key = getRowKey(row, index);\n // A row is interactive only if the table has a handler AND this row is\n // not excluded, so the affordance, the tab stop and the role move\n // together. Splitting them would announce a button that does nothing.\n const clickable = Boolean(onRowClick && (isRowClickable?.(row, index) ?? true));\n const extraClass = rowClassName?.(row, index);\n return (\n <tr\n key={key}\n className={[\n \"data-table__row\",\n clickable && \"data-table__row--clickable\",\n extraClass,\n ]\n .filter(Boolean)\n .join(\" \")}\n onClick={\n clickable\n ? () => {\n onRowClick?.(row);\n }\n : undefined\n }\n onKeyDown={\n clickable\n ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onRowClick?.(row);\n }\n }\n : undefined\n }\n tabIndex={clickable ? 0 : undefined}\n role={clickable ? \"button\" : undefined}\n >\n {visibleColumns.map((col) => (\n <td\n key={col.id}\n className={[\n \"data-table__cell\",\n col.className,\n col.align && `data-table__cell--align-${col.align}`,\n ]\n .filter(Boolean)\n .join(\" \")}\n >\n {col.render(row, index)}\n </td>\n ))}\n </tr>\n );\n });\n };\n\n return (\n <div className={composedClassName} data-testid={testId}>\n <table className=\"data-table__table\" aria-label={ariaLabel} role=\"table\">\n <thead className=\"data-table__head\">\n <tr className=\"data-table__row data-table__row--head\">\n {visibleColumns.map((col) => {\n const width = resolveWidth(col);\n const isSortActive = col.sortable && col.id === activeSortColumnId;\n\n return (\n <th\n key={col.id}\n scope=\"col\"\n className={[\n \"data-table__cell\",\n \"data-table__cell--head\",\n col.sortable && \"data-table__cell--sortable\",\n isSortActive && \"data-table__cell--sort-active\",\n col.className,\n col.align && `data-table__cell--align-${col.align}`,\n ]\n .filter(Boolean)\n .join(\" \")}\n style={width ? { width: `${String(width)}px` } : undefined}\n aria-sort={ariaSortValue(\n col as DataTableColumn<unknown>,\n activeSortColumnId,\n activeSortDirection,\n )}\n onClick={\n col.sortable\n ? () => {\n handleSortToggle(col);\n }\n : undefined\n }\n onKeyDown={\n col.sortable\n ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n handleSortToggle(col);\n }\n }\n : undefined\n }\n tabIndex={col.sortable ? 0 : undefined}\n >\n <span className=\"data-table__head-label\">{col.header}</span>\n {col.sortable && (\n <SortIcon\n direction={\n isSortActive && activeSortDirection\n ? activeSortDirection\n : \"none\"\n }\n />\n )}\n {!col.noResize && (\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n className=\"data-table__resize-handle\"\n onMouseDown={(e) => {\n beginResize(e, col, width ?? col.minWidth ?? 120);\n }}\n />\n )}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody className=\"data-table__body\">{renderBody()}</tbody>\n </table>\n </div>\n );\n}\n\n// Memoize while preserving the generic signature.\nexport const DataTable = memo(DataTableInner) as typeof DataTableInner;\n\nexport default DataTable;\n"],"mappings":"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"}
|
|
@@ -58,6 +58,7 @@ function s({ isOpen: s, onClose: c, title: l, subtitle: u, width: d = "medium",
|
|
|
58
58
|
onClick: I,
|
|
59
59
|
role: "presentation",
|
|
60
60
|
"aria-hidden": !s,
|
|
61
|
+
inert: !s,
|
|
61
62
|
children: /* @__PURE__ */ a("aside", {
|
|
62
63
|
ref: b,
|
|
63
64
|
className: `drawer ${M ? "drawer--open" : ""} ${x ? "drawer--shaking" : ""} ${m}`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Drawer2.js","names":[],"sources":["../../../src/components/Drawer/Drawer.tsx"],"sourcesContent":["/**\n * Drawer Component\n *\n * A reusable right-side drawer panel with backdrop, animations, and accessibility features.\n * Consolidates common patterns from all drawer implementations.\n */\n\nimport { useEffect, useRef, useCallback, useState } from \"react\";\nimport \"./Drawer.css\";\n\nexport interface DrawerProps {\n /** Whether the drawer is open */\n isOpen: boolean;\n /** Callback when drawer should close */\n onClose: () => void;\n /** Drawer title */\n title: React.ReactNode;\n /** Optional subtitle */\n subtitle?: React.ReactNode;\n /** Drawer width preset or custom CSS width value */\n width?: \"narrow\" | \"medium\" | \"wide\" | (string & Record<never, never>);\n /** Drawer content */\n children: React.ReactNode;\n /** Optional footer content */\n footer?: React.ReactNode;\n /** Additional CSS class for drawer panel */\n className?: string;\n /** ARIA labelledby ID (defaults to internal title ID) */\n ariaLabelledBy?: string;\n /** ARIA describedby ID (defaults to internal subtitle ID if subtitle exists) */\n ariaDescribedBy?: string;\n /** Accessible label for the close button. Default: \"Close\" */\n closeLabel?: string;\n /**\n * Refuse dismissal by Escape or a backdrop click, shaking the panel instead.\n * The close button and any footer control still call `onClose` directly, so\n * route those through the same guard when they also need confirming.\n */\n preventDismiss?: boolean;\n /**\n * Called when `preventDismiss` blocked a dismissal, so the consumer can offer\n * an explicit way out (a discard confirmation, for instance).\n */\n onDismissAttempt?: () => void;\n}\n\nconst WIDTH_PRESETS = {\n narrow: \"400px\",\n medium: \"520px\",\n wide: \"900px\",\n};\n\nexport function Drawer({\n isOpen,\n onClose,\n title,\n subtitle,\n width = \"medium\",\n children,\n footer,\n className = \"\",\n ariaLabelledBy,\n ariaDescribedBy,\n closeLabel = \"Close\",\n preventDismiss = false,\n onDismissAttempt,\n}: DrawerProps) {\n const drawerRef = useRef<HTMLDivElement>(null);\n\n const [isShaking, setIsShaking] = useState(false);\n\n // Read the guard through refs so `blockDismiss` keeps a stable identity. The\n // focus-management effect depends on it, and if the identity changed the\n // moment the consumer marks the drawer dirty, that effect would re-run on the\n // first keystroke and steal focus from the field being typed into.\n const preventDismissRef = useRef(preventDismiss);\n preventDismissRef.current = preventDismiss;\n const onDismissAttemptRef = useRef(onDismissAttempt);\n onDismissAttemptRef.current = onDismissAttempt;\n\n /** Blocks a dismissal when the guard is on. Returns whether it blocked. */\n const blockDismiss = useCallback(() => {\n if (!preventDismissRef.current) return false;\n setIsShaking(true);\n onDismissAttemptRef.current?.();\n return true;\n }, []);\n\n // Guarded on the animation name so an unrelated animation on the panel does\n // not clear the flag early.\n const handleAnimationEnd = useCallback((e: React.AnimationEvent) => {\n if (e.animationName === \"drawer-shake\") setIsShaking(false);\n }, []);\n const previousActiveElementRef = useRef<HTMLElement | null>(null);\n const closeButtonRef = useRef<HTMLButtonElement>(null);\n\n // Track if this is the first render to enable animation on initial open\n // Without this, when the component mounts with isOpen=true, there's no\n // transition because CSS transitions only apply to state changes, not initial renders.\n const [shouldAnimate, setShouldAnimate] = useState(false);\n const hasInitialized = useRef(false);\n\n useEffect(() => {\n if (isOpen && !hasInitialized.current) {\n // First time opening - delay adding open class to trigger animation\n hasInitialized.current = true;\n // Use double rAF to ensure the closed state is painted first\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n setShouldAnimate(true);\n });\n });\n } else if (isOpen) {\n setShouldAnimate(true);\n } else {\n setShouldAnimate(false);\n }\n }, [isOpen]);\n\n // Compute the actual open state for CSS classes\n const isVisuallyOpen = isOpen && shouldAnimate;\n\n // Compute width value\n const widthValue =\n width in WIDTH_PRESETS ? WIDTH_PRESETS[width as keyof typeof WIDTH_PRESETS] : width;\n\n // Internal IDs for ARIA\n const titleId = ariaLabelledBy || \"drawer-title\";\n const subtitleId = ariaDescribedBy || (subtitle ? \"drawer-subtitle\" : undefined);\n\n // Focus management and trap\n useEffect(() => {\n if (!isOpen) return;\n\n // Store previously focused element\n previousActiveElementRef.current = document.activeElement as HTMLElement;\n\n const drawer = drawerRef.current;\n if (!drawer) return;\n\n // Focus close button when drawer opens\n requestAnimationFrame(() => {\n closeButtonRef.current?.focus();\n });\n\n // Setup focus trap\n const focusableElements = drawer.querySelectorAll<HTMLElement>(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])',\n );\n\n const firstFocusable = focusableElements[0];\n const lastFocusable = focusableElements[focusableElements.length - 1];\n\n const handleKeyDown = (e: KeyboardEvent) => {\n // Escape to close, unless the guard blocks it\n if (e.key === \"Escape\") {\n if (blockDismiss()) return;\n onClose();\n return;\n }\n\n // Tab key focus trap\n if (e.key === \"Tab\") {\n if (e.shiftKey) {\n // Shift + Tab\n if (document.activeElement === firstFocusable) {\n e.preventDefault();\n lastFocusable?.focus();\n }\n } else {\n // Tab\n if (document.activeElement === lastFocusable) {\n e.preventDefault();\n firstFocusable?.focus();\n }\n }\n }\n };\n\n drawer.addEventListener(\"keydown\", handleKeyDown);\n\n return () => {\n drawer.removeEventListener(\"keydown\", handleKeyDown);\n // Restore focus when drawer closes\n if (previousActiveElementRef.current) {\n previousActiveElementRef.current.focus();\n }\n };\n }, [isOpen, onClose, blockDismiss]);\n\n // Handle backdrop click\n const handleBackdropClick = useCallback(\n (e: React.MouseEvent) => {\n if (e.target === e.currentTarget) {\n if (blockDismiss()) return;\n onClose();\n }\n },\n [onClose, blockDismiss],\n );\n\n return (\n <div\n className={`drawer__backdrop ${isVisuallyOpen ? \"drawer__backdrop--open\" : \"\"}`}\n onClick={handleBackdropClick}\n role=\"presentation\"\n aria-hidden={!isOpen}\n >\n <aside\n ref={drawerRef}\n className={`drawer ${isVisuallyOpen ? \"drawer--open\" : \"\"} ${\n isShaking ? \"drawer--shaking\" : \"\"\n } ${className}`}\n style={{ width: widthValue, maxWidth: \"100vw\" }}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={titleId}\n aria-describedby={subtitleId}\n onAnimationEnd={handleAnimationEnd}\n >\n {/* Header */}\n <header className=\"drawer__header\">\n <div className=\"drawer__title-row\">\n <h2 id={titleId} className=\"drawer__title\">\n {title}\n </h2>\n <button\n ref={closeButtonRef}\n type=\"button\"\n className=\"drawer__close-btn\"\n onClick={onClose}\n aria-label={closeLabel}\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M18 6L6 18\" />\n <path d=\"M6 6l12 12\" />\n </svg>\n </button>\n </div>\n {subtitle && (\n <p id={subtitleId} className=\"drawer__subtitle\">\n {subtitle}\n </p>\n )}\n </header>\n\n {/* Content */}\n <div className=\"drawer__content\">{children}</div>\n\n {/* Footer */}\n {footer && <footer className=\"drawer__footer\">{footer}</footer>}\n </aside>\n </div>\n );\n}\n"],"mappings":";;;;AA8CA,IAAM,IAAgB;CACpB,QAAQ;CACR,QAAQ;CACR,MAAM;AACR;AAEA,SAAgB,EAAO,EACrB,WACA,YACA,UACA,aACA,WAAQ,UACR,aACA,WACA,eAAY,IACZ,mBACA,oBACA,gBAAa,SACb,oBAAiB,IACjB,uBACc;CACd,IAAM,IAAY,EAAuB,IAAI,GAEvC,CAAC,GAAW,KAAgB,EAAS,EAAK,GAM1C,IAAoB,EAAO,CAAc;CAC/C,EAAkB,UAAU;CAC5B,IAAM,IAAsB,EAAO,CAAgB;CACnD,EAAoB,UAAU;CAG9B,IAAM,IAAe,QACd,EAAkB,WACvB,EAAa,EAAI,GACjB,EAAoB,UAAU,GACvB,MAHgC,IAItC,CAAC,CAAC,GAIC,IAAqB,GAAa,MAA4B;EAClE,AAAI,EAAE,kBAAkB,kBAAgB,EAAa,EAAK;CAC5D,GAAG,CAAC,CAAC,GACC,IAA2B,EAA2B,IAAI,GAC1D,IAAiB,EAA0B,IAAI,GAK/C,CAAC,GAAe,KAAoB,EAAS,EAAK,GAClD,IAAiB,EAAO,EAAK;CAEnC,QAAgB;EACd,AAAI,KAAU,CAAC,EAAe,WAE5B,EAAe,UAAU,IAEzB,4BAA4B;GAC1B,4BAA4B;IAC1B,EAAiB,EAAI;GACvB,CAAC;EACH,CAAC,KAED,EADS,GAGa;CAE1B,GAAG,CAAC,CAAM,CAAC;CAGX,IAAM,IAAiB,KAAU,GAG3B,IACJ,KAAS,IAAgB,EAAc,KAAuC,GAG1E,IAAU,KAAkB,gBAC5B,IAAa,MAAoB,IAAW,oBAAoB,KAAA;CAGtE,QAAgB;EACd,IAAI,CAAC,GAAQ;EAGb,EAAyB,UAAU,SAAS;EAE5C,IAAM,IAAS,EAAU;EACzB,IAAI,CAAC,GAAQ;EAGb,4BAA4B;GAC1B,EAAe,SAAS,MAAM;EAChC,CAAC;EAGD,IAAM,IAAoB,EAAO,iBAC/B,4EACF,GAEM,IAAiB,EAAkB,IACnC,IAAgB,EAAkB,EAAkB,SAAS,IAE7D,KAAiB,MAAqB;GAE1C,IAAI,EAAE,QAAQ,UAAU;IACtB,IAAI,EAAa,GAAG;IACpB,EAAQ;IACR;GACF;GAGA,AAAI,EAAE,QAAQ,UACR,EAAE,WAEA,SAAS,kBAAkB,MAC7B,EAAE,eAAe,GACjB,GAAe,MAAM,KAInB,SAAS,kBAAkB,MAC7B,EAAE,eAAe,GACjB,GAAgB,MAAM;EAI9B;EAIA,OAFA,EAAO,iBAAiB,WAAW,CAAa,SAEnC;GAGX,AAFA,EAAO,oBAAoB,WAAW,CAAa,GAE/C,EAAyB,WAC3B,EAAyB,QAAQ,MAAM;EAE3C;CACF,GAAG;EAAC;EAAQ;EAAS;CAAY,CAAC;CAGlC,IAAM,IAAsB,GACzB,MAAwB;EACvB,IAAI,EAAE,WAAW,EAAE,eAAe;GAChC,IAAI,EAAa,GAAG;GACpB,EAAQ;EACV;CACF,GACA,CAAC,GAAS,CAAY,CACxB;CAEA,OACE,kBAAC,OAAD;EACE,WAAW,oBAAoB,IAAiB,2BAA2B;EAC3E,SAAS;EACT,MAAK;EACL,eAAa,CAAC;EAEd,UAAA,kBAAC,SAAD;GACE,KAAK;GACL,WAAW,UAAU,IAAiB,iBAAiB,GAAG,GACxD,IAAY,oBAAoB,GACjC,GAAG;GACJ,OAAO;IAAE,OAAO;IAAY,UAAU;GAAQ;GAC9C,MAAK;GACL,cAAW;GACX,mBAAiB;GACjB,oBAAkB;GAClB,gBAAgB;GAVlB,UAAA;IAaE,kBAAC,UAAD;KAAQ,WAAU;KAAlB,UAAA,CACE,kBAAC,OAAD;MAAK,WAAU;MAAf,UAAA,CACE,kBAAC,MAAD;OAAI,IAAI;OAAS,WAAU;OACxB,UAAA;MACC,CAAA,GACJ,kBAAC,UAAD;OACE,KAAK;OACL,MAAK;OACL,WAAU;OACV,SAAS;OACT,cAAY;OAEZ,UAAA,kBAAC,OAAD;QACE,OAAM;QACN,QAAO;QACP,SAAQ;QACR,MAAK;QACL,QAAO;QACP,aAAY;QACZ,eAAc;QACd,gBAAe;QARjB,UAAA,CAUE,kBAAC,QAAD,EAAM,GAAE,aAAc,CAAA,GACtB,kBAAC,QAAD,EAAM,GAAE,aAAc,CAAA,CACnB;;MACC,CAAA,CACL;KACJ,CAAA,GAAA,KACC,kBAAC,KAAD;MAAG,IAAI;MAAY,WAAU;MAC1B,UAAA;KACA,CAAA,CAEC;;IAGR,kBAAC,OAAD;KAAK,WAAU;KAAmB;IAAc,CAAA;IAG/C,KAAU,kBAAC,UAAD;KAAQ,WAAU;KAAkB,UAAA;IAAe,CAAA;GACzD;;CACJ,CAAA;AAET"}
|
|
1
|
+
{"version":3,"file":"Drawer2.js","names":[],"sources":["../../../src/components/Drawer/Drawer.tsx"],"sourcesContent":["/**\n * Drawer Component\n *\n * A reusable right-side drawer panel with backdrop, animations, and accessibility features.\n * Consolidates common patterns from all drawer implementations.\n */\n\nimport { useEffect, useRef, useCallback, useState } from \"react\";\nimport \"./Drawer.css\";\n\nexport interface DrawerProps {\n /** Whether the drawer is open */\n isOpen: boolean;\n /** Callback when drawer should close */\n onClose: () => void;\n /** Drawer title */\n title: React.ReactNode;\n /** Optional subtitle */\n subtitle?: React.ReactNode;\n /** Drawer width preset or custom CSS width value */\n width?: \"narrow\" | \"medium\" | \"wide\" | (string & Record<never, never>);\n /** Drawer content */\n children: React.ReactNode;\n /** Optional footer content */\n footer?: React.ReactNode;\n /** Additional CSS class for drawer panel */\n className?: string;\n /** ARIA labelledby ID (defaults to internal title ID) */\n ariaLabelledBy?: string;\n /** ARIA describedby ID (defaults to internal subtitle ID if subtitle exists) */\n ariaDescribedBy?: string;\n /** Accessible label for the close button. Default: \"Close\" */\n closeLabel?: string;\n /**\n * Refuse dismissal by Escape or a backdrop click, shaking the panel instead.\n * The close button and any footer control still call `onClose` directly, so\n * route those through the same guard when they also need confirming.\n */\n preventDismiss?: boolean;\n /**\n * Called when `preventDismiss` blocked a dismissal, so the consumer can offer\n * an explicit way out (a discard confirmation, for instance).\n */\n onDismissAttempt?: () => void;\n}\n\nconst WIDTH_PRESETS = {\n narrow: \"400px\",\n medium: \"520px\",\n wide: \"900px\",\n};\n\nexport function Drawer({\n isOpen,\n onClose,\n title,\n subtitle,\n width = \"medium\",\n children,\n footer,\n className = \"\",\n ariaLabelledBy,\n ariaDescribedBy,\n closeLabel = \"Close\",\n preventDismiss = false,\n onDismissAttempt,\n}: DrawerProps) {\n const drawerRef = useRef<HTMLDivElement>(null);\n\n const [isShaking, setIsShaking] = useState(false);\n\n // Read the guard through refs so `blockDismiss` keeps a stable identity. The\n // focus-management effect depends on it, and if the identity changed the\n // moment the consumer marks the drawer dirty, that effect would re-run on the\n // first keystroke and steal focus from the field being typed into.\n const preventDismissRef = useRef(preventDismiss);\n preventDismissRef.current = preventDismiss;\n const onDismissAttemptRef = useRef(onDismissAttempt);\n onDismissAttemptRef.current = onDismissAttempt;\n\n /** Blocks a dismissal when the guard is on. Returns whether it blocked. */\n const blockDismiss = useCallback(() => {\n if (!preventDismissRef.current) return false;\n setIsShaking(true);\n onDismissAttemptRef.current?.();\n return true;\n }, []);\n\n // Guarded on the animation name so an unrelated animation on the panel does\n // not clear the flag early.\n const handleAnimationEnd = useCallback((e: React.AnimationEvent) => {\n if (e.animationName === \"drawer-shake\") setIsShaking(false);\n }, []);\n const previousActiveElementRef = useRef<HTMLElement | null>(null);\n const closeButtonRef = useRef<HTMLButtonElement>(null);\n\n // Track if this is the first render to enable animation on initial open\n // Without this, when the component mounts with isOpen=true, there's no\n // transition because CSS transitions only apply to state changes, not initial renders.\n const [shouldAnimate, setShouldAnimate] = useState(false);\n const hasInitialized = useRef(false);\n\n useEffect(() => {\n if (isOpen && !hasInitialized.current) {\n // First time opening - delay adding open class to trigger animation\n hasInitialized.current = true;\n // Use double rAF to ensure the closed state is painted first\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n setShouldAnimate(true);\n });\n });\n } else if (isOpen) {\n setShouldAnimate(true);\n } else {\n setShouldAnimate(false);\n }\n }, [isOpen]);\n\n // Compute the actual open state for CSS classes\n const isVisuallyOpen = isOpen && shouldAnimate;\n\n // Compute width value\n const widthValue =\n width in WIDTH_PRESETS ? WIDTH_PRESETS[width as keyof typeof WIDTH_PRESETS] : width;\n\n // Internal IDs for ARIA\n const titleId = ariaLabelledBy || \"drawer-title\";\n const subtitleId = ariaDescribedBy || (subtitle ? \"drawer-subtitle\" : undefined);\n\n // Focus management and trap\n useEffect(() => {\n if (!isOpen) return;\n\n // Store previously focused element\n previousActiveElementRef.current = document.activeElement as HTMLElement;\n\n const drawer = drawerRef.current;\n if (!drawer) return;\n\n // Focus close button when drawer opens\n requestAnimationFrame(() => {\n closeButtonRef.current?.focus();\n });\n\n // Setup focus trap\n const focusableElements = drawer.querySelectorAll<HTMLElement>(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])',\n );\n\n const firstFocusable = focusableElements[0];\n const lastFocusable = focusableElements[focusableElements.length - 1];\n\n const handleKeyDown = (e: KeyboardEvent) => {\n // Escape to close, unless the guard blocks it\n if (e.key === \"Escape\") {\n if (blockDismiss()) return;\n onClose();\n return;\n }\n\n // Tab key focus trap\n if (e.key === \"Tab\") {\n if (e.shiftKey) {\n // Shift + Tab\n if (document.activeElement === firstFocusable) {\n e.preventDefault();\n lastFocusable?.focus();\n }\n } else {\n // Tab\n if (document.activeElement === lastFocusable) {\n e.preventDefault();\n firstFocusable?.focus();\n }\n }\n }\n };\n\n drawer.addEventListener(\"keydown\", handleKeyDown);\n\n return () => {\n drawer.removeEventListener(\"keydown\", handleKeyDown);\n // Restore focus when drawer closes\n if (previousActiveElementRef.current) {\n previousActiveElementRef.current.focus();\n }\n };\n }, [isOpen, onClose, blockDismiss]);\n\n // Handle backdrop click\n const handleBackdropClick = useCallback(\n (e: React.MouseEvent) => {\n if (e.target === e.currentTarget) {\n if (blockDismiss()) return;\n onClose();\n }\n },\n [onClose, blockDismiss],\n );\n\n return (\n <div\n className={`drawer__backdrop ${isVisuallyOpen ? \"drawer__backdrop--open\" : \"\"}`}\n onClick={handleBackdropClick}\n role=\"presentation\"\n aria-hidden={!isOpen}\n // `aria-hidden` takes the closed drawer out of the accessibility tree but\n // leaves its focusable children in the tab sequence, so a keyboard user\n // tabs into an invisible form. `inert` removes both.\n inert={!isOpen}\n >\n <aside\n ref={drawerRef}\n className={`drawer ${isVisuallyOpen ? \"drawer--open\" : \"\"} ${\n isShaking ? \"drawer--shaking\" : \"\"\n } ${className}`}\n style={{ width: widthValue, maxWidth: \"100vw\" }}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={titleId}\n aria-describedby={subtitleId}\n onAnimationEnd={handleAnimationEnd}\n >\n {/* Header */}\n <header className=\"drawer__header\">\n <div className=\"drawer__title-row\">\n <h2 id={titleId} className=\"drawer__title\">\n {title}\n </h2>\n <button\n ref={closeButtonRef}\n type=\"button\"\n className=\"drawer__close-btn\"\n onClick={onClose}\n aria-label={closeLabel}\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M18 6L6 18\" />\n <path d=\"M6 6l12 12\" />\n </svg>\n </button>\n </div>\n {subtitle && (\n <p id={subtitleId} className=\"drawer__subtitle\">\n {subtitle}\n </p>\n )}\n </header>\n\n {/* Content */}\n <div className=\"drawer__content\">{children}</div>\n\n {/* Footer */}\n {footer && <footer className=\"drawer__footer\">{footer}</footer>}\n </aside>\n </div>\n );\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED