@apliteni/apliteni-ui 0.25.2 → 0.26.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/package.json +1 -1
- package/react/README.md +37 -0
- package/react/dist/index.d.ts +25 -6
- package/react/dist/index.js +57 -27
package/package.json
CHANGED
package/react/README.md
CHANGED
|
@@ -31,6 +31,14 @@ import { DataTable, Modal, Button } from '@apliteni/apliteni-ui/react';
|
|
|
31
31
|
|
|
32
32
|
Components: `DataTable`, `Modal`, `Button`, `Badge`, `Card`, `Icon`.
|
|
33
33
|
|
|
34
|
+
## What the Modal does with focus
|
|
35
|
+
|
|
36
|
+
Opening moves focus to the first eligible control in the body, in DOM order, or to
|
|
37
|
+
the dialog itself if none exists. Links and disclosure summaries are eligible; hidden
|
|
38
|
+
controls, disabled controls, controls inside a closed disclosure and elements with a
|
|
39
|
+
negative tabindex are skipped. Tab and Shift+Tab wrap at the ends of the same list.
|
|
40
|
+
Escape and a click on the scrim dismiss the dialog and return focus to its opener.
|
|
41
|
+
|
|
34
42
|
## Work on them
|
|
35
43
|
|
|
36
44
|
From the repo root — one `npm install` covers the workspace:
|
|
@@ -56,3 +64,32 @@ The bare `@apliteni/apliteni-ui` specifier in this source resolves to the kit it
|
|
|
56
64
|
once installed. In the repo there is no copy to resolve to, so `kit-alias.ts` points
|
|
57
65
|
vitest and Storybook straight at `../src/` — which is why the class-name parity tests
|
|
58
66
|
now compare against the working tree rather than the last published release.
|
|
67
|
+
|
|
68
|
+
### Tables with another presentation of the same rows
|
|
69
|
+
|
|
70
|
+
Pass `selectable={false}` to omit selection controls and their callbacks. Existing callers
|
|
71
|
+
that supply selection callbacks retain the checkbox column by default.
|
|
72
|
+
|
|
73
|
+
`sort` and `onSortChange` make sorting controlled. Both presentations can use the exported
|
|
74
|
+
`sortTableRows` helper, so initial order, stable ties and later changes agree:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
const [sort, setSort] = useState<TableSort<Row>>({ key: 'name', dir: -1 });
|
|
78
|
+
const ordered = sortTableRows(rows, sort);
|
|
79
|
+
|
|
80
|
+
<DataTable columns={columns} rows={rows} selectable={false}
|
|
81
|
+
sort={sort} onSortChange={setSort} />
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Render the sibling list from `ordered`. Omit `sort` to keep the table's own state;
|
|
85
|
+
`onSortChange` can also observe that uncontrolled state. Set `key: undefined` to preserve
|
|
86
|
+
input order. Import `TableSort` and `sortTableRows` from `@apliteni/apliteni-ui/react`.
|
|
87
|
+
|
|
88
|
+
Changing the sort returns the table to its first page. The comparator uses JavaScript
|
|
89
|
+
`<` and `>`; use consistently typed, comparable values in sortable columns. Ordering of
|
|
90
|
+
mixed types, missing values and `NaN` is not guaranteed. `sortTableRows` always returns
|
|
91
|
+
a new array, including when `key` is `undefined`.
|
|
92
|
+
|
|
93
|
+
Choose controlled or uncontrolled once per table. Passing `sort` for a while and then
|
|
94
|
+
dropping it is not supported: the table falls back to the sort state it started with, not to
|
|
95
|
+
the one it was last given.
|
package/react/dist/index.d.ts
CHANGED
|
@@ -45,17 +45,36 @@ type Column<T> = {
|
|
|
45
45
|
sortable?: boolean;
|
|
46
46
|
render?: (row: T) => ReactNode;
|
|
47
47
|
};
|
|
48
|
-
type
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
type TableSort<T> = {
|
|
49
|
+
key: (keyof T & string) | undefined;
|
|
50
|
+
dir: 1 | -1;
|
|
51
|
+
};
|
|
52
|
+
type SelectionProps = {
|
|
53
|
+
selectable: false;
|
|
54
|
+
selected?: Set<string>;
|
|
55
|
+
onToggle?: (name: string) => void;
|
|
56
|
+
onTogglePage?: (names: string[]) => void;
|
|
57
|
+
} | {
|
|
58
|
+
selectable?: true;
|
|
52
59
|
selected: Set<string>;
|
|
53
60
|
onToggle: (name: string) => void;
|
|
54
61
|
onTogglePage: (names: string[]) => void;
|
|
55
62
|
};
|
|
63
|
+
type DataTableProps<T> = {
|
|
64
|
+
columns: Column<T>[];
|
|
65
|
+
rows: T[];
|
|
66
|
+
pageSize?: number;
|
|
67
|
+
} & SelectionProps & ({
|
|
68
|
+
sort?: never;
|
|
69
|
+
onSortChange?: (sort: TableSort<T>) => void;
|
|
70
|
+
} | {
|
|
71
|
+
sort: TableSort<T>;
|
|
72
|
+
onSortChange: (sort: TableSort<T>) => void;
|
|
73
|
+
});
|
|
74
|
+
declare function sortTableRows<T>(rows: T[], sort: TableSort<T>): T[];
|
|
56
75
|
declare function DataTable<T extends {
|
|
57
76
|
name: string;
|
|
58
|
-
}>({ columns, rows, pageSize, selected, onToggle, onTogglePage, }: DataTableProps<T>): react.JSX.Element;
|
|
77
|
+
}>({ columns, rows, pageSize, selectable, selected, onToggle, onTogglePage, sort: controlledSort, onSortChange, }: DataTableProps<T>): react.JSX.Element;
|
|
59
78
|
|
|
60
79
|
type SkeletonProps = {
|
|
61
80
|
/** A count of bars, or explicit widths when a ragged prose edge matters. */
|
|
@@ -96,4 +115,4 @@ type DeniedProps = {
|
|
|
96
115
|
};
|
|
97
116
|
declare function Denied({ title, sub, need, icon, className, children, }: DeniedProps): react.JSX.Element;
|
|
98
117
|
|
|
99
|
-
export { Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, DataTable, type DataTableProps, Denied, type DeniedProps, Icon, Modal, type ModalProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps };
|
|
118
|
+
export { Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, DataTable, type DataTableProps, Denied, type DeniedProps, Icon, Modal, type ModalProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, type TableSort, sortTableRows };
|
package/react/dist/index.js
CHANGED
|
@@ -84,7 +84,25 @@ function Card({ title, sub, children }) {
|
|
|
84
84
|
import { useEffect, useRef } from "react";
|
|
85
85
|
import { createPortal } from "react-dom";
|
|
86
86
|
import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
87
|
-
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
87
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), details > summary:first-of-type';
|
|
88
|
+
function tabbable(el) {
|
|
89
|
+
const tabindex = el.getAttribute("tabindex");
|
|
90
|
+
if (tabindex !== null && Number(tabindex) < 0) return false;
|
|
91
|
+
if (el.matches(":disabled")) return false;
|
|
92
|
+
for (let node = el; node; node = node.parentElement) {
|
|
93
|
+
if (node.inert || node.hasAttribute("inert") || node.hasAttribute("hidden")) return false;
|
|
94
|
+
const holder = node.parentElement;
|
|
95
|
+
const folded = holder?.tagName === "DETAILS" && !holder.open;
|
|
96
|
+
if (folded && node !== holder.querySelector(":scope > summary")) return false;
|
|
97
|
+
}
|
|
98
|
+
return typeof el.checkVisibility !== "function" || el.checkVisibility({ visibilityProperty: true });
|
|
99
|
+
}
|
|
100
|
+
var tabbablesIn = (root) => Array.from(root.querySelectorAll(FOCUSABLE)).filter(tabbable);
|
|
101
|
+
var dismissOnScrim = (onClose) => (e) => {
|
|
102
|
+
if (e.target !== e.currentTarget) return;
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
onClose();
|
|
105
|
+
};
|
|
88
106
|
function Modal({ open, title, onClose, footer, children }) {
|
|
89
107
|
const panel = useRef(null);
|
|
90
108
|
useEffect(() => {
|
|
@@ -95,7 +113,7 @@ function Modal({ open, title, onClose, footer, children }) {
|
|
|
95
113
|
return;
|
|
96
114
|
}
|
|
97
115
|
if (e.key !== "Tab" || !panel.current) return;
|
|
98
|
-
const items =
|
|
116
|
+
const items = tabbablesIn(panel.current);
|
|
99
117
|
if (items.length === 0) {
|
|
100
118
|
e.preventDefault();
|
|
101
119
|
panel.current.focus();
|
|
@@ -112,7 +130,7 @@ function Modal({ open, title, onClose, footer, children }) {
|
|
|
112
130
|
if (!e.shiftKey && active === last) {
|
|
113
131
|
e.preventDefault();
|
|
114
132
|
first.focus();
|
|
115
|
-
} else if (e.shiftKey && active === first) {
|
|
133
|
+
} else if (e.shiftKey && (active === first || active === panel.current)) {
|
|
116
134
|
e.preventDefault();
|
|
117
135
|
last.focus();
|
|
118
136
|
}
|
|
@@ -133,16 +151,13 @@ function Modal({ open, title, onClose, footer, children }) {
|
|
|
133
151
|
}, [open]);
|
|
134
152
|
useEffect(() => {
|
|
135
153
|
if (!open) return;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
) || panel.current;
|
|
154
|
+
const body = panel.current?.querySelector(".rx-modal__body");
|
|
155
|
+
const target = (body ? tabbablesIn(body) : [])[0] || panel.current;
|
|
139
156
|
target?.focus();
|
|
140
157
|
}, [open]);
|
|
141
158
|
if (!open) return null;
|
|
142
159
|
return createPortal(
|
|
143
|
-
/* @__PURE__ */ jsx5("div", { className: "rx-scrim", onMouseDown: (
|
|
144
|
-
if (e.target === e.currentTarget) onClose();
|
|
145
|
-
}, children: /* @__PURE__ */ jsxs3("div", { className: "rx-modal", role: "dialog", "aria-modal": "true", "aria-label": title, tabIndex: -1, ref: panel, children: [
|
|
160
|
+
/* @__PURE__ */ jsx5("div", { className: "rx-scrim", onMouseDown: dismissOnScrim(onClose), children: /* @__PURE__ */ jsxs3("div", { className: "rx-modal", role: "dialog", "aria-modal": "true", "aria-label": title, tabIndex: -1, ref: panel, children: [
|
|
146
161
|
/* @__PURE__ */ jsxs3("div", { className: "rx-modal__head", children: [
|
|
147
162
|
/* @__PURE__ */ jsx5("div", { className: "rx-modal__title", children: title }),
|
|
148
163
|
/* @__PURE__ */ jsx5(Button, { variant: "ghost", size: "sm", iconOnly: true, icon: "x", "aria-label": "Close", onClick: onClose })
|
|
@@ -157,36 +172,50 @@ function Modal({ open, title, onClose, footer, children }) {
|
|
|
157
172
|
// src/DataTable.tsx
|
|
158
173
|
import { useMemo, useState } from "react";
|
|
159
174
|
import { Fragment, jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
175
|
+
function sortTableRows(rows, sort) {
|
|
176
|
+
if (sort.key === void 0) return [...rows];
|
|
177
|
+
const key = sort.key;
|
|
178
|
+
return [...rows].sort((a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0) * sort.dir);
|
|
179
|
+
}
|
|
160
180
|
function DataTable({
|
|
161
181
|
columns,
|
|
162
182
|
rows,
|
|
163
183
|
pageSize = 4,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
184
|
+
selectable = true,
|
|
185
|
+
selected = /* @__PURE__ */ new Set(),
|
|
186
|
+
onToggle = () => {
|
|
187
|
+
},
|
|
188
|
+
onTogglePage = () => {
|
|
189
|
+
},
|
|
190
|
+
sort: controlledSort,
|
|
191
|
+
onSortChange
|
|
167
192
|
}) {
|
|
168
|
-
const [
|
|
193
|
+
const [localSort, setLocalSort] = useState(
|
|
169
194
|
{ key: columns.find((c) => c.sortable)?.key, dir: -1 }
|
|
170
195
|
);
|
|
196
|
+
const sort = controlledSort ?? localSort;
|
|
171
197
|
const [page, setPage] = useState(0);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
198
|
+
const [pagedSort, setPagedSort] = useState(sort);
|
|
199
|
+
if (pagedSort.key !== sort.key || pagedSort.dir !== sort.dir) {
|
|
200
|
+
setPagedSort(sort);
|
|
201
|
+
setPage(0);
|
|
202
|
+
}
|
|
203
|
+
const sorted = useMemo(() => sortTableRows(rows, sort), [rows, sort.key, sort.dir]);
|
|
177
204
|
const pages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
|
178
205
|
const safePage = Math.min(page, pages - 1);
|
|
179
206
|
const slice = sorted.slice(safePage * pageSize, safePage * pageSize + pageSize);
|
|
180
|
-
const onSort = (
|
|
181
|
-
|
|
207
|
+
const onSort = (key) => {
|
|
208
|
+
const next = sort.key === key ? { key, dir: sort.dir === 1 ? -1 : 1 } : { key, dir: -1 };
|
|
209
|
+
if (controlledSort === void 0) setLocalSort(next);
|
|
210
|
+
onSortChange?.(next);
|
|
182
211
|
setPage(0);
|
|
183
212
|
};
|
|
184
213
|
const caret = (k) => sort.key === k ? sort.dir === 1 ? " \u25B2" : " \u25BC" : " \u2195";
|
|
185
|
-
const pageAllOn = slice.length > 0 && slice.every((r) => selected.has(r.name));
|
|
214
|
+
const pageAllOn = selectable && slice.length > 0 && slice.every((r) => selected.has(r.name));
|
|
186
215
|
return /* @__PURE__ */ jsxs4(Fragment, { children: [
|
|
187
216
|
/* @__PURE__ */ jsxs4("table", { className: "ui-table ui-table--hover ui-table--zebra", children: [
|
|
188
217
|
/* @__PURE__ */ jsx6("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
|
|
189
|
-
/* @__PURE__ */ jsx6("th", { scope: "col", children: /* @__PURE__ */ jsx6(
|
|
218
|
+
selectable ? /* @__PURE__ */ jsx6("th", { scope: "col", children: /* @__PURE__ */ jsx6(
|
|
190
219
|
"input",
|
|
191
220
|
{
|
|
192
221
|
type: "checkbox",
|
|
@@ -194,7 +223,7 @@ function DataTable({
|
|
|
194
223
|
"aria-label": "Select all rows on this page",
|
|
195
224
|
onChange: () => onTogglePage(slice.map((r) => r.name))
|
|
196
225
|
}
|
|
197
|
-
) }),
|
|
226
|
+
) }) : null,
|
|
198
227
|
columns.map((c) => (
|
|
199
228
|
// The sort control is a real <button> inside the header cell. It used to be
|
|
200
229
|
// role="button" ON the <th>, which threw away the columnheader role and put
|
|
@@ -204,7 +233,7 @@ function DataTable({
|
|
|
204
233
|
{
|
|
205
234
|
scope: "col",
|
|
206
235
|
className: [c.num && "ui-table__num", c.sortable && "rx-sortable"].filter(Boolean).join(" "),
|
|
207
|
-
"aria-sort":
|
|
236
|
+
"aria-sort": sort.key === c.key ? sort.dir === 1 ? "ascending" : "descending" : c.sortable ? "none" : void 0,
|
|
208
237
|
children: c.sortable ? /* @__PURE__ */ jsxs4("button", { type: "button", className: "rx-sort", onClick: () => onSort(c.key), children: [
|
|
209
238
|
c.label,
|
|
210
239
|
/* @__PURE__ */ jsx6("span", { className: "rx-caret", "aria-hidden": "true", children: caret(c.key) })
|
|
@@ -215,7 +244,7 @@ function DataTable({
|
|
|
215
244
|
))
|
|
216
245
|
] }) }),
|
|
217
246
|
/* @__PURE__ */ jsx6("tbody", { children: slice.map((r) => /* @__PURE__ */ jsxs4("tr", { children: [
|
|
218
|
-
/* @__PURE__ */ jsx6("td", { children: /* @__PURE__ */ jsx6(
|
|
247
|
+
selectable ? /* @__PURE__ */ jsx6("td", { children: /* @__PURE__ */ jsx6(
|
|
219
248
|
"input",
|
|
220
249
|
{
|
|
221
250
|
type: "checkbox",
|
|
@@ -223,7 +252,7 @@ function DataTable({
|
|
|
223
252
|
"aria-label": `Select ${r.name}`,
|
|
224
253
|
onChange: () => onToggle(r.name)
|
|
225
254
|
}
|
|
226
|
-
) }),
|
|
255
|
+
) }) : null,
|
|
227
256
|
columns.map((c) => /* @__PURE__ */ jsx6("td", { className: c.num ? "ui-table__num" : void 0, children: c.render ? c.render(r) : String(r[c.key]) }, c.key))
|
|
228
257
|
] }, r.name)) })
|
|
229
258
|
] }),
|
|
@@ -342,5 +371,6 @@ export {
|
|
|
342
371
|
Icon,
|
|
343
372
|
Modal,
|
|
344
373
|
Skeleton,
|
|
345
|
-
SkeletonTable
|
|
374
|
+
SkeletonTable,
|
|
375
|
+
sortTableRows
|
|
346
376
|
};
|