@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.25.2",
3
+ "version": "0.26.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
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.
@@ -45,17 +45,36 @@ type Column<T> = {
45
45
  sortable?: boolean;
46
46
  render?: (row: T) => ReactNode;
47
47
  };
48
- type DataTableProps<T> = {
49
- columns: Column<T>[];
50
- rows: T[];
51
- pageSize?: number;
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 };
@@ -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 = Array.from(panel.current.querySelectorAll(FOCUSABLE));
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 target = panel.current?.querySelector(
137
- ".rx-modal__body input, .rx-modal__body select, .rx-modal__body textarea, .rx-modal__body button"
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: (e) => {
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
- selected,
165
- onToggle,
166
- onTogglePage
184
+ selectable = true,
185
+ selected = /* @__PURE__ */ new Set(),
186
+ onToggle = () => {
187
+ },
188
+ onTogglePage = () => {
189
+ },
190
+ sort: controlledSort,
191
+ onSortChange
167
192
  }) {
168
- const [sort, setSort] = useState(
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 sorted = useMemo(() => {
173
- if (!sort.key) return rows;
174
- const k = sort.key;
175
- return [...rows].sort((a, b) => (a[k] > b[k] ? 1 : a[k] < b[k] ? -1 : 0) * sort.dir);
176
- }, [rows, sort]);
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 = (k) => {
181
- setSort((s) => s.key === k ? { key: k, dir: s.dir === 1 ? -1 : 1 } : { key: k, dir: -1 });
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": c.sortable ? sort.key === c.key ? sort.dir === 1 ? "ascending" : "descending" : "none" : void 0,
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
  };