nexus-shared 4.0.0 → 4.1.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +220 -14
  3. package/dist/Client.Index.d.ts +13 -0
  4. package/dist/Client.Index.js +13 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  22. package/dist/Components/Documents/TabButtons.js +5 -4
  23. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  24. package/dist/Components/Domains/DomainCard.js +182 -0
  25. package/dist/Components/Forms/ApiForm.js +5 -1
  26. package/dist/Components/Forms/Crud.js +72 -24
  27. package/dist/Components/Forms/CrudActions.d.ts +5 -4
  28. package/dist/Components/Forms/CrudActions.js +42 -12
  29. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  30. package/dist/Components/Forms/CrudHistory.js +14 -6
  31. package/dist/Components/Forms/CrudParts.d.ts +11 -4
  32. package/dist/Components/Forms/CrudParts.js +76 -10
  33. package/dist/Components/Forms/CrudRowForms.js +65 -5
  34. package/dist/Components/Forms/CrudTree.js +7 -7
  35. package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
  36. package/dist/Components/Forms/CrudWorkspace.js +19 -11
  37. package/dist/Components/Forms/Form.d.ts +3 -2
  38. package/dist/Components/Forms/Form.js +33 -26
  39. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  40. package/dist/Components/Forms/FormQuestion.js +28 -0
  41. package/dist/Components/Forms/SubmitForm.js +2 -15
  42. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  43. package/dist/Components/Inputs/ComputedInput.js +179 -0
  44. package/dist/Components/Inputs/Dropdown.js +4 -2
  45. package/dist/Components/Inputs/GroupForm.js +3 -2
  46. package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
  47. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  48. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  49. package/dist/Components/Inputs/PictureInput.js +8 -3
  50. package/dist/Components/Inputs/RowsInput.d.ts +14 -1
  51. package/dist/Components/Inputs/RowsInput.js +78 -5
  52. package/dist/Components/Inputs/TabularForm.js +16 -3
  53. package/dist/Components/Inputs/TreeView.d.ts +24 -1
  54. package/dist/Components/Inputs/TreeView.js +84 -7
  55. package/dist/Components/Layouts/Popup.d.ts +22 -2
  56. package/dist/Components/Layouts/Popup.js +108 -6
  57. package/dist/Components/Messages/MailClient.d.ts +50 -0
  58. package/dist/Components/Messages/MailClient.js +410 -0
  59. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  60. package/dist/Components/Messages/MailColumns.js +92 -0
  61. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  62. package/dist/Components/Messages/MailCompose.js +154 -0
  63. package/dist/Components/Messages/MailReader.d.ts +43 -0
  64. package/dist/Components/Messages/MailReader.js +207 -0
  65. package/dist/Components/Messages/MailThread.d.ts +71 -0
  66. package/dist/Components/Messages/MailThread.js +185 -0
  67. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  68. package/dist/Components/Messages/MessageBell.js +76 -0
  69. package/dist/Components/Viewers/DataTable.js +7 -5
  70. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  71. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  72. package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
  73. package/dist/Components/Viewers/DataTableParts.js +2 -2
  74. package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
  75. package/dist/Components/Viewers/OwnerFiles.js +142 -31
  76. package/dist/Helpers/ApiModules.js +4 -0
  77. package/dist/Helpers/AuditEntries.d.ts +50 -0
  78. package/dist/Helpers/AuditEntries.js +96 -0
  79. package/dist/Helpers/ChatBackend.d.ts +8 -1
  80. package/dist/Helpers/ChatBackend.js +18 -0
  81. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  82. package/dist/Helpers/ChatHelpers.js +4 -1
  83. package/dist/Helpers/CrudColumns.d.ts +44 -6
  84. package/dist/Helpers/CrudColumns.js +63 -13
  85. package/dist/Helpers/CrudHelpers.d.ts +36 -1
  86. package/dist/Helpers/CrudHelpers.js +61 -0
  87. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  88. package/dist/Helpers/CrudPickerPlan.js +46 -0
  89. package/dist/Helpers/CrudRowForms.d.ts +18 -1
  90. package/dist/Helpers/CrudRowForms.js +35 -2
  91. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  92. package/dist/Helpers/DashboardHelpers.js +305 -0
  93. package/dist/Helpers/DomainCards.d.ts +140 -0
  94. package/dist/Helpers/DomainCards.js +370 -0
  95. package/dist/Helpers/FileAdapter.d.ts +82 -2
  96. package/dist/Helpers/FileAdapter.js +72 -1
  97. package/dist/Helpers/FormStore.d.ts +11 -1
  98. package/dist/Helpers/FormStore.js +66 -19
  99. package/dist/Helpers/InputConditions.d.ts +6 -0
  100. package/dist/Helpers/InputConditions.js +24 -0
  101. package/dist/Helpers/InputMemory.js +40 -30
  102. package/dist/Helpers/InputNarrowing.d.ts +38 -6
  103. package/dist/Helpers/InputNarrowing.js +102 -21
  104. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  105. package/dist/Helpers/MailClientHelpers.js +211 -0
  106. package/dist/Helpers/MessageBackend.d.ts +152 -0
  107. package/dist/Helpers/MessageBackend.js +329 -0
  108. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  109. package/dist/Helpers/MessageHelpers.js +573 -0
  110. package/dist/Helpers/MessageUnread.d.ts +95 -0
  111. package/dist/Helpers/MessageUnread.js +245 -0
  112. package/dist/Helpers/NexusDriveAdapter.d.ts +17 -1
  113. package/dist/Helpers/NexusDriveAdapter.js +32 -3
  114. package/dist/Helpers/NumberHelpers.d.ts +61 -0
  115. package/dist/Helpers/NumberHelpers.js +124 -8
  116. package/dist/Helpers/PagedPicker.d.ts +42 -0
  117. package/dist/Helpers/PagedPicker.js +85 -0
  118. package/dist/Helpers/RowsStore.d.ts +24 -0
  119. package/dist/Helpers/RowsStore.js +79 -10
  120. package/dist/Helpers/TableHelpers.d.ts +15 -2
  121. package/dist/Helpers/TableHelpers.js +28 -1
  122. package/dist/Helpers/TableStore.d.ts +7 -3
  123. package/dist/Helpers/TableStore.js +9 -4
  124. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  125. package/dist/Helpers/ToolbarFit.js +30 -0
  126. package/dist/Helpers/TreeStore.d.ts +75 -6
  127. package/dist/Helpers/TreeStore.js +182 -18
  128. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  129. package/dist/Interfaces/ApiInterfaces.js +10 -1
  130. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  131. package/dist/Interfaces/CrudInterfaces.d.ts +78 -5
  132. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  133. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  134. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  135. package/dist/Interfaces/DomainInterfaces.js +1 -0
  136. package/dist/Interfaces/FileInterfaces.d.ts +21 -0
  137. package/dist/Interfaces/FileInterfaces.js +2 -0
  138. package/dist/Interfaces/FormInterfaces.d.ts +78 -11
  139. package/dist/Interfaces/InputInterfaces.d.ts +12 -5
  140. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  141. package/dist/Interfaces/MailInterfaces.js +1 -0
  142. package/dist/Interfaces/TableInterfaces.d.ts +28 -2
  143. package/dist/Services/JoinSources.d.ts +47 -6
  144. package/dist/Services/JoinSources.js +68 -33
  145. package/dist/Services/MessageApi.d.ts +18 -0
  146. package/dist/Services/MessageApi.js +64 -0
  147. package/dist/Shared.Index.d.ts +12 -0
  148. package/dist/Shared.Index.js +12 -0
  149. package/package.json +7 -1
  150. package/src/Styles/Nexus.Button.css +29 -0
  151. package/src/Styles/Nexus.Chat.css +114 -22
  152. package/src/Styles/Nexus.Crud.css +119 -28
  153. package/src/Styles/Nexus.Dashboard.css +763 -0
  154. package/src/Styles/Nexus.Domain.css +384 -0
  155. package/src/Styles/Nexus.Editor.css +20 -0
  156. package/src/Styles/Nexus.Files.css +27 -0
  157. package/src/Styles/Nexus.Index.css +3 -0
  158. package/src/Styles/Nexus.Input.css +31 -0
  159. package/src/Styles/Nexus.Mail.css +1062 -0
  160. package/src/Styles/Nexus.Popup.css +58 -0
  161. package/src/Styles/Nexus.Table.css +1458 -1449
  162. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  163. package/src/Styles/Nexus.Tree.css +10 -0
