@ai-matrx/records-ui 0.93.3 → 0.93.4

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/dist/index.js CHANGED
@@ -7712,6 +7712,10 @@ function useMergedGridControls(args) {
7712
7712
  (columnKey) => enabled && fieldOf2(columnKey) ? headerMenuItems(columnKey, ctx) : void 0,
7713
7713
  [enabled, ctx, fieldOf2]
7714
7714
  );
7715
+ const columnsControl = useMemo14(
7716
+ () => enabled ? { order, hidden, onChange: keepColumns } : void 0,
7717
+ [enabled, order, hidden, keepColumns]
7718
+ );
7715
7719
  const close = () => setDialog(null);
7716
7720
  const dialogs = !enabled ? null : /* @__PURE__ */ jsxs24(Fragment8, { children: [
7717
7721
  colours.dialogs,
@@ -7843,7 +7847,7 @@ function useMergedGridControls(args) {
7843
7847
  headerMenuFor,
7844
7848
  filterFor,
7845
7849
  contextMenu,
7846
- columns: enabled ? { order, hidden, onChange: keepColumns } : void 0,
7850
+ columns: columnsControl,
7847
7851
  dialogs,
7848
7852
  addColumn: () => setDialog({ kind: "insert", anchor: null, side: "right" }),
7849
7853
  sheetTools: enabled ? /* @__PURE__ */ jsxs24(Fragment8, { children: [
@@ -9293,7 +9297,7 @@ function GridLayoutMenu({
9293
9297
  }
9294
9298
 
9295
9299
  // src/gridUndo.tsx
9296
- import { useCallback as useCallback13, useEffect as useEffect21, useMemo as useMemo18, useRef as useRef11, useState as useState26 } from "react";
9300
+ import { useCallback as useCallback13, useEffect as useEffect21, useLayoutEffect, useMemo as useMemo18, useRef as useRef11, useState as useState26, useSyncExternalStore } from "react";
9297
9301
  import { useRecordsClient as useRecordsClient16 } from "@ai-matrx/records/react";
9298
9302
  import { announceRecordChanged } from "@ai-matrx/records/core";
9299
9303
  import { Button as Button23 } from "@ai-matrx/design-system";
@@ -9399,7 +9403,43 @@ function useGridUndo(args) {
9399
9403
  [push, undo, redo, held, busy, notice, dismiss]
9400
9404
  );
9401
9405
  }
9402
- function UndoRedoButtons({ undo }) {
9406
+ function useUndoSource(undo) {
9407
+ const box = useRef11({
9408
+ undo,
9409
+ face: { back: undo.depth.back, forward: undo.depth.forward, busy: undo.busy },
9410
+ listeners: /* @__PURE__ */ new Set()
9411
+ });
9412
+ box.current.undo = undo;
9413
+ const source = useMemo18(
9414
+ () => ({
9415
+ face: () => box.current.face,
9416
+ subscribe: (listener) => {
9417
+ box.current.listeners.add(listener);
9418
+ return () => {
9419
+ box.current.listeners.delete(listener);
9420
+ };
9421
+ },
9422
+ undo: () => void box.current.undo.undo(),
9423
+ redo: () => void box.current.undo.redo()
9424
+ }),
9425
+ []
9426
+ );
9427
+ const { back, forward } = undo.depth;
9428
+ const busy = undo.busy;
9429
+ useLayoutEffect(() => {
9430
+ const held = box.current.face;
9431
+ if (held.back === back && held.forward === forward && held.busy === busy) return;
9432
+ box.current.face = { back, forward, busy };
9433
+ for (const listener of box.current.listeners) listener();
9434
+ }, [back, forward, busy]);
9435
+ return source;
9436
+ }
9437
+ function UndoRedoLive({ source }) {
9438
+ const face = useSyncExternalStore(source.subscribe, source.face, source.face);
9439
+ return /* @__PURE__ */ jsx37(UndoRedoPair, { face, onUndo: source.undo, onRedo: source.redo });
9440
+ }
9441
+ function UndoRedoPair({ face, onUndo, onRedo }) {
9442
+ const undo = { depth: { back: face.back, forward: face.forward }, busy: face.busy };
9403
9443
  if (undo.depth.back === 0 && undo.depth.forward === 0) return null;
9404
9444
  return /* @__PURE__ */ jsxs32(Fragment13, { children: [
9405
9445
  /* @__PURE__ */ jsxs32(
@@ -9411,7 +9451,7 @@ function UndoRedoButtons({ undo }) {
9411
9451
  title: undo.depth.back > 0 ? `Undo (Cmd-Z) \xB7 ${undo.depth.back} ${undo.depth.back === 1 ? "step" : "steps"} back` : "Nothing to undo",
9412
9452
  "aria-label": `Undo, ${undo.depth.back} ${undo.depth.back === 1 ? "step" : "steps"}`,
9413
9453
  disabled: undo.busy || undo.depth.back === 0,
9414
- onClick: () => void undo.undo(),
9454
+ onClick: onUndo,
9415
9455
  children: [
9416
9456
  "Undo ",
9417
9457
  /* @__PURE__ */ jsx37("span", { className: "ml-1 tabular-nums text-muted-foreground", "data-records-undo-count": "", children: undo.depth.back })
@@ -9427,7 +9467,7 @@ function UndoRedoButtons({ undo }) {
9427
9467
  title: undo.depth.forward > 0 ? `Redo (Shift-Cmd-Z) \xB7 ${undo.depth.forward} ${undo.depth.forward === 1 ? "step" : "steps"} forward` : "Nothing to redo",
9428
9468
  "aria-label": `Redo, ${undo.depth.forward} ${undo.depth.forward === 1 ? "step" : "steps"}`,
9429
9469
  disabled: undo.busy || undo.depth.forward === 0,
9430
- onClick: () => void undo.redo(),
9470
+ onClick: onRedo,
9431
9471
  children: [
9432
9472
  "Redo ",
9433
9473
  /* @__PURE__ */ jsx37("span", { className: "ml-1 tabular-nums text-muted-foreground", "data-records-redo-count": "", children: undo.depth.forward })
@@ -10792,6 +10832,7 @@ function Grid({
10792
10832
  );
10793
10833
  const rowVersion = useCallback16((row) => editingNow.current.rowSignature(row.id), []);
10794
10834
  const undo = useGridUndo({ tableId, reload: records.reload, forget: editing.forget });
10835
+ const undoSource = useUndoSource(undo);
10795
10836
  pushWritten.current = (write) => {
10796
10837
  const field2 = (fields.data ?? []).find((f) => f.key === write.key);
10797
10838
  const name = changedRecordName(table.data, rows.find((r) => r.id === write.rowId)?.document ?? {}, write.key, write.after);
@@ -11343,9 +11384,14 @@ function Grid({
11343
11384
  );
11344
11385
  const footerNow = useRef14({
11345
11386
  summaryCell: () => null,
11346
- addLine: () => null
11387
+ addLine: () => null,
11388
+ choose: () => void 0,
11389
+ add: () => void 0
11347
11390
  });
11348
11391
  footerNow.current = {
11392
+ // The footer is memoised on what it shows (`footerRows` below), so its buttons act through here.
11393
+ choose: chooseSummary,
11394
+ add: () => void addRowHere(),
11349
11395
  summaryCell: (columnId) => {
11350
11396
  const field2 = ordered.find((f) => f.key === columnId);
11351
11397
  if (!field2) return null;
@@ -11358,11 +11404,11 @@ function Grid({
11358
11404
  value: summaries.values[columnId],
11359
11405
  rowCount: summaries.rowCount,
11360
11406
  summaries,
11361
- onChoose: (op2) => chooseSummary(columnId, op2)
11407
+ onChoose: (op2) => footerNow.current.choose(columnId, op2)
11362
11408
  }
11363
11409
  );
11364
11410
  },
11365
- addLine: () => /* @__PURE__ */ jsx42(AddRowLine, { onAdd: () => void addRowHere(), busy: added.busy })
11411
+ addLine: () => /* @__PURE__ */ jsx42(AddRowLine, { onAdd: () => footerNow.current.add(), busy: added.busy })
11366
11412
  };
11367
11413
  const addColumnNow = useRef14(controls.addColumn);
11368
11414
  addColumnNow.current = controls.addColumn;
@@ -11477,13 +11523,24 @@ function Grid({
11477
11523
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `hasTalk`/`hasAgent` stand for the host ports the JSX reads; handlers run through `rowActionsNow`.
11478
11524
  [canWrite, mayWriteRow, tableActions.runnable, tableActions.agent, runner.busy, hasTalk, hasAgent, editable, rights.write]
11479
11525
  );
11480
- const columns = useMemo22(
11526
+ const headerHeld = useRef14(/* @__PURE__ */ new Map());
11527
+ const menuLatest = useRef14(/* @__PURE__ */ new Map());
11528
+ const addColumnDef = useMemo22(() => addColumnColumn(() => addColumnNow.current()), []);
11529
+ const drawnColumns = useMemo22(
11481
11530
  () => {
11482
- const drawn = withHeaderMenus(bodyColumns, controls.headerMenuFor, controls.filterFor);
11483
- return merged && rights.structure && editable ? [...drawn, addColumnColumn(() => addColumnNow.current())] : drawn;
11531
+ const filterFor = controls.filterFor;
11532
+ const drawn = withHeaderMenus(
11533
+ bodyColumns,
11534
+ (key) => holdHeaderMenu(headerHeld.current, menuLatest.current, key, controls.headerMenuFor(key)),
11535
+ (key) => holdByContent(headerHeld.current, `filter:${key}`, filterFor(key))
11536
+ );
11537
+ return merged && rights.structure && editable ? [...drawn, addColumnDef] : drawn;
11484
11538
  },
11485
- [bodyColumns, controls.headerMenuFor, controls.filterFor, merged, rights.structure, editable]
11539
+ [bodyColumns, controls.headerMenuFor, controls.filterFor, merged, rights.structure, editable, addColumnDef]
11486
11540
  );
11541
+ const columnsHeld = useRef14(null);
11542
+ const columns = columnsHeld.current !== null && sameColumns(columnsHeld.current, drawnColumns) ? columnsHeld.current : drawnColumns;
11543
+ columnsHeld.current = columns;
11487
11544
  const [localColumns, setLocalColumns] = useState31(
11488
11545
  null
11489
11546
  );
@@ -11556,6 +11613,274 @@ function Grid({
11556
11613
  } : {}
11557
11614
  };
11558
11615
  }, [presentation, onPresentationChange, writeLook, merged, phone]);
11616
+ const queryTotal = records.data?.total ?? rows.length;
11617
+ const tableQuery = useMemo22(
11618
+ () => merged ? {
11619
+ // THE STORE ANSWERS THE QUERY (tranche 6k): `data` is already the page.
11620
+ mode: "controlled",
11621
+ state: query,
11622
+ totalItems: queryTotal,
11623
+ onStateChange: setQuery,
11624
+ sourceProcessing: { search: "source", columnFilters: "source", sort: "source" }
11625
+ } : { mode: "controlled-local", state: query, onStateChange: setQuery },
11626
+ [merged, query, queryTotal]
11627
+ );
11628
+ const showAddLine = mayAddRow && rows.length > 0;
11629
+ const showSummaryLine = rows.length > 0 && (!merged || Object.keys(chosenSummaries).length > 0);
11630
+ const footerFace = JSON.stringify([
11631
+ showAddLine ? added.busy : null,
11632
+ showSummaryLine ? [chosenSummaries, summaries.values, summaries.rowCount, summaries.loading] : null
11633
+ ]);
11634
+ const footerRows = useMemo22(
11635
+ () => [...showAddLine ? [ADD_ROW] : [], ...showSummaryLine ? [SUMMARY_ROW] : []],
11636
+ // `footerFace`, `ordered` and the summaries' operations are what the footer's cells draw.
11637
+ // eslint-disable-next-line react-hooks/exhaustive-deps
11638
+ [showAddLine, showSummaryLine, footerFace, ordered, summaries.operations, summaries.askOperations]
11639
+ );
11640
+ const coverageTotal = typeof records.data?.total === "number" ? records.data.total : null;
11641
+ const tableCoverage = useMemo22(
11642
+ () => ({
11643
+ answeredBy: merged ? "source" : "client",
11644
+ ...merged && coverageTotal !== null ? { matched: coverageTotal } : {},
11645
+ noun: "record",
11646
+ ...coverageTotal !== null ? { total: coverageTotal } : {}
11647
+ }),
11648
+ [merged, coverageTotal]
11649
+ );
11650
+ const sheetNow = useRef14({ merged, writeRange, editing, fields: fields.data ?? [], mayWriteRow, notify: host.notify });
11651
+ sheetNow.current = { merged, writeRange, editing, fields: fields.data ?? [], mayWriteRow, notify: host.notify };
11652
+ const hasNotify = Boolean(host.notify);
11653
+ const readOnlyReason = !editable || !rights.known || rights.write ? "Editing is off here, so this table can be copied but not changed." : rights.why("write");
11654
+ const spreadsheetConfig = useMemo22(
11655
+ () => canWrite && editable ? {
11656
+ enabled: true,
11657
+ writableColumnIds: pasteableKeys,
11658
+ onPatch: (patches, intent) => {
11659
+ const now = sheetNow.current;
11660
+ if (now.merged) {
11661
+ void now.writeRange(patches, intent);
11662
+ return;
11663
+ }
11664
+ for (const patch of patches) {
11665
+ now.editing.setCell(patch.rowId, patch.columnId, patch.value);
11666
+ }
11667
+ },
11668
+ ...merged ? { undoDepth: 0, onSelectionChange: followSelection, controllerRef: sheetController } : {},
11669
+ columnRefusal: (columnId) => {
11670
+ const field2 = sheetNow.current.fields.find((f) => f.key === columnId);
11671
+ if (!field2) return `This table has no column called ${columnId}.`;
11672
+ return `${fieldName(field2)} cannot take a pasted value \u2014 ${fieldTypeLabel(field2)} is worked out or chosen, not typed.`;
11673
+ },
11674
+ validateCell: ({ columnId, value }) => {
11675
+ const field2 = sheetNow.current.fields.find((f) => f.key === columnId);
11676
+ if (!field2) return `This table has no column called ${columnId}.`;
11677
+ const judged = valueFromPastedText(field2, value === null ? "" : String(value));
11678
+ return "refusal" in judged ? judged.refusal : void 0;
11679
+ },
11680
+ onPastePlan: receivePastePlan,
11681
+ /*
11682
+ * TYPE-TO-EDIT (design-system 0.43 `onEditRequest`): the cell a person is on
11683
+ * opens ITS OWN editor — the Field's — holding what they typed (a letter, an
11684
+ * IME composition, an Option/AltGr character, dictation). A worked-out column
11685
+ * or a row this person may not write answers false, so nothing is swallowed.
11686
+ */
11687
+ onEditRequest: (address, seed) => {
11688
+ const now = sheetNow.current;
11689
+ const field2 = now.fields.find((f) => f.key === address.columnId);
11690
+ if (!field2 || !fieldIsEditable(field2) || !now.mayWriteRow(address.rowId)) return false;
11691
+ if (seed !== null && seed !== "" && typingOpens(field2) === "none") return false;
11692
+ now.editing.begin({ rowId: address.rowId, key: field2.key }, seed);
11693
+ return true;
11694
+ },
11695
+ ...hasNotify ? { notify: (message) => sheetNow.current.notify?.success(message) } : {}
11696
+ } : {
11697
+ /*
11698
+ * RANGE SELECTION AND COPY FOR A READER (merge step 3, 0.42 `readOnly`). A person
11699
+ * who may not change this table may still drag out a range, select all and press
11700
+ * Cmd-C for a real Excel/Sheets block — the UX walk graded range selection BROKEN
11701
+ * on a table whose writes were refused. Every write gesture (cut, paste, clear,
11702
+ * fill, undo) says why it cannot, in the store's own words.
11703
+ */
11704
+ enabled: true,
11705
+ readOnly: true,
11706
+ ...merged ? { onSelectionChange: followSelection, controllerRef: sheetController } : {},
11707
+ readOnlyReason
11708
+ },
11709
+ [canWrite, editable, pasteableKeys, merged, followSelection, receivePastePlan, hasNotify, readOnlyReason]
11710
+ );
11711
+ const toolbarFields = fields.data;
11712
+ const hasSeveralRows = rows.length > 1;
11713
+ const editingBusy = editing.busy;
11714
+ const addRecord = editing.addRecord;
11715
+ const phoneSheetTools = merged && phone ? controls.sheetTools : null;
11716
+ const tableToolbar = useMemo22(
11717
+ () => ({
11718
+ search: true,
11719
+ searchPlaceholder: "Search records",
11720
+ /*
11721
+ * A PHONE (tranche 6o): search and the one Tools button sit side by side at the START of
11722
+ * the row, so Tools is never scrolled out of reach; the search is the same query.
11723
+ */
11724
+ ...merged && phone ? {
11725
+ customSearch: /* @__PURE__ */ jsx42("div", { className: "flex min-w-0 flex-1 items-center gap-1.5", "data-records-grid-phone-search": "", children: /* @__PURE__ */ jsx42(
11726
+ "input",
11727
+ {
11728
+ type: "search",
11729
+ value: query.search,
11730
+ placeholder: "Search",
11731
+ "aria-label": "Search records",
11732
+ onChange: (event) => {
11733
+ const search = event.target.value;
11734
+ setQuery((held) => ({ ...held, search, page: 1 }));
11735
+ },
11736
+ className: "h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
11737
+ style: { fontSize: "16px" }
11738
+ }
11739
+ ) })
11740
+ } : {},
11741
+ /* ONE ROW (lane TABLE-PAGE-CHROME): on a table page the grid's search, columns and
11742
+ sort are drawn INTO the page's toolbar row, never a second bar under it. */
11743
+ ...toolbarSlot !== void 0 ? { portalInto: toolbarSlot, singleRow: true } : {},
11744
+ // SCR-2 / SCR-4: PRESENT for an admin, ABSENT for everyone else.
11745
+ // There is no third state: this package ships no disabled variant of
11746
+ // these controls, so a member cannot be shown a dead one by mistake.
11747
+ /*
11748
+ * WHERE THE GRID'S CONTROLS GO (lane C, review 2026-09-26 D6). Inline when the row has
11749
+ * room; behind the row's "…" once the page row is folded that far (Undo / Redo and New
11750
+ * record stay beside it — the primary action is never folded); on a phone EVERY control
11751
+ * — the table's own (clear, copy/export, group, columns), these, the search, the sort and
11752
+ * the page's filter, layout and share — in ONE "Tools" sheet. Never dropped.
11753
+ */
11754
+ actions: /* @__PURE__ */ jsxs36(Fragment15, { children: [
11755
+ manual ? /* @__PURE__ */ jsx42(
11756
+ SortStateControl,
11757
+ {
11758
+ ...handOrderSortState({
11759
+ sortName: columnSort ? columnLabel(toolbarFields ?? [], columnSort.id) : null,
11760
+ direction: columnSort?.direction ?? null,
11761
+ onBack: () => setQuery((held) => ({ ...held, sort: null, page: 1 })),
11762
+ ...viewOrder?.onUseSort && columnSort ? {
11763
+ onUseSort: () => {
11764
+ const sort = columnSort;
11765
+ const chosen = { field: sort.id, direction: sort.direction };
11766
+ setHeldOrder({ order: "sorted", sorts: [chosen] });
11767
+ viewOrder.onUseSort?.(chosen);
11768
+ host.notify?.success(
11769
+ `Sorted by ${columnLabel(toolbarFields ?? [], sort.id)} ${sortArrow(sort.direction)}. ${MANUAL_SORT.replacedToast}`
11770
+ );
11771
+ }
11772
+ } : {}
11773
+ })
11774
+ }
11775
+ ) : null,
11776
+ viewId && viewOrder?.onPlaced && hasSeveralRows ? /* @__PURE__ */ jsx42(Button27, { size: "sm", variant: "ghost", onClick: () => setReordering(true), "data-records-reorder-open": "", children: "Reorder" }) : null,
11777
+ !merged && Boolean(editable) && rights.write ? /* @__PURE__ */ jsx42(Button27, { size: "sm", disabled: editingBusy, onClick: () => void addRecord(), children: editingBusy ? "Adding\u2026" : "New record" }) : null,
11778
+ rights.structure && onAddField ? /* @__PURE__ */ jsx42(AddFieldButton, { onClick: onAddField }) : null,
11779
+ /* @__PURE__ */ jsx42(
11780
+ GridLayoutMenu,
11781
+ {
11782
+ look,
11783
+ firstColumn: sortedKeys[0] ? {
11784
+ key: sortedKeys[0],
11785
+ name: (() => {
11786
+ const first = (toolbarFields ?? []).find((f) => f.key === sortedKeys[0]);
11787
+ return first ? fieldName(first) : humanize(sortedKeys[0]);
11788
+ })()
11789
+ } : null,
11790
+ onChange: changeLayout,
11791
+ kept: Boolean(onPresentationChange)
11792
+ }
11793
+ )
11794
+ ] }),
11795
+ ...merged ? {
11796
+ overflow: {
11797
+ mode: phone ? "sheet" : toolbarFold2 >= 3 ? "menu" : "inline",
11798
+ pinned: /* @__PURE__ */ jsxs36(Fragment15, { children: [
11799
+ /* @__PURE__ */ jsx42(UndoRedoLive, { source: undoSource }),
11800
+ Boolean(editable) && rights.write ? /* @__PURE__ */ jsx42(Button27, { size: "sm", className: "shrink-0", disabled: added.busy, onClick: () => addRowRef.current(), children: added.busy ? "Adding\u2026" : "New record" }) : null
11801
+ ] }),
11802
+ ...phone ? {
11803
+ sheetTop: /* @__PURE__ */ jsxs36("div", { className: "flex flex-col gap-1.5 pb-1", "data-records-grid-sheet-top": "", "data-matrx-table-tools-keep-open": "", children: [
11804
+ /* @__PURE__ */ jsx42(
11805
+ "input",
11806
+ {
11807
+ type: "search",
11808
+ value: query.search,
11809
+ placeholder: "Search",
11810
+ "aria-label": "Search records",
11811
+ onChange: (event) => {
11812
+ const search = event.target.value;
11813
+ setQuery((held) => ({ ...held, search, page: 1 }));
11814
+ },
11815
+ className: "h-10 w-full min-w-0 rounded-md border border-input bg-background px-2 text-foreground placeholder:text-muted-foreground",
11816
+ style: { fontSize: "16px" }
11817
+ }
11818
+ ),
11819
+ /* @__PURE__ */ jsxs36("label", { className: "flex items-center gap-2 text-xs text-muted-foreground", "data-records-grid-sheet-sort": "", children: [
11820
+ /* @__PURE__ */ jsx42("span", { className: "shrink-0", children: "Sort by" }),
11821
+ /* @__PURE__ */ jsxs36(
11822
+ "select",
11823
+ {
11824
+ "aria-label": "Sort by",
11825
+ value: query.sort ? `${query.sort.id}:${query.sort.direction}` : "",
11826
+ onChange: (event) => {
11827
+ const [id, direction] = event.target.value.split(":");
11828
+ setQuery((held) => ({
11829
+ ...held,
11830
+ page: 1,
11831
+ sort: id ? { id, direction: direction === "desc" ? "desc" : "asc" } : null
11832
+ }));
11833
+ },
11834
+ className: "h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-foreground",
11835
+ style: { fontSize: "16px" },
11836
+ children: [
11837
+ /* @__PURE__ */ jsx42("option", { value: "", children: "No sort" }),
11838
+ ordered.flatMap((field2) => [
11839
+ /* @__PURE__ */ jsx42("option", { value: `${field2.key}:asc`, children: `${fieldName(field2)} \u2191` }, `${field2.key}:asc`),
11840
+ /* @__PURE__ */ jsx42("option", { value: `${field2.key}:desc`, children: `${fieldName(field2)} \u2193` }, `${field2.key}:desc`)
11841
+ ])
11842
+ ]
11843
+ }
11844
+ )
11845
+ ] }),
11846
+ phoneSheetTools
11847
+ ] }),
11848
+ ...sheetExtras ? { sheetExtras } : {}
11849
+ } : {}
11850
+ }
11851
+ } : {}
11852
+ }),
11853
+ [
11854
+ merged,
11855
+ phone,
11856
+ query.search,
11857
+ query.sort,
11858
+ toolbarSlot,
11859
+ manual,
11860
+ columnSort,
11861
+ toolbarFields,
11862
+ viewOrder,
11863
+ host,
11864
+ viewId,
11865
+ hasSeveralRows,
11866
+ editable,
11867
+ rights.write,
11868
+ rights.structure,
11869
+ editingBusy,
11870
+ addRecord,
11871
+ onAddField,
11872
+ look,
11873
+ sortedKeys,
11874
+ changeLayout,
11875
+ onPresentationChange,
11876
+ toolbarFold2,
11877
+ undoSource,
11878
+ added.busy,
11879
+ ordered,
11880
+ phoneSheetTools,
11881
+ sheetExtras
11882
+ ]
11883
+ );
11559
11884
  const labelOfField = useCallback16(
11560
11885
  (key) => {
11561
11886
  const held = (fields.data ?? []).find((f) => f.key === key);
@@ -11724,25 +12049,12 @@ function Grid({
11724
12049
  viewTabs: false,
11725
12050
  stickyHeader: true,
11726
12051
  pageSize,
11727
- query: merged ? {
11728
- // THE STORE ANSWERS THE QUERY (tranche 6k): `data` is already the page.
11729
- mode: "controlled",
11730
- state: query,
11731
- totalItems: records.data?.total ?? rows.length,
11732
- onStateChange: setQuery,
11733
- sourceProcessing: { search: "source", columnFilters: "source", sort: "source" }
11734
- } : { mode: "controlled-local", state: query, onStateChange: setQuery },
12052
+ query: tableQuery,
11735
12053
  tableClassName: cn20(
11736
12054
  !look.wrap && "[&_tbody_td]:max-w-[28rem] [&_tbody_td]:overflow-hidden [&_tbody_td]:text-ellipsis [&_tbody_td]:whitespace-nowrap! [&_tbody_td_*]:whitespace-nowrap!",
11737
12055
  look.fit === "scroll" && "sm:[&>table]:w-max! sm:[&>table]:max-w-none! sm:[&>table]:min-w-full!"
11738
12056
  ),
11739
- ...(() => {
11740
- const footerRows = [
11741
- ...mayAddRow && rows.length > 0 ? [ADD_ROW] : [],
11742
- ...rows.length > 0 && (!merged || Object.keys(chosenSummaries).length > 0) ? [SUMMARY_ROW] : []
11743
- ];
11744
- return footerRows.length > 0 ? { footerRows } : {};
11745
- })(),
12057
+ footerRows,
11746
12058
  ...copyConfig ? { copy: copyConfig } : {},
11747
12059
  ...offerSelection ? {
11748
12060
  selection: {
@@ -11776,75 +12088,15 @@ function Grid({
11776
12088
  ] })
11777
12089
  }
11778
12090
  } : {},
11779
- detail: { enabled: false },
11780
- window: { enabled: false },
12091
+ detail: DETAIL_OFF,
12092
+ window: WINDOW_OFF,
11781
12093
  ...controls.contextMenu ? { contextMenu: controls.contextMenu } : {},
11782
- ...canWrite && editable ? {
11783
- spreadsheet: {
11784
- enabled: true,
11785
- writableColumnIds: pasteableKeys,
11786
- onPatch: (patches, intent) => {
11787
- if (merged) {
11788
- void writeRange(patches, intent);
11789
- return;
11790
- }
11791
- for (const patch of patches) {
11792
- editing.setCell(patch.rowId, patch.columnId, patch.value);
11793
- }
11794
- },
11795
- ...merged ? { undoDepth: 0, onSelectionChange: followSelection, controllerRef: sheetController } : {},
11796
- columnRefusal: (columnId) => {
11797
- const field2 = (fields.data ?? []).find((f) => f.key === columnId);
11798
- if (!field2) return `This table has no column called ${columnId}.`;
11799
- return `${fieldName(field2)} cannot take a pasted value \u2014 ${fieldTypeLabel(field2)} is worked out or chosen, not typed.`;
11800
- },
11801
- validateCell: ({ columnId, value }) => {
11802
- const field2 = (fields.data ?? []).find((f) => f.key === columnId);
11803
- if (!field2) return `This table has no column called ${columnId}.`;
11804
- const judged = valueFromPastedText(field2, value === null ? "" : String(value));
11805
- return "refusal" in judged ? judged.refusal : void 0;
11806
- },
11807
- onPastePlan: receivePastePlan,
11808
- /*
11809
- * TYPE-TO-EDIT (design-system 0.43 `onEditRequest`): the cell a person is on
11810
- * opens ITS OWN editor — the Field's — holding what they typed (a letter, an
11811
- * IME composition, an Option/AltGr character, dictation). A worked-out column
11812
- * or a row this person may not write answers false, so nothing is swallowed.
11813
- */
11814
- onEditRequest: (address, seed) => {
11815
- const field2 = (fields.data ?? []).find((f) => f.key === address.columnId);
11816
- if (!field2 || !fieldIsEditable(field2) || !mayWriteRow(address.rowId)) return false;
11817
- if (seed !== null && seed !== "" && typingOpens(field2) === "none") return false;
11818
- editing.begin({ rowId: address.rowId, key: field2.key }, seed);
11819
- return true;
11820
- },
11821
- ...host.notify ? { notify: (message) => host.notify?.success(message) } : {}
11822
- }
11823
- } : {
11824
- /*
11825
- * RANGE SELECTION AND COPY FOR A READER (merge step 3, 0.42 `readOnly`). A person
11826
- * who may not change this table may still drag out a range, select all and press
11827
- * Cmd-C for a real Excel/Sheets block — the UX walk graded range selection BROKEN
11828
- * on a table whose writes were refused. Every write gesture (cut, paste, clear,
11829
- * fill, undo) says why it cannot, in the store's own words.
11830
- */
11831
- spreadsheet: {
11832
- enabled: true,
11833
- readOnly: true,
11834
- ...merged ? { onSelectionChange: followSelection, controllerRef: sheetController } : {},
11835
- readOnlyReason: !editable || !rights.known || rights.write ? "Editing is off here, so this table can be copied but not changed." : rights.why("write")
11836
- }
11837
- },
12094
+ spreadsheet: spreadsheetConfig,
11838
12095
  ...groupingConfig ? { grouping: groupingConfig } : {},
11839
12096
  columnState,
11840
12097
  ...look.rowHeight ? { rowHeight: look.rowHeight } : {},
11841
- virtualize: fills ? { threshold: 40 } : true,
11842
- coverage: {
11843
- answeredBy: merged ? "source" : "client",
11844
- ...merged && typeof records.data?.total === "number" ? { matched: records.data.total } : {},
11845
- noun: "record",
11846
- ...typeof records.data?.total === "number" ? { total: records.data.total } : {}
11847
- },
12098
+ virtualize: fills ? VIRTUALIZE_FILLED : true,
12099
+ coverage: tableCoverage,
11848
12100
  ...tableStyleProp ? { tableStyle: tableStyleProp } : {},
11849
12101
  ...onOpenRecord ? { onRowOpen: (row) => onOpenRecord(row.id) } : {},
11850
12102
  ...offersRowActions ? { rowActions } : {},
@@ -11856,142 +12108,7 @@ function Grid({
11856
12108
  ...mayAddRow ? { addFirst: /* @__PURE__ */ jsx42(AddFirstRowButton, { onAdd: () => void addRowHere(), busy: added.busy }) } : {},
11857
12109
  emptyDescription: rights.write ? "Add the first one and it appears here." : rights.known ? `Nothing has been written to this table. ${rights.why("write")}` : "Nothing has been written to this table."
11858
12110
  }),
11859
- toolbar: {
11860
- search: true,
11861
- searchPlaceholder: "Search records",
11862
- /*
11863
- * A PHONE (tranche 6o): search and the one Tools button sit side by side at the START of
11864
- * the row, so Tools is never scrolled out of reach; the search is the same query.
11865
- */
11866
- ...merged && phone ? {
11867
- customSearch: /* @__PURE__ */ jsx42("div", { className: "flex min-w-0 flex-1 items-center gap-1.5", "data-records-grid-phone-search": "", children: /* @__PURE__ */ jsx42(
11868
- "input",
11869
- {
11870
- type: "search",
11871
- value: query.search,
11872
- placeholder: "Search",
11873
- "aria-label": "Search records",
11874
- onChange: (event) => {
11875
- const search = event.target.value;
11876
- setQuery((held) => ({ ...held, search, page: 1 }));
11877
- },
11878
- className: "h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
11879
- style: { fontSize: "16px" }
11880
- }
11881
- ) })
11882
- } : {},
11883
- /* ONE ROW (lane TABLE-PAGE-CHROME): on a table page the grid's search, columns and
11884
- sort are drawn INTO the page's toolbar row, never a second bar under it. */
11885
- ...toolbarSlot !== void 0 ? { portalInto: toolbarSlot, singleRow: true } : {},
11886
- // SCR-2 / SCR-4: PRESENT for an admin, ABSENT for everyone else.
11887
- // There is no third state: this package ships no disabled variant of
11888
- // these controls, so a member cannot be shown a dead one by mistake.
11889
- /*
11890
- * WHERE THE GRID'S CONTROLS GO (lane C, review 2026-09-26 D6). Inline when the row has
11891
- * room; behind the row's "…" once the page row is folded that far (Undo / Redo and New
11892
- * record stay beside it — the primary action is never folded); on a phone EVERY control
11893
- * — the table's own (clear, copy/export, group, columns), these, the search, the sort and
11894
- * the page's filter, layout and share — in ONE "Tools" sheet. Never dropped.
11895
- */
11896
- actions: /* @__PURE__ */ jsxs36(Fragment15, { children: [
11897
- manual ? /* @__PURE__ */ jsx42(
11898
- SortStateControl,
11899
- {
11900
- ...handOrderSortState({
11901
- sortName: columnSort ? columnLabel(fields.data ?? [], columnSort.id) : null,
11902
- direction: columnSort?.direction ?? null,
11903
- onBack: () => setQuery((held) => ({ ...held, sort: null, page: 1 })),
11904
- ...viewOrder?.onUseSort && columnSort ? {
11905
- onUseSort: () => {
11906
- const sort = columnSort;
11907
- const chosen = { field: sort.id, direction: sort.direction };
11908
- setHeldOrder({ order: "sorted", sorts: [chosen] });
11909
- viewOrder.onUseSort?.(chosen);
11910
- host.notify?.success(
11911
- `Sorted by ${columnLabel(fields.data ?? [], sort.id)} ${sortArrow(sort.direction)}. ${MANUAL_SORT.replacedToast}`
11912
- );
11913
- }
11914
- } : {}
11915
- })
11916
- }
11917
- ) : null,
11918
- viewId && viewOrder?.onPlaced && rows.length > 1 ? /* @__PURE__ */ jsx42(Button27, { size: "sm", variant: "ghost", onClick: () => setReordering(true), "data-records-reorder-open": "", children: "Reorder" }) : null,
11919
- !merged && Boolean(editable) && rights.write ? /* @__PURE__ */ jsx42(Button27, { size: "sm", disabled: editing.busy, onClick: () => void editing.addRecord(), children: editing.busy ? "Adding\u2026" : "New record" }) : null,
11920
- rights.structure && onAddField ? /* @__PURE__ */ jsx42(AddFieldButton, { onClick: onAddField }) : null,
11921
- /* @__PURE__ */ jsx42(
11922
- GridLayoutMenu,
11923
- {
11924
- look,
11925
- firstColumn: sortedKeys[0] ? {
11926
- key: sortedKeys[0],
11927
- name: (() => {
11928
- const first = (fields.data ?? []).find((f) => f.key === sortedKeys[0]);
11929
- return first ? fieldName(first) : humanize(sortedKeys[0]);
11930
- })()
11931
- } : null,
11932
- onChange: changeLayout,
11933
- kept: Boolean(onPresentationChange)
11934
- }
11935
- )
11936
- ] }),
11937
- ...merged ? {
11938
- overflow: {
11939
- mode: phone ? "sheet" : toolbarFold2 >= 3 ? "menu" : "inline",
11940
- pinned: /* @__PURE__ */ jsxs36(Fragment15, { children: [
11941
- /* @__PURE__ */ jsx42(UndoRedoButtons, { undo }),
11942
- Boolean(editable) && rights.write ? /* @__PURE__ */ jsx42(Button27, { size: "sm", className: "shrink-0", disabled: added.busy, onClick: () => void addRowHere(), children: added.busy ? "Adding\u2026" : "New record" }) : null
11943
- ] }),
11944
- ...phone ? {
11945
- sheetTop: /* @__PURE__ */ jsxs36("div", { className: "flex flex-col gap-1.5 pb-1", "data-records-grid-sheet-top": "", "data-matrx-table-tools-keep-open": "", children: [
11946
- /* @__PURE__ */ jsx42(
11947
- "input",
11948
- {
11949
- type: "search",
11950
- value: query.search,
11951
- placeholder: "Search",
11952
- "aria-label": "Search records",
11953
- onChange: (event) => {
11954
- const search = event.target.value;
11955
- setQuery((held) => ({ ...held, search, page: 1 }));
11956
- },
11957
- className: "h-10 w-full min-w-0 rounded-md border border-input bg-background px-2 text-foreground placeholder:text-muted-foreground",
11958
- style: { fontSize: "16px" }
11959
- }
11960
- ),
11961
- /* @__PURE__ */ jsxs36("label", { className: "flex items-center gap-2 text-xs text-muted-foreground", "data-records-grid-sheet-sort": "", children: [
11962
- /* @__PURE__ */ jsx42("span", { className: "shrink-0", children: "Sort by" }),
11963
- /* @__PURE__ */ jsxs36(
11964
- "select",
11965
- {
11966
- "aria-label": "Sort by",
11967
- value: query.sort ? `${query.sort.id}:${query.sort.direction}` : "",
11968
- onChange: (event) => {
11969
- const [id, direction] = event.target.value.split(":");
11970
- setQuery((held) => ({
11971
- ...held,
11972
- page: 1,
11973
- sort: id ? { id, direction: direction === "desc" ? "desc" : "asc" } : null
11974
- }));
11975
- },
11976
- className: "h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-foreground",
11977
- style: { fontSize: "16px" },
11978
- children: [
11979
- /* @__PURE__ */ jsx42("option", { value: "", children: "No sort" }),
11980
- ordered.flatMap((field2) => [
11981
- /* @__PURE__ */ jsx42("option", { value: `${field2.key}:asc`, children: `${fieldName(field2)} \u2191` }, `${field2.key}:asc`),
11982
- /* @__PURE__ */ jsx42("option", { value: `${field2.key}:desc`, children: `${fieldName(field2)} \u2193` }, `${field2.key}:desc`)
11983
- ])
11984
- ]
11985
- }
11986
- )
11987
- ] }),
11988
- controls.sheetTools
11989
- ] }),
11990
- ...sheetExtras ? { sheetExtras } : {}
11991
- } : {}
11992
- }
11993
- } : {}
11994
- }
12111
+ toolbar: tableToolbar
11995
12112
  },
11996
12113
  `records-grid-${tableId}:${openingKey}`
11997
12114
  )
@@ -12093,6 +12210,58 @@ function Grid({
12093
12210
  }
12094
12211
  ) });
12095
12212
  }
12213
+ var DETAIL_OFF = { enabled: false };
12214
+ var WINDOW_OFF = { enabled: false };
12215
+ var VIRTUALIZE_FILLED = { threshold: 40 };
12216
+ function menuFace(items) {
12217
+ return JSON.stringify(items, (_key, value) => typeof value === "function" ? "\u0192" : value);
12218
+ }
12219
+ function menuThroughLatest(items, at, latest) {
12220
+ return items.map((item) => {
12221
+ const path = `${at}/${item.id}`;
12222
+ return {
12223
+ ...item,
12224
+ ...item.onSelect ? { onSelect: () => latest.get(path)?.() } : {},
12225
+ ...item.submenu ? { submenu: menuThroughLatest(item.submenu, path, latest) } : {}
12226
+ };
12227
+ });
12228
+ }
12229
+ function bindLatest(items, at, latest) {
12230
+ for (const item of items) {
12231
+ const path = `${at}/${item.id}`;
12232
+ if (item.onSelect) latest.set(path, item.onSelect);
12233
+ if (item.submenu) bindLatest(item.submenu, path, latest);
12234
+ }
12235
+ }
12236
+ function holdHeaderMenu(held, latest, key, items) {
12237
+ if (!items) return items;
12238
+ const at = `menu:${key}`;
12239
+ bindLatest(items, at, latest);
12240
+ const sig = menuFace(items);
12241
+ const was = held.get(at);
12242
+ if (was && was.sig === sig) return was.value;
12243
+ const value = menuThroughLatest(items, at, latest);
12244
+ held.set(at, { sig, value });
12245
+ return value;
12246
+ }
12247
+ function holdByContent(held, at, value) {
12248
+ if (value === void 0) return value;
12249
+ const sig = JSON.stringify(value);
12250
+ const was = held.get(at);
12251
+ if (was && was.sig === sig) return was.value;
12252
+ held.set(at, { sig, value });
12253
+ return value;
12254
+ }
12255
+ function sameColumns(a, b) {
12256
+ if (a === b) return true;
12257
+ if (a.length !== b.length) return false;
12258
+ return a.every((column, i) => {
12259
+ const other = b[i];
12260
+ if (column === other) return true;
12261
+ const keys = Object.keys(column);
12262
+ return keys.length === Object.keys(other).length && keys.every((key) => Object.is(column[key], other[key]));
12263
+ });
12264
+ }
12096
12265
  function withHeaderMenus(columns, menuFor, filterFor) {
12097
12266
  return columns.map((column) => {
12098
12267
  const items = menuFor(String(column.id));