@@ -1,14 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { iconAlertCircle, iconArrowBackUp, iconPlus, InlineIcon } from "nexus-icons";
4
- import { memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
4
+ import { memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
6
  import { useDatePreferences } from "../../Helpers/DatePreferences.js";
7
7
  import { browserInputMemory, FORM_SAVED_EVENT } from "../../Helpers/InputMemory.js";
8
+ import { narrowingValue, narrowInputParamsBy } from "../../Helpers/InputNarrowing.js";
8
9
  import { inputFieldName } from "../../Helpers/InputParamsHelpers.js";
9
10
  import { RowsStore } from "../../Helpers/RowsStore.js";
10
11
  import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
11
12
  import { Button } from "../Documents/Button.js";
13
+ import { ComputedInput } from "./ComputedInput.js";
12
14
  import { hasContent } from "./Field.js";
13
15
  import { isFirstInvalid } from "./InputField.js";
14
16
  import { renderInput } from "./InputRenderer.js";
@@ -194,9 +196,43 @@ export function useRowsInput(props, fallbackItem, layoutRef) {
194
196
  const row = store.row(key);
195
197
  return row ? ask(row, name, index) : undefined;
196
198
  }, [store, rowLock]);
199
+ // The form's values the rows follow (`narrowedBy` `from: "form"`): when one changes, every row draws again, narrowed afresh.
200
+ const formKey = formValuesKey(props.formValues);
201
+ const shownFormKey = useRef(formKey);
202
+ useLayoutEffect(() => {
203
+ if (shownFormKey.current === formKey)
204
+ return;
205
+ shownFormKey.current = formKey;
206
+ store.redrawRows();
207
+ }, [store, formKey]);
208
+ // Row inputs worked out from others (`compute`) read the rows' `computeWith` as they draw: a new one draws every row.
209
+ const computeWith = useCallback(() => latest.current.props.computeWith, []);
210
+ const shownComputeWith = useRef(props.computeWith);
211
+ useLayoutEffect(() => {
212
+ if (Object.is(shownComputeWith.current, props.computeWith))
213
+ return;
214
+ shownComputeWith.current = props.computeWith;
215
+ if (inputs.some(params => params.compute))
216
+ store.redrawRows();
217
+ }, [store, inputs, props.computeWith]);
197
218
  // A cell's own params, merged once per answer: the same answer gives the same object, so the cell does not draw again.
198
219
  const merged = useRef(new WeakMap());
199
220
  const cellParams = useCallback((key, params, index) => {
221
+ const own = ownCellParams(key, params, index);
222
+ // Options narrowed by the row's own values and the form's (`narrowedBy`): read as the row draws, which it does when
223
+ // one of them changes.
224
+ if (!params.narrowedBy)
225
+ return own;
226
+ const row = store.row(key);
227
+ const outer = latest.current.props.formValues;
228
+ return narrowInputParamsBy(own, narrowing => {
229
+ if (narrowing.from === "form")
230
+ return outer?.[narrowing.name] ?? { value: undefined };
231
+ return { value: row?.[narrowing.name], label: latest.current.props.inputs.find(input => input.name === narrowing.name)?.label };
232
+ });
233
+ }, [store]);
234
+ /** The column's params, or the row's own over them (`cellParams`). */
235
+ function ownCellParams(key, params, index) {
200
236
  const ask = latest.current.props.cellParams;
201
237
  if (!ask)
202
238
  return params;
@@ -216,8 +252,14 @@ export function useRowsInput(props, fallbackItem, layoutRef) {
216
252
  byColumn.set(params, result);
217
253
  }
218
254
  return result;
219
- }, [store]);
220
- return { store, snapshot, proxyRef, onInvalid, canAdd, actions, cellLock, rowLock, cellParams };
255
+ }
256
+ return { store, snapshot, proxyRef, onInvalid, canAdd, actions, cellLock, rowLock, cellParams, computeWith };
257
+ }
258
+ /** The form values rows follow, as text that changes only when one of them does (a new object with the same values does not). */
259
+ function formValuesKey(values) {
260
+ if (!values)
261
+ return "";
262
+ return JSON.stringify(Object.keys(values).sort().map(name => [name, narrowingValue(values[name]?.value)]));
221
263
  }
222
264
  function addButtonOf(proxy) {
223
265
  return proxy?.closest(".nx-rows")?.querySelector(":scope > .nx-rows__footer .nx-rows__add:not(:disabled)") ?? null;
@@ -243,17 +285,23 @@ export function rowName(store, index) {
243
285
  export function labelText(params) {
244
286
  return typeof params.label === "string" ? params.label : (inputFieldName(params) ?? params.name);
245
287
  }
288
+ /** A cell's control id: the rows' id, the row's key, and the input's name. */
289
+ const cellId = (idPrefix, rowKey, name) => `${idPrefix}-${rowKey.replace(/[^A-Za-z0-9_-]/g, "_")}-${name}`;
246
290
  /**
247
291
  * One input of one row. It starts from the store's value when it mounts and writes every change back, so typing renders
248
292
  * nothing; a value set from code mounts it again (its key holds the value's revision). In a table the column header is
249
293
  * the label, so the input is named "City, row 2" for screen readers and its messages still say "City".
250
294
  */
251
- export const RowCell = memo(function RowCell({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled, lock }) {
295
+ export const RowCell = memo(function RowCell(props) {
296
+ const { store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled, lock } = props;
252
297
  const [initial] = useState(() => store.get(rowKey, params.name));
253
298
  const onValueChange = useCallback((next) => store.set(rowKey, params.name, next, "input"), [store, rowKey, params.name]);
299
+ // Worked out from the row's other inputs: no value of its own, drawn from the row as it is now.
300
+ if (params.compute)
301
+ return _jsx(RowComputedCell, { ...props });
254
302
  const needsName = params.type === EInputTypes.RadioGroup || params.type === EInputTypes.CheckBoxGroup;
255
303
  const overrides = {
256
- id: `${idPrefix}-${rowKey.replace(/[^A-Za-z0-9_-]/g, "_")}-${params.name}`,
304
+ id: cellId(idPrefix, rowKey, params.name),
257
305
  // Nothing goes into a parent form by itself: the rows go in whole, under the rows input's name.
258
306
  name: needsName ? `${ROW_INPUT_NAME}${idPrefix}:${rowKey}:${params.name}` : undefined,
259
307
  defaultValue: initial,
@@ -285,6 +333,31 @@ export const RowCell = memo(function RowCell({ store, rowKey, params, index, err
285
333
  }
286
334
  return renderInput(params, overrides);
287
335
  });
336
+ /**
337
+ * A row input worked out from the row's others (`compute`): `ComputedInput` reading the row and the rows' `computeWith`,
338
+ * drawn again whenever the row changes, and writing a typed-in figure's answer as the person's change of the row
339
+ * (`RowsStore.setFromPerson`). In a table it has no label and no line under it: the note goes under the rows.
340
+ */
341
+ function RowComputedCell({ store, rowKey, params, index, layout, idPrefix, size, design, readOnly, disabled, lock, cells }) {
342
+ useRowVersion(store, rowKey);
343
+ const at = useRef(index);
344
+ at.current = index;
345
+ const read = useCallback(() => (store.row(rowKey) ?? {}), [store, rowKey]);
346
+ const write = useCallback((values) => void store.setFromPerson(rowKey, values), [store, rowKey]);
347
+ // The row's other inputs as this row has them: its own params (`cellParams`), and read-only where the cell is locked.
348
+ const inputParams = useCallback((name) => {
349
+ const column = store.inputs.find(params => params.name === name);
350
+ if (!column || !cells)
351
+ return column;
352
+ const own = cells.cellParams(rowKey, column, at.current);
353
+ return cells.cellLock(rowKey, name, at.current) ? { ...own, readOnly: true } : own;
354
+ }, [store, rowKey, cells]);
355
+ const sourceId = useCallback((name) => cellId(idPrefix, rowKey, name), [idPrefix, rowKey]);
356
+ const tabular = layout === "tabular";
357
+ const say = cells?.onNote;
358
+ const onNote = useMemo(() => (tabular && say ? (note) => say(rowKey, params.name, note) : undefined), [tabular, say, rowKey, params.name]);
359
+ return (_jsx(ComputedInput, { params: params, id: cellId(idPrefix, rowKey, params.name), read: read, withValue: cells?.computeWith?.(), write: write, inputParams: inputParams, sourceId: sourceId, size: params.size ?? size, design: design, readOnly: readOnly || Boolean(lock), readOnlyMessage: typeof lock === "string" ? lock : undefined, disabled: disabled, cell: tabular ? { "aria-label": `${labelText(params)}, ${rowName(store, index)}` } : undefined, onNote: onNote }));
360
+ }
288
361
  /** The legend of the rows, with the required mark and, when read-only, a lock. */
289
362
  export function RowsLegend({ id, label, required, lock }) {
290
363
  if (!hasContent(label))
@@ -84,7 +84,7 @@ const TabularRow = memo(function TabularRow({ store, rowKey, index, count, colum
84
84
  return;
85
85
  store.touch(rowKey, cell.dataset.nxCell ?? "");
86
86
  }
87
- return (_jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": index + 2, "data-nx-row": rowKey, "data-problem": invalid || undefined, onBlur: onBlur, children: [context.numbered && (_jsxs("div", { role: "cell", className: "nx-tabular__num", children: [_jsx("span", { className: "nx-tabular__num-text", children: index + 1 }), !atEnd && menu] })), columns.map(params => (_jsx("div", { role: "cell", className: cx("nx-tabular__cell", `nx-tabular__cell--${cellKind(params)}`), "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: context.cellParams(rowKey, params, index), index: index, error: store.shownError(rowKey, params.name), layout: "tabular", idPrefix: context.idPrefix, size: context.size, readOnly: context.readOnly, disabled: context.disabled, lock: context.cellLock(rowKey, params.name, index) }, store.revision(rowKey, params.name)) }, params.name))), atEnd && (_jsxs("div", { role: "cell", className: "nx-tabular__actions", children: [removable && (_jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `Remove ${title}`, title: locked ? "Unlock the row to remove it" : "Remove", "data-tone": "danger", "data-nx-row-actions": "", disabled: locked || context.disabled, onClick: () => onAction("remove", rowKey), children: _jsx(InlineIcon, { icon: iconTrash }) })), lockable && (_jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `${locked ? "Unlock" : "Lock"} ${title}`, "aria-pressed": locked, title: locked ? "Locked: unlock to change it" : "Lock, so it cannot be changed", "data-nx-row-actions": "", disabled: context.disabled, onClick: () => onAction(locked ? "unlock" : "lock", rowKey), children: _jsx(InlineIcon, { icon: locked ? iconLock : iconLockOpen }) })), menu] }))] }));
87
+ return (_jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": index + 2, "data-nx-row": rowKey, "data-problem": invalid || undefined, onBlur: onBlur, children: [context.numbered && (_jsxs("div", { role: "cell", className: "nx-tabular__num", children: [_jsx("span", { className: "nx-tabular__num-text", children: index + 1 }), !atEnd && menu] })), columns.map(params => (_jsx("div", { role: "cell", className: cx("nx-tabular__cell", `nx-tabular__cell--${cellKind(params)}`), "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: context.cellParams(rowKey, params, index), index: index, error: store.shownError(rowKey, params.name), layout: "tabular", idPrefix: context.idPrefix, size: context.size, readOnly: context.readOnly, disabled: context.disabled, lock: context.cellLock(rowKey, params.name, index), cells: context }, store.revision(rowKey, params.name)) }, params.name))), atEnd && (_jsxs("div", { role: "cell", className: "nx-tabular__actions", children: [removable && (_jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `Remove ${title}`, title: locked ? "Unlock the row to remove it" : "Remove", "data-tone": "danger", "data-nx-row-actions": "", disabled: locked || context.disabled, onClick: () => onAction("remove", rowKey), children: _jsx(InlineIcon, { icon: iconTrash }) })), lockable && (_jsx("button", { type: "button", className: "nx-tabular__num-button", "aria-label": `${locked ? "Unlock" : "Lock"} ${title}`, "aria-pressed": locked, title: locked ? "Locked: unlock to change it" : "Lock, so it cannot be changed", "data-nx-row-actions": "", disabled: context.disabled, onClick: () => onAction(locked ? "unlock" : "lock", rowKey), children: _jsx(InlineIcon, { icon: locked ? iconLock : iconLockOpen }) })), menu] }))] }));
88
88
  });
89
89
  /**
90
90
  * Rows of inputs in a table, one column per input: a person's emails or addresses, invoice lines, a salary sheet. The
@@ -135,6 +135,15 @@ export function TabularForm(props) {
135
135
  const pageRows = Math.ceil((typeof maxHeight === "number" ? maxHeight : 600) / rowHeight);
136
136
  const [range, setRange] = useState({ start: 0, end: pageRows + OVERSCAN });
137
137
  const [status, setStatus] = useState(null);
138
+ /** The note of the figure typed into a worked-out cell last (`compute.solve`), until it no longer applies. */
139
+ const [note, setNote] = useState(null);
140
+ const onNote = useCallback((key, name, next) => {
141
+ setNote(current => {
142
+ if (next)
143
+ return current && current.key === key && current.name === name && current.text === next.text && current.refused === next.refused ? current : { ...next, key, name };
144
+ return current && current.key === key && current.name === name ? null : current;
145
+ });
146
+ }, []);
138
147
  const pendingRef = useRef(null);
139
148
  const start = Math.min(range.start, count);
140
149
  const end = Math.min(count, Math.max(range.end, start + 1));
@@ -311,7 +320,7 @@ export function TabularForm(props) {
311
320
  layoutRef.current?.focusRow(key, name);
312
321
  }
313
322
  }
314
- const context = useMemo(() => ({ idPrefix: frame.id, size, readOnly, disabled, numbered, removable, sortable, canAdd: rows.canAdd, showAll: snapshot.showAll, cellLock, cellParams: rows.cellParams, rowLock: rows.rowLock, actionsAt, lockable, isLocked }), [frame.id, size, readOnly, disabled, numbered, removable, sortable, rows.canAdd, snapshot.showAll, cellLock, rows.cellParams, rows.rowLock, actionsAt, lockable, isLocked]);
323
+ const context = useMemo(() => ({ idPrefix: frame.id, size, readOnly, disabled, numbered, removable, sortable, canAdd: rows.canAdd, showAll: snapshot.showAll, cellLock, cellParams: rows.cellParams, rowLock: rows.rowLock, actionsAt, lockable, isLocked, computeWith: rows.computeWith, onNote }), [frame.id, size, readOnly, disabled, numbered, removable, sortable, rows.canAdd, snapshot.showAll, cellLock, rows.cellParams, rows.rowLock, actionsAt, lockable, isLocked, rows.computeWith, onNote]);
315
324
  /**
316
325
  * Which columns are generated for **every** row on the page. The lock is then said once, on the column header,
317
326
  * instead of on each of its cells: eighteen locks down one column is eighteen times the ink for one fact. The cells
@@ -369,6 +378,10 @@ export function TabularForm(props) {
369
378
  const minWidth = `calc(${[numbered ? numberWidth : "0px", ...widths, actionsWidth ?? "0px"].join(" + ")})`;
370
379
  const tableStyle = { "--nx-tabular-columns": template, "--nx-tabular-num": numbered ? numberWidth : "0px", minWidth };
371
380
  const invalid = Boolean(error || snapshot.message || snapshot.invalidRows);
381
+ // A typed-in figure's note, named with its row, says more than a hint; a message of the cell in focus says more still.
382
+ const noteRow = note ? store.indexOf(note.key) : -1;
383
+ const said = note && noteRow >= 0 ? { text: `${store.names.title} ${noteRow + 1}: ${note.text}`, error: note.refused } : null;
384
+ const footerStatus = status?.error ? status : (said ?? status);
372
385
  return (_jsxs("fieldset", { id: frame.id, className: cx("nx-rows", "nx-tabular", `nx-tabular--${size}`, className), style: style, disabled: disabled, "data-problem": invalid || undefined, "data-readonly": readOnly || undefined, "data-actions-at": atEnd ? "end" : undefined, "aria-label": props["aria-label"], "aria-describedby": frame.messageId, children: [_jsx(RowsLegend, { id: frame.legendId, label: label, required: required || (props.min ?? 0) > 0, lock: frame.lock }), _jsx("input", { ref: rows.proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", form: props.form, onInvalid: rows.onInvalid }), _jsx("div", { ref: viewportRef, className: "nx-tabular__viewport", tabIndex: -1,
373
386
  // A focused cell scrolls clear of the actions fixed at the end, as it does of the number at the start.
374
387
  style: { maxHeight: viewportMax ?? cssLength(maxHeight), scrollPaddingRight: actionsWidth ?? undefined }, onScroll: updateRange, children: _jsxs("div", { role: "table", className: "nx-tabular__table", style: tableStyle, "aria-labelledby": label ? frame.legendId : undefined, "aria-label": label ? undefined : props["aria-label"], "aria-rowcount": count + 1, children: [_jsx("div", { ref: headRef, role: "rowgroup", className: "nx-tabular__head", children: _jsxs("div", { role: "row", className: "nx-tabular__row", "aria-rowindex": 1, children: [numbered && (_jsxs("div", { role: "columnheader", className: "nx-tabular__num", children: [_jsx("span", { "aria-hidden": "true", children: "#" }), _jsx("span", { className: "nx-visually-hidden", children: store.names.title })] })), columns.map(params => {
@@ -376,5 +389,5 @@ export function TabularForm(props) {
376
389
  const locked = lockedColumns.get(params.name);
377
390
  const reason = typeof locked === "string" ? locked : locked ? "Read-only" : undefined;
378
391
  return (_jsxs("div", { role: "columnheader", className: cx("nx-tabular__head-cell", `nx-tabular__cell--${cellKind(params)}`), title: reason ?? (typeof columnHint === "string" ? columnHint : undefined), "data-nx-locked": locked ? "" : undefined, children: [_jsx("span", { className: "nx-tabular__head-label", children: params.label ?? params.name }), params.required && !locked && (_jsx("span", { className: "nx-field__required", "aria-hidden": "true", children: "*" })), locked && _jsx(InlineIcon, { icon: iconLock, className: "nx-tabular__head-lock", "aria-hidden": "true" })] }, params.name));
379
- }), actionsWidth && (_jsx("div", { role: "columnheader", className: "nx-tabular__actions", children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }) }), _jsx("div", { ref: bodyRef, role: "rowgroup", className: "nx-tabular__body", style: { paddingTop: start * rowHeight, paddingBottom: Math.max(0, count - end) * rowHeight }, onKeyDown: onBodyKeyDown, onFocus: readStatus, onBlur: readStatusSoon, onInput: readStatusSoon, children: count === 0 ? (_jsx("div", { role: "row", className: "nx-tabular__row nx-tabular__row--empty", children: _jsx("div", { role: "cell", className: "nx-tabular__empty", children: frame.emptyMessage }) })) : (shownKeys.map((key, offset) => (_jsx(TabularRow, { store: store, rowKey: key, index: start + offset, count: count, columns: columns, context: context, onAction: onAction }, `${snapshot.generation}:${key}`)))) })] }) }), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, status: status, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
392
+ }), actionsWidth && (_jsx("div", { role: "columnheader", className: "nx-tabular__actions", children: _jsx("span", { className: "nx-visually-hidden", children: "Actions" }) }))] }) }), _jsx("div", { ref: bodyRef, role: "rowgroup", className: "nx-tabular__body", style: { paddingTop: start * rowHeight, paddingBottom: Math.max(0, count - end) * rowHeight }, onKeyDown: onBodyKeyDown, onFocus: readStatus, onBlur: readStatusSoon, onInput: readStatusSoon, children: count === 0 ? (_jsx("div", { role: "row", className: "nx-tabular__row nx-tabular__row--empty", children: _jsx("div", { role: "cell", className: "nx-tabular__empty", children: frame.emptyMessage }) })) : (shownKeys.map((key, offset) => (_jsx(TabularRow, { store: store, rowKey: key, index: start + offset, count: count, columns: columns, context: context, onAction: onAction }, `${snapshot.generation}:${key}`)))) })] }) }), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, status: footerStatus, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
380
393
  }
@@ -1,5 +1,5 @@
1
1
  import { type CSSProperties, type ReactNode, type Ref } from "react";
2
- import { type TreeChangeRules, type TreeChildrenPage, type TreeChildrenRequest, type TreeItem, type TreeItemChange, type TreeValue, type TreeValueMode } from "../../Helpers/TreeStore.ts";
2
+ import { type TreeChangeRules, type TreeChildrenPage, type TreeChildrenRequest, type TreeFilter, type TreeItem, type TreeItemChange, type TreeValue, type TreeValueMode } from "../../Helpers/TreeStore.ts";
3
3
  import type { TableRowAction } from "../../Interfaces/TableInterfaces.ts";
4
4
  import type { Size } from "../../Interfaces/TypeInterfaces.ts";
5
5
  export type TreeAction = "create" | "update" | "delete";
@@ -204,6 +204,29 @@ export interface TreeViewProps<TItem extends TreeItem = TreeItem> {
204
204
  * where it is. Not on a `checkable` tree, whose rows are checked instead.
205
205
  */
206
206
  selectedValue?: TreeValue | null;
207
+ /**
208
+ * Small tabs above the rows that narrow them, such as All actions · View only · Add and edit · Trash and delete on a
209
+ * permission tree. A filter's `match` is asked of every item without children (one without `match` shows all); the
210
+ * tree shows the leaves it takes and the parents that hold one. While it is on, checking a parent - or Select all -
211
+ * checks or unchecks only the leaves shown, a parent's box and count read only them, and a leaf the filter hides keeps
212
+ * its check exactly as it was. The value is the same whatever tab is on: switching tabs never changes it, and never
213
+ * makes a form dirty. The first filter is on at the start, unless `defaultFilter` names another.
214
+ */
215
+ filters?: readonly TreeFilter<TItem>[];
216
+ /** With `filters`: the id of the filter on at the start. Default the first. */
217
+ defaultFilter?: string;
218
+ /**
219
+ * With `filters`: keeps the tab chosen in this browser, per `key` and `viewer` (the signed-in person), and opens it
220
+ * next time; nothing is kept without a viewer. Read after the first draw, as every kept choice is.
221
+ */
222
+ rememberFilter?: {
223
+ key: string;
224
+ viewer?: string | null;
225
+ };
226
+ /** With `filters`: after a tab is chosen, its id. */
227
+ onFilterChange?: (id: string) => void;
228
+ /** With `filters`: what a screen reader calls the tabs. Default "Show". */
229
+ filtersLabel?: string;
207
230
  /** The longest name the editor takes. Default 100. */
208
231
  maxLabelLength?: number;
209
232
  /** A click on a row without a checkbox, or Enter on it. Without it, a click expands or collapses a parent. */
@@ -4,10 +4,12 @@ import { Icon, iconAlertCircle, iconCheck, iconChevronRight, iconChevronsDown, i
4
4
  import { isValidElement, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from "react";
5
5
  import { afterAnimation } from "../../Helpers/AnimationHelpers.js";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
+ import { browserInputMemory } from "../../Helpers/InputMemory.js";
7
8
  import { isPromiseLike, REQUEST_CACHE_LIFETIME } from "../../Helpers/RequestCache.js";
8
- import { expireTreePages, subscribeTreeChanges, TREE_DEFAULT_KEYS, treeKey, treePageKey, treePages, TreeStore, treeStatusKey, } from "../../Helpers/TreeStore.js";
9
+ import { expireTreePages, shownTreeFilter, subscribeTreeChanges, TREE_DEFAULT_KEYS, treeKey, treePageKey, treePages, TreeStore, treeFilterStorageKey, treeStatusKey, } from "../../Helpers/TreeStore.js";
9
10
  import { Button } from "../Documents/Button.js";
10
11
  import { ContextMenu } from "../Documents/Menu.js";
12
+ import { TabButtons } from "../Documents/TabButtons.js";
11
13
  import { messageBox } from "../Layouts/MessageBox.js";
12
14
  import { CheckBox } from "./CheckBox.js";
13
15
  import { hasContent } from "./Field.js";
@@ -56,7 +58,7 @@ const sameItems = (a, b) => a === b || (a.length === b.length && a.every((item,
56
58
  * loadChildren={({ parent, skip, take, signal }) => loadRegions(parent, skip, take, signal)} />
57
59
  */
58
60
  export function TreeView(props) {
59
- const { items: givenItems, loadChildren, childPageSize = CHILD_PAGE_SIZE, hasChildrenKey, cacheKey, cacheLifetime = REQUEST_CACHE_LIFETIME, partialMessage = "Its items are not all loaded: open it and load them all to check it as a whole.", valueKey = TREE_DEFAULT_KEYS.value, parentKey = TREE_DEFAULT_KEYS.parent, labelKey = TREE_DEFAULT_KEYS.label, descriptionKey = "description", iconKey = "icon", isItemDisabled, lockedMessage = "You do not have permission to change this item.", label, hint, error, size = "md", checkable = false, cascade = true, valueMode = "leaves", value, defaultValue, onValueChange, name, form, counts = true, defaultExpanded, creatable = false, editable = false, deletable = false, canChange, onCreate, onUpdate, onDelete, onItemsChange, confirmDelete = true, rowActions, add, selectedValue, maxLabelLength = 100, onItemClick, loading = false, emptyMessage = "No items", toolbar = true, height, maxHeight = 420, fill = false, readOnly = false, readOnlyMessage = "Read-only: this tree cannot be changed.", disabled = false, id, className, style, "aria-label": ariaLabel, ref, } = props;
61
+ const { items: givenItems, loadChildren, childPageSize = CHILD_PAGE_SIZE, hasChildrenKey, cacheKey, cacheLifetime = REQUEST_CACHE_LIFETIME, partialMessage = "Its items are not all loaded: open it and load them all to check it as a whole.", valueKey = TREE_DEFAULT_KEYS.value, parentKey = TREE_DEFAULT_KEYS.parent, labelKey = TREE_DEFAULT_KEYS.label, descriptionKey = "description", iconKey = "icon", isItemDisabled, lockedMessage = "You do not have permission to change this item.", label, hint, error, size = "md", checkable = false, cascade = true, valueMode = "leaves", value, defaultValue, onValueChange, name, form, counts = true, defaultExpanded, creatable = false, editable = false, deletable = false, canChange, onCreate, onUpdate, onDelete, onItemsChange, confirmDelete = true, rowActions, add, selectedValue, filters, defaultFilter, rememberFilter, onFilterChange, filtersLabel = "Show", maxLabelLength = 100, onItemClick, loading = false, emptyMessage = "No items", toolbar = true, height, maxHeight = 420, fill = false, readOnly = false, readOnlyMessage = "Read-only: this tree cannot be changed.", disabled = false, id, className, style, "aria-label": ariaLabel, ref, } = props;
60
62
  const generatedId = useId();
61
63
  const treeId = id ?? generatedId;
62
64
  const labelId = `${treeId}-label`;
@@ -75,6 +77,14 @@ export function TreeView(props) {
75
77
  const holderRef = useRef(null);
76
78
  // Whether the user (or the handle) changed the checks since the items came, so new items keep them.
77
79
  const touchedRef = useRef(false);
80
+ // The filter on: the tab chosen (or kept), else the default, else the first.
81
+ const [chosenFilter, setChosenFilter] = useState(null);
82
+ const activeFilter = shownTreeFilter(filters, chosenFilter, defaultFilter);
83
+ const activeFilterId = activeFilter?.match ? activeFilter.id : null;
84
+ // The store asks the filter through this, so a page passing a new arrow function each draw counts nothing again.
85
+ const matchRef = useRef(undefined);
86
+ matchRef.current = activeFilter?.match;
87
+ const storeMatch = (id) => (id === null ? null : item => (matchRef.current ? matchRef.current(item) : true));
78
88
  if (holderRef.current?.config !== config) {
79
89
  // A new rule (another value mode, say) keeps what was checked and expanded.
80
90
  const previous = holderRef.current?.store;
@@ -87,6 +97,7 @@ export function TreeView(props) {
87
97
  lazy,
88
98
  lazyRoots,
89
99
  hasChildrenKey,
100
+ filter: storeMatch(activeFilterId),
90
101
  isItemDisabled: item => (disabledRef.current ? disabledRef.current(item) : read(item, "disabled") === true || read(item, "isDisabled") === true),
91
102
  }, value ?? (previous && touchedRef.current ? previous.value() : defaultValue));
92
103
  if (previous)
@@ -97,10 +108,15 @@ export function TreeView(props) {
97
108
  store.expandAll(defaultExpanded);
98
109
  else if (defaultExpanded)
99
110
  store.expandValues(defaultExpanded);
100
- holderRef.current = { config, store, items, value };
111
+ holderRef.current = { config, store, items, value, filterId: activeFilterId };
101
112
  }
102
113
  const holder = holderRef.current;
103
114
  const store = holder.store;
115
+ // Another filter (a kept one read, the filters changed): rows again, nothing checked or unchecked.
116
+ if (holder.filterId !== activeFilterId) {
117
+ store.setFilter(storeMatch(activeFilterId));
118
+ holder.filterId = activeFilterId;
119
+ }
104
120
  if (!sameItems(holder.items, items)) {
105
121
  store.setItems(items, value ?? (touchedRef.current ? store.value() : defaultValue));
106
122
  holder.items = items;
@@ -358,6 +374,59 @@ export function TreeView(props) {
358
374
  function onPanelFocus() {
359
375
  hadFocusRef.current = true;
360
376
  }
377
+ // Filters: the tab kept in this browser is read after the first draw, as the server drew none; a tab chosen before then wins.
378
+ const filterStorageKey = filters && filters.length > 0 ? treeFilterStorageKey(rememberFilter) : null;
379
+ const filterTouchedRef = useRef(false);
380
+ useLayoutEffect(() => {
381
+ if (!filterStorageKey)
382
+ return;
383
+ let kept = null;
384
+ try {
385
+ kept = browserInputMemory()?.getItem(filterStorageKey) ?? null;
386
+ }
387
+ catch {
388
+ kept = null;
389
+ }
390
+ if (kept !== null && !filterTouchedRef.current)
391
+ setChosenFilter(kept);
392
+ }, [filterStorageKey]);
393
+ /** A tab chosen: rows again for its filter, focus kept on a row still shown, and the choice kept. Nothing is checked or unchecked. */
394
+ function chooseFilter(id) {
395
+ const next = shownTreeFilter(filters, id, defaultFilter);
396
+ if (!next)
397
+ return;
398
+ filterTouchedRef.current = true;
399
+ setChosenFilter(next.id);
400
+ if (filterStorageKey) {
401
+ try {
402
+ browserInputMemory()?.setItem(filterStorageKey, next.id);
403
+ }
404
+ catch {
405
+ // Storage switched off or full: the choice lasts until the page goes.
406
+ }
407
+ }
408
+ matchRef.current = next.match;
409
+ const nextId = next.match ? next.id : null;
410
+ if (holder.filterId !== nextId) {
411
+ store.setFilter(storeMatch(nextId));
412
+ holder.filterId = nextId;
413
+ }
414
+ setMotion(null);
415
+ // A focused row the filter hides hands its place to its nearest shown parent, or to the first row.
416
+ const focused = focusKey === null ? undefined : store.find(focusKey);
417
+ if (focused && !store.isVisible(focused)) {
418
+ let up = focused.parent;
419
+ while (up && !store.isVisible(up))
420
+ up = up.parent;
421
+ setFocusKey((up ?? store.rows[0])?.key ?? null);
422
+ }
423
+ noteRowRef.current = null;
424
+ readOnlyNotice.hide();
425
+ lockedNotice.hide();
426
+ partialNotice.hide();
427
+ onFilterChange?.(next.id);
428
+ bump();
429
+ }
361
430
  /**
362
431
  * Changes made elsewhere, written in (the handle's `apply`). The focused row keeps focus; when it leaves the page or
363
432
  * moves in it, focus goes to the viewport first, while the DOM is whole, and comes back to it - or, when the item
@@ -1262,6 +1331,8 @@ export function TreeView(props) {
1262
1331
  const rendered = new Set();
1263
1332
  /** How many siblings a row has: all of them on the server when the loader said, loaded or not. */
1264
1333
  function setSize(node) {
1334
+ if (store.filtered)
1335
+ return store.shownChildCount(node.parent);
1265
1336
  const loaded = node.parent ? node.parent.children.length : store.roots.length;
1266
1337
  const total = store.pagesOf(node.parent)?.total;
1267
1338
  return total === undefined ? loaded : Math.max(total, loaded);
@@ -1329,7 +1400,7 @@ export function TreeView(props) {
1329
1400
  return (_jsxs("div", { role: ghost ? undefined : "treeitem", className: cx("nx-tree__row", locked && "nx-tree__row--locked", edit && "nx-tree__row--editing", node.key === selectedKey && "nx-tree__row--selected"), style: { "--nx-tree-depth": node.depth }, "data-key": ghost ? undefined : node.key, "data-uid": ghost ? undefined : node.uid, "data-readonly": locked || undefined, tabIndex: ghost ? undefined : !disabled && tabbable === node ? 0 : -1, "aria-level": ghost ? undefined : node.depth + 1, "aria-setsize": ghost ? undefined : setSize(node), "aria-posinset": ghost ? undefined : node.position + 1, "aria-expanded": ghost || !expandable ? undefined : node.expanded, "aria-checked": ghost || !state ? undefined : state === "mixed" ? "mixed" : state === "checked", "aria-selected": !ghost && node.key === selectedKey ? true : undefined, "aria-disabled": ghost ? undefined : locked || undefined, "aria-busy": busy || childrenLoading || undefined, "aria-labelledby": ghost || edit ? undefined : labelElementId, "aria-label": edit ? (edit.mode === "create" ? "New item" : `Renaming ${node.label}`) : undefined, "aria-describedby": ghost || edit ? undefined : descriptionElementId, children: [_jsx("span", { className: "nx-tree__toggle", "aria-hidden": "true", children: expandable && _jsx(InlineIcon, { icon: iconChevronRight }) }), checkable && (_jsx("span", { className: cx("nx-tree__check nx-choice", `nx-choice--${size}`), "aria-hidden": "true", children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", tabIndex: -1, checked: state === "checked", disabled: disabled, readOnly: true, ref: input => {
1330
1401
  if (input)
1331
1402
  input.indeterminate = state === "mixed";
1332
- } }) })), icon && (_jsx("span", { className: "nx-tree__icon", "aria-hidden": "true", children: icon })), edit ? (_jsx(TreeEditor, { initial: editTextRef.current, select: edit.mode === "update", maxLength: maxLabelLength, saving: saving, label: edit.mode === "create" ? "Name of the new item" : `New name for ${node.label}`, invalid: message !== null, describedBy: message !== null ? messageId : undefined, onText: text => (editTextRef.current = text), onCommit: via => void commitEdit(via), onCancel: cancelEdit }, node.uid)) : (_jsxs("span", { className: "nx-tree__text", children: [_jsx("span", { id: ghost ? undefined : labelElementId, className: "nx-tree__label", title: node.label, children: node.label || "(no name)" }), hasDescription && (_jsx("span", { id: ghost ? undefined : descriptionElementId, className: "nx-tree__description", children: String(description) }))] })), showCounts && hasChildren && !edit && (_jsxs("span", { className: "nx-tree__count", "aria-hidden": "true", children: [node.checkedLeaves.toLocaleString(), "/", node.leaves.toLocaleString(), node.partial && "+"] })), busy && !edit && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin nx-tree__busy", "aria-hidden": "true" }), locked && !edit && (_jsx("span", { className: "nx-tree__lock", title: lockedMessage, "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLock }) })), actions.length > 0 && _jsx("span", { className: "nx-tree__actions", children: actions })] }, node.uid));
1403
+ } }) })), icon && (_jsx("span", { className: "nx-tree__icon", "aria-hidden": "true", children: icon })), edit ? (_jsx(TreeEditor, { initial: editTextRef.current, select: edit.mode === "update", maxLength: maxLabelLength, saving: saving, label: edit.mode === "create" ? "Name of the new item" : `New name for ${node.label}`, invalid: message !== null, describedBy: message !== null ? messageId : undefined, onText: text => (editTextRef.current = text), onCommit: via => void commitEdit(via), onCancel: cancelEdit }, node.uid)) : (_jsxs("span", { className: "nx-tree__text", children: [_jsx("span", { id: ghost ? undefined : labelElementId, className: "nx-tree__label", title: node.label, children: node.label || "(no name)" }), hasDescription && (_jsx("span", { id: ghost ? undefined : descriptionElementId, className: "nx-tree__description", children: String(description) }))] })), showCounts && hasChildren && !edit && (_jsxs("span", { className: "nx-tree__count", "aria-hidden": "true", children: [node.shownChecked.toLocaleString(), "/", node.shownLeaves.toLocaleString(), node.partial && "+"] })), busy && !edit && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin nx-tree__busy", "aria-hidden": "true" }), locked && !edit && (_jsx("span", { className: "nx-tree__lock", title: lockedMessage, "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconLock }) })), actions.length > 0 && _jsx("span", { className: "nx-tree__actions", children: actions })] }, node.uid));
1333
1404
  }
1334
1405
  const end = Math.min(range.end, total);
1335
1406
  const start = Math.min(range.start, end);
@@ -1362,6 +1433,8 @@ export function TreeView(props) {
1362
1433
  }
1363
1434
  const allState = checkable ? store.allState() : null;
1364
1435
  const tally = store.counts();
1436
+ // Under a filter the toolbar counts what is shown, and everything after it.
1437
+ const shownTally = store.filtered ? store.shownCounts() : null;
1365
1438
  const hasParents = store.roots.some(root => root.children.length > 0 || root.expanded);
1366
1439
  const hasLabel = hasContent(label);
1367
1440
  const shownMessage = message ?? (hasContent(error) ? error : null);
@@ -1370,11 +1443,15 @@ export function TreeView(props) {
1370
1443
  const rootWaiting = store.size === 0 && rootPages !== null && (rootPages.status === "idle" || rootPages.status === "loading");
1371
1444
  const waiting = loading || rootWaiting;
1372
1445
  const empty = total === 0 || rootWaiting;
1373
- return (_jsxs("div", { className: cx("nx-field nx-field--standard", `nx-field--${size}`, "nx-tree-field", fill && "nx-tree-field--fill", className), style: style, "data-invalid": shownMessage ? true : undefined, "data-readonly": readOnly || undefined, "data-disabled": disabled || undefined, children: [hasLabel && (_jsxs("span", { id: labelId, className: "nx-field__label", children: [label, readOnlyNotice.lock] })), _jsxs("div", { ref: panelRef, className: cx("nx-tree", `nx-tree--${size}`), onFocus: onPanelFocus, onBlur: onPanelBlur, children: [toolbar && (_jsxs("div", { className: "nx-tree__toolbar", children: [checkable && (_jsx(CheckBox, { size: "sm", className: "nx-tree__all", label: "Select all", checked: allState === "checked", indeterminate: allState === "mixed", disabled: disabled || allState === null || empty, readOnly: readOnly, readOnlyMessage: readOnlyMessage, onValueChange: toggleAll })), _jsxs("span", { className: "nx-tree__summary", "aria-live": "polite", children: [(loading || rootPages?.status === "loading") && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin", "aria-hidden": "true" }), waiting && empty
1446
+ return (_jsxs("div", { className: cx("nx-field nx-field--standard", `nx-field--${size}`, "nx-tree-field", fill && "nx-tree-field--fill", className), style: style, "data-invalid": shownMessage ? true : undefined, "data-readonly": readOnly || undefined, "data-disabled": disabled || undefined, children: [hasLabel && (_jsxs("span", { id: labelId, className: "nx-field__label", children: [label, readOnlyNotice.lock] })), _jsxs("div", { ref: panelRef, className: cx("nx-tree", `nx-tree--${size}`), onFocus: onPanelFocus, onBlur: onPanelBlur, children: [activeFilter && filters && (_jsx("div", { className: "nx-tree__filters", children: _jsx(TabButtons, { size: "sm", wrap: true, options: filters.map(filter => ({ value: filter.id, label: filter.label, title: filter.title, disabled })), value: activeFilter.id, onChange: chooseFilter,
1447
+ // An id no form has: the tabs are no value of the form around the tree.
1448
+ form: `${treeId}-filters`, "aria-label": filtersLabel }) })), toolbar && (_jsxs("div", { className: "nx-tree__toolbar", children: [checkable && (_jsx(CheckBox, { size: "sm", className: "nx-tree__all", label: "Select all", checked: allState === "checked", indeterminate: allState === "mixed", disabled: disabled || allState === null || empty, readOnly: readOnly, readOnlyMessage: readOnlyMessage, onValueChange: toggleAll })), _jsxs("span", { className: "nx-tree__summary", "aria-live": "polite", children: [(loading || rootPages?.status === "loading") && _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin", "aria-hidden": "true" }), waiting && empty
1374
1449
  ? "Loading…"
1375
1450
  : checkable
1376
- ? `${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} selected`
1377
- : `${store.size.toLocaleString()} ${store.size === 1 ? "item" : "items"}${lazy ? " loaded" : ""}`, !hasLabel && readOnlyNotice.lock] }), _jsxs("span", { className: "nx-tree__tools", children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsDown }), "aria-label": "Expand all", title: "Expand all", disabled: !hasParents, onClick: expandAll }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsUp }), "aria-label": "Collapse all", title: "Collapse all", disabled: !hasParents, onClick: collapseAll }), creatable && !blocked && (!canChange || canChange("create", null)) && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onMouseDown: keepEditorFocus, onClick: () => startCreate(null), children: "Add" })), add && !blocked && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), "aria-disabled": add.disabled || undefined, title: add.description, onMouseDown: keepEditorFocus, onKeyDown: addNotice.onKeyDown, onClick: event => (add.disabled ? addNotice.show(event.currentTarget) : add.onClick()), children: add.label }))] })] })), _jsx(ContextMenu, { items: contextItems, onSelect: onContextSelect, disabled: contextItems.length === 0, "aria-label": "Item actions", children: _jsx("div", { ref: viewportRef, className: "nx-tree__viewport", style: fill ? undefined : { height, maxHeight: height === undefined ? maxHeight : undefined }, tabIndex: !disabled && !empty && !(tabbable && rendered.has(tabbable.key)) ? 0 : -1, onScroll: event => syncRange(event.currentTarget), onFocus: onViewportFocus, onPointerDown: onViewportPointerDown, onClick: onViewportClick, onKeyDown: onViewportKeyDown, children: empty ? (waiting ? (_jsx("div", { className: "nx-tree__list", "aria-hidden": "true", children: SKELETON_WIDTHS.map((width, index) => (_jsxs("div", { className: "nx-tree__row nx-tree__row--skeleton", style: { "--nx-tree-depth": index % 3 === 0 ? 0 : 1 }, children: [_jsx("span", { className: "nx-tree__toggle" }), _jsx("span", { className: "nx-tree__skeleton", style: { width } })] }, index))) })) : (_jsx("p", { className: "nx-tree__empty", children: emptyMessage }))) : (_jsx("div", { role: "tree", className: "nx-tree__list", "aria-labelledby": hasLabel ? labelId : undefined, "aria-label": hasLabel ? undefined : (ariaLabel ?? "Tree"), "aria-describedby": shownMessage || hasContent(hint) ? messageId : undefined, "aria-disabled": disabled || undefined, "aria-readonly": readOnly || undefined, "aria-busy": waiting || undefined,
1451
+ ? shownTally
1452
+ ? `${shownTally.checked.toLocaleString()} of ${shownTally.total.toLocaleString()} selected · ${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} in all`
1453
+ : `${tally.checked.toLocaleString()} of ${tally.total.toLocaleString()} selected`
1454
+ : `${store.size.toLocaleString()} ${store.size === 1 ? "item" : "items"}${lazy ? " loaded" : ""}`, !hasLabel && readOnlyNotice.lock] }), _jsxs("span", { className: "nx-tree__tools", children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsDown }), "aria-label": "Expand all", title: "Expand all", disabled: !hasParents, onClick: expandAll }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconChevronsUp }), "aria-label": "Collapse all", title: "Collapse all", disabled: !hasParents, onClick: collapseAll }), creatable && !blocked && (!canChange || canChange("create", null)) && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onMouseDown: keepEditorFocus, onClick: () => startCreate(null), children: "Add" })), add && !blocked && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), "aria-disabled": add.disabled || undefined, title: add.description, onMouseDown: keepEditorFocus, onKeyDown: addNotice.onKeyDown, onClick: event => (add.disabled ? addNotice.show(event.currentTarget) : add.onClick()), children: add.label }))] })] })), _jsx(ContextMenu, { items: contextItems, onSelect: onContextSelect, disabled: contextItems.length === 0, "aria-label": "Item actions", children: _jsx("div", { ref: viewportRef, className: "nx-tree__viewport", style: fill ? undefined : { height, maxHeight: height === undefined ? maxHeight : undefined }, tabIndex: !disabled && !empty && !(tabbable && rendered.has(tabbable.key)) ? 0 : -1, onScroll: event => syncRange(event.currentTarget), onFocus: onViewportFocus, onPointerDown: onViewportPointerDown, onClick: onViewportClick, onKeyDown: onViewportKeyDown, children: empty ? (waiting ? (_jsx("div", { className: "nx-tree__list", "aria-hidden": "true", children: SKELETON_WIDTHS.map((width, index) => (_jsxs("div", { className: "nx-tree__row nx-tree__row--skeleton", style: { "--nx-tree-depth": index % 3 === 0 ? 0 : 1 }, children: [_jsx("span", { className: "nx-tree__toggle" }), _jsx("span", { className: "nx-tree__skeleton", style: { width } })] }, index))) })) : (_jsx("p", { className: "nx-tree__empty", children: store.filtered && store.size > 0 && activeFilter ? `No items in "${activeFilter.label}".` : emptyMessage }))) : (_jsx("div", { role: "tree", className: "nx-tree__list", "aria-labelledby": hasLabel ? labelId : undefined, "aria-label": hasLabel ? undefined : (ariaLabel ?? "Tree"), "aria-describedby": shownMessage || hasContent(hint) ? messageId : undefined, "aria-disabled": disabled || undefined, "aria-readonly": readOnly || undefined, "aria-busy": waiting || undefined,
1378
1455
  // As tall as all of its rows at every moment of a commit, not only once it has finished. A scroll that
1379
1456
  // moves the window makes React take the rows that leave it out before it puts the new ones in and sets
1380
1457
  // this padding, and a layout in between - Chrome lays the page out inside the removal of a focused row,
@@ -40,6 +40,11 @@ export interface PopupProps {
40
40
  variant?: PopupVariant;
41
41
  /** Width: `xs` 360, `sm` 440, `md` 560 (default), `lg` 720, `xl` 960, or `full`. */
42
42
  size?: PopupSize;
43
+ /**
44
+ * Height, pixels or a CSS length: the body scrolls inside it. Default: as tall as the content, up to the window. A
45
+ * docked window is `32rem` tall by default, never taller than the window.
46
+ */
47
+ height?: number | string;
43
48
  /** Buttons at the end, right-aligned. */
44
49
  footer?: ReactNode;
45
50
  /** Shows the close button. Default true. */
@@ -67,13 +72,28 @@ export interface PopupProps {
67
72
  reopen?: PopupReopen;
68
73
  /** The window state, when the page controls it; pair it with `onWindowStateChange`. */
69
74
  windowState?: PopupWindowState;
75
+ /**
76
+ * A window that leaves the page usable: it opens without a backdrop at the bottom right, beside the other docked
77
+ * windows (the first on the right, each next one to its left; on a screen too narrow for two, over each other), the
78
+ * one last used on top. Esc inside it closes it, Tab leaves it for the page. A mail client's chat popups and compose
79
+ * window are docked. Minimize and maximize work as on any popup. Default false: a popup locks the page.
80
+ */
81
+ docked?: boolean;
82
+ /**
83
+ * A drawer: the window's whole height at its end edge (the right, in a left-to-right page), its width from `size`
84
+ * (all of it on a phone). Modal as any popup; it neither moves nor maximizes. A board's Add widget list is one.
85
+ */
86
+ drawer?: boolean;
70
87
  /** The window state each time the popup opens, when the page does not control it. Default `normal`. */
71
88
  defaultWindowState?: Exclude<PopupWindowState, "minimized">;
72
89
  /** When the buttons, a double-click on the title bar, a drag, or the minimized bar change the window state. */
73
90
  onWindowStateChange?: (state: PopupWindowState) => void;
74
91
  /** A body without padding, for tables and lists that reach the edges. */
75
92
  flush?: boolean;
76
- /** A selector for the element that takes focus when the popup opens. Default: the first control in the body or footer. */
93
+ /**
94
+ * A selector for the element that takes focus when the popup opens. Default: an element of the body or footer marked
95
+ * `data-nx-focus` (with `tabIndex={-1}` when it is no control, such as a heading), else the first control there.
96
+ */
77
97
  initialFocus?: string;
78
98
  /** Name for screen readers when there is no title. */
79
99
  "aria-label"?: string;
@@ -96,4 +116,4 @@ export interface PopupProps {
96
116
  * @example
97
117
  * <Popup open={open} onClose={() => setOpen(false)} title="Edit booking" footer={<Button variant="primary">Save</Button>}>…</Popup>
98
118
  */
99
- export declare function Popup({ open, onClose, onClosed, title, description, icon, variant, size, footer, closable, closeOnEscape, closeOnBackdrop, minimizable, maximizable, movable, reopen, windowState: windowStateProp, defaultWindowState, onWindowStateChange, flush, initialFocus, "aria-label": ariaLabel, "aria-describedby": ariaDescribedBy, className, children, }: PopupProps): import("react").JSX.Element;
119
+ export declare function Popup({ open, onClose, onClosed, title, description, icon, variant, size, height, footer, closable, closeOnEscape, closeOnBackdrop, minimizable, maximizable: maximizableProp, movable: movableProp, reopen, docked, drawer, windowState: windowStateProp, defaultWindowState, onWindowStateChange, flush, initialFocus, "aria-label": ariaLabel, "aria-describedby": ariaDescribedBy, className, children, }: PopupProps): import("react").JSX.Element | null;