@connextar/house 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -491,6 +491,75 @@ module to React and its icons — no router, no toast library, no design system
491
491
  because the moment it reaches for one of those, the claim that six apps can host
492
492
  it stops being true.
493
493
 
494
+ ### `@connextar/house/grid`
495
+
496
+ Search, sorting, per-column filters, column visibility, saved views and paging
497
+ for a list screen — the arithmetic, with no markup in it.
498
+
499
+ This is the rule's second clause again, and the clearest case since `ux`: three
500
+ apps had written the same list behaviour three times. FB's grids sit on TanStack
501
+ Table with sticky columns and a context menu; AltEd hand-rolled the same
502
+ behaviour over its own `Column<T>`; PharmaLine had four hand-written `<table>`s
503
+ with one search box between them. What actually differed was entirely the
504
+ rendering. What was identical — and repeatedly wrong in small ways — was the
505
+ arithmetic underneath: which rows match, in what order, on which page.
506
+
507
+ Two of those small ways are worth naming, because both are in here now and
508
+ neither is obvious. A plain string sort files every "Year 1x" before "Year 2",
509
+ which reads as a broken table rather than a subtle one, so ordering goes through
510
+ an `Intl.Collator` with `numeric` and `sensitivity: "base"`. And filtering a list
511
+ while on page 4 strands the reader on a page that no longer exists — clamped
512
+ when the page is derived, not corrected afterwards by an effect, which rendered
513
+ the empty page once before putting it right.
514
+
515
+ There is deliberately no table here. Each app already has its own table
516
+ primitives and its own look, and bringing a second design system into an app
517
+ that has one is the cost this package exists to avoid. So a host declares
518
+ columns that extend `GridColumnSpec` with whatever its own table needs, and gets
519
+ the same objects back:
520
+
521
+ ```tsx
522
+ interface Column<T> extends GridColumnSpec<T> {
523
+ header: ReactNode;
524
+ cell: (row: T) => ReactNode;
525
+ numeric?: boolean;
526
+ }
527
+
528
+ const columns: Column<Patient>[] = [
529
+ { key: "name", header: "Name", label: "Name", cell: (p) => p.name, sortValue: (p) => p.name },
530
+ // `filterValue` is what offers the column a filter — and only when the rows
531
+ // hold more than one distinct value, since a filter with one option is clutter.
532
+ {
533
+ key: "status",
534
+ header: "Status",
535
+ label: "Status",
536
+ cell: (p) => <Badge>{p.status}</Badge>,
537
+ sortValue: (p) => p.status,
538
+ filterValue: (p) => p.status,
539
+ },
540
+ ];
541
+
542
+ const grid = useGrid({ rows, columns, storageKey: "patients", initialSort: { key: "name", direction: "asc" } });
543
+ ```
544
+
545
+ `grid.rows` is the page to render and `grid.visibleColumns` the columns to draw;
546
+ `grid.matched` is everything the search and filters kept, for a total or a bulk
547
+ action. `storageKey` is what lets column choices be remembered — in the browser,
548
+ through `useHiddenColumns`, which is exported separately so a grid paging on the
549
+ server keeps the same behaviour and the same stored choice.
550
+
551
+ `GridController` is the contract a toolbar needs, and `useGrid` is only one thing
552
+ that satisfies it: an app whose list is too long to send in one response drives
553
+ the same search box, filter menus, column picker and saved-views menu from its
554
+ own server-backed hook, and the UI cannot tell.
555
+
556
+ `GridViewState` is what a saved view holds. It is written somewhere and read back
557
+ later, possibly by a newer build, so `parseGridView` repairs rather than trusts:
558
+ a view saved before `pageSize` existed is still a valid view, and one stale
559
+ filter is dropped rather than throwing when the menu entry is picked. Where views
560
+ are stored — a table keyed by account, or the browser — is the app's decision,
561
+ because only the app knows whether a view should follow someone to their phone.
562
+
494
563
  ### Styles — `@connextar/house/house.css`
495
564
 
496
565
  What the components need that utility classes cannot say: rich text, the editor's
@@ -0,0 +1,47 @@
1
+ import type { GridValue } from "./compare.js";
2
+ /**
3
+ * What the engine needs to know about a column — and nothing more.
4
+ *
5
+ * Deliberately free of `header`, `cell` and every other presentational field:
6
+ * the engine never renders anything, and each app's table already has its own
7
+ * column type with its own look. Hosts extend this interface and hand their own
8
+ * columns in; `useGrid` is generic over that type and gives the same objects
9
+ * back in `visibleColumns`, so nothing has to be mapped on the way through.
10
+ */
11
+ export interface GridColumnSpec<T> {
12
+ /** Stable identity, used by sorting, filters, saved views and column choices. */
13
+ key: string;
14
+ /**
15
+ * Plain-text name for the column menu and the filter label. Falls back to
16
+ * `key` — which is a poor label, so hosts with a string header should pass it.
17
+ */
18
+ label?: string;
19
+ /**
20
+ * The underlying value, for sorting and for matching a search term.
21
+ *
22
+ * A cell renders arbitrary markup, which cannot be sorted or searched, so a
23
+ * column that wants either has to say what it actually holds. Declaring this
24
+ * is also what makes a column sortable at all — better than sorting by
25
+ * something meaningless.
26
+ */
27
+ sortValue?: (row: T) => GridValue;
28
+ /**
29
+ * A short, repeating label — a status, a type, a person — offered as a filter
30
+ * on the column. Only offered when the rows hold more than one distinct
31
+ * value: a filter with a single option is clutter.
32
+ */
33
+ filterValue?: (row: T) => string | null | undefined;
34
+ /**
35
+ * Keep this column out of the global search. Use it for a column whose value
36
+ * is an opaque id or a duplicate of another column's — matches against it
37
+ * confuse more than they help.
38
+ */
39
+ unsearchable?: boolean;
40
+ }
41
+ /** A column's distinct values, offered as a filter. */
42
+ export interface GridFacet {
43
+ key: string;
44
+ label: string;
45
+ values: string[];
46
+ }
47
+ //# sourceMappingURL=columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"columns.d.ts","sourceRoot":"","sources":["../../src/grid/columns.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,iFAAiF;IACjF,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,SAAS,CAAC;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACpD;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,uDAAuD;AACvD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=columns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"columns.js","sourceRoot":"","sources":["../../src/grid/columns.ts"],"names":[],"mappings":""}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Ordering two cell values the way a reader expects them.
3
+ *
4
+ * Extracted from AltEd, where the rule was learnt the hard way: a plain string
5
+ * comparison reads digits one character at a time and files every "Year 1x"
6
+ * before "Year 2", which looks exactly like a broken sort rather than a subtle
7
+ * one. `numeric` fixes that; `sensitivity: "base"` folds case and accents so
8
+ * "bertie" does not sort after "Cai".
9
+ */
10
+ /** What a column contributes to sorting. Anything else is the host's business. */
11
+ export type GridValue = string | number | Date | null | undefined;
12
+ /** Order two labels the way a reader expects. Nullish sorts as an empty string. */
13
+ export declare function compareValues(a: GridValue, b: GridValue): number;
14
+ /** Compare two plain labels — for the value lists inside a column's filter. */
15
+ export declare function compareLabels(a: string, b: string): number;
16
+ //# sourceMappingURL=compare.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compare.d.ts","sourceRoot":"","sources":["../../src/grid/compare.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,kFAAkF;AAClF,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,CAAC;AAIlE,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,GAAG,MAAM,CAOhE;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAE1D"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ordering two cell values the way a reader expects them.
3
+ *
4
+ * Extracted from AltEd, where the rule was learnt the hard way: a plain string
5
+ * comparison reads digits one character at a time and files every "Year 1x"
6
+ * before "Year 2", which looks exactly like a broken sort rather than a subtle
7
+ * one. `numeric` fixes that; `sensitivity: "base"` folds case and accents so
8
+ * "bertie" does not sort after "Cai".
9
+ */
10
+ const collator = new Intl.Collator("en-GB", { numeric: true, sensitivity: "base" });
11
+ /** Order two labels the way a reader expects. Nullish sorts as an empty string. */
12
+ export function compareValues(a, b) {
13
+ const left = normalise(a);
14
+ const right = normalise(b);
15
+ if (typeof left === "number" && typeof right === "number") {
16
+ return left === right ? 0 : left < right ? -1 : 1;
17
+ }
18
+ return collator.compare(String(left), String(right));
19
+ }
20
+ /** Compare two plain labels — for the value lists inside a column's filter. */
21
+ export function compareLabels(a, b) {
22
+ return collator.compare(a, b);
23
+ }
24
+ function normalise(value) {
25
+ if (value === null || value === undefined)
26
+ return "";
27
+ if (value instanceof Date)
28
+ return value.getTime();
29
+ return value;
30
+ }
31
+ //# sourceMappingURL=compare.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compare.js","sourceRoot":"","sources":["../../src/grid/compare.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,MAAM,QAAQ,GAAG,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC,CAAC;AAEpF,mFAAmF;AACnF,MAAM,UAAU,aAAa,CAAC,CAAY,EAAE,CAAY;IACtD,MAAM,IAAI,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC1B,MAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC3B,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC1D,OAAO,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;AACvD,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,aAAa,CAAC,CAAS,EAAE,CAAS;IAChD,OAAO,QAAQ,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,SAAS,CAAC,KAAgB;IACjC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IACrD,IAAI,KAAK,YAAY,IAAI;QAAE,OAAO,KAAK,CAAC,OAAO,EAAE,CAAC;IAClD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,8 @@
1
+ export interface HiddenColumns {
2
+ hiddenColumns: string[];
3
+ setHidden: (next: string[]) => void;
4
+ toggleColumn: (key: string) => void;
5
+ showAllColumns: () => void;
6
+ }
7
+ export declare function useHiddenColumns(grid: string | undefined): HiddenColumns;
8
+ //# sourceMappingURL=hidden-columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hidden-columns.d.ts","sourceRoot":"","sources":["../../src/grid/hidden-columns.ts"],"names":[],"mappings":"AAoDA,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACpC,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,cAAc,EAAE,MAAM,IAAI,CAAC;CAC5B;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,CAmCxE"}
@@ -0,0 +1,81 @@
1
+ "use client";
2
+ import { useState, useSyncExternalStore } from "react";
3
+ /**
4
+ * Which columns the reader has switched off, remembered per grid.
5
+ *
6
+ * Kept separate from the engine so a grid driven by the server — paging and
7
+ * filtering in the database rather than in the browser — keeps the same
8
+ * behaviour and the same stored choice. The store belongs to the browser, not
9
+ * to whichever hook happens to be driving the table.
10
+ */
11
+ /** Shared empty array: `useSyncExternalStore` compares snapshots by identity. */
12
+ const NONE = [];
13
+ const storeKey = (grid) => `cx-grid:${grid}:hidden`;
14
+ // Parsing yields a fresh array each call, which would look like a new snapshot
15
+ // on every render, so the parse is cached against the text it came from.
16
+ const cache = new Map();
17
+ function read(grid) {
18
+ if (!grid)
19
+ return NONE;
20
+ let raw = null;
21
+ try {
22
+ raw = window.localStorage.getItem(storeKey(grid));
23
+ }
24
+ catch {
25
+ // A blocked store is not worth breaking the grid over.
26
+ }
27
+ const cached = cache.get(grid);
28
+ if (cached && cached.raw === raw)
29
+ return cached.value;
30
+ let value = NONE;
31
+ try {
32
+ if (raw) {
33
+ const parsed = JSON.parse(raw);
34
+ if (Array.isArray(parsed))
35
+ value = parsed.filter((key) => typeof key === "string");
36
+ }
37
+ }
38
+ catch {
39
+ // A corrupt entry reads as "nothing hidden".
40
+ }
41
+ cache.set(grid, { raw, value });
42
+ return value;
43
+ }
44
+ /** Another tab changing the saved columns should move this one too. */
45
+ function subscribe(onChange) {
46
+ window.addEventListener("storage", onChange);
47
+ return () => window.removeEventListener("storage", onChange);
48
+ }
49
+ export function useHiddenColumns(grid) {
50
+ /**
51
+ * Read through `useSyncExternalStore` rather than copied into state by an
52
+ * effect — an effect meant one render with every column showing and a second
53
+ * with the reader's choice. `chosen` wins once they pick, which is also what
54
+ * keeps the control working where the store is blocked.
55
+ */
56
+ const stored = useSyncExternalStore(subscribe, () => read(grid), () => NONE);
57
+ const [chosen, setChosen] = useState(null);
58
+ const hiddenColumns = chosen ?? stored;
59
+ function setHidden(next) {
60
+ setChosen(next);
61
+ if (!grid)
62
+ return;
63
+ try {
64
+ window.localStorage.setItem(storeKey(grid), JSON.stringify(next));
65
+ }
66
+ catch {
67
+ // Ignore — the choice just will not survive a reload.
68
+ }
69
+ }
70
+ return {
71
+ hiddenColumns,
72
+ setHidden,
73
+ toggleColumn(key) {
74
+ setHidden(hiddenColumns.includes(key) ? hiddenColumns.filter((k) => k !== key) : [...hiddenColumns, key]);
75
+ },
76
+ showAllColumns() {
77
+ setHidden([]);
78
+ },
79
+ };
80
+ }
81
+ //# sourceMappingURL=hidden-columns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hidden-columns.js","sourceRoot":"","sources":["../../src/grid/hidden-columns.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@connextar/house/grid` — the arithmetic behind a list screen.
3
+ *
4
+ * Search, sorting, per-column filters, column visibility, saved views and
5
+ * paging, with no markup and no design system in them: React, and nothing else.
6
+ * Every app already has its own table primitives and its own look, so what it
7
+ * gets from here is the behaviour, not the appearance — the same bargain the
8
+ * error shell and the wizard offer.
9
+ *
10
+ * A host declares columns that extend `GridColumnSpec` with whatever its own
11
+ * table needs (`header`, `cell`, alignment, an inline editor) and hands them
12
+ * straight in; `useGrid` is generic over that type and returns the same objects
13
+ * in `visibleColumns`, so nothing is mapped on the way through.
14
+ */
15
+ export type { GridColumnSpec, GridFacet } from "./columns.js";
16
+ export { compareLabels, compareValues, type GridValue } from "./compare.js";
17
+ export { useHiddenColumns, type HiddenColumns } from "./hidden-columns.js";
18
+ export { DEFAULT_PAGE_SIZE, useGrid, type Grid, type GridController, type GridSort, type SortDirection, type UseGridOptions, } from "./use-grid.js";
19
+ export { EMPTY_VIEW, parseGridView, type GridViewState } from "./views.js";
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/grid/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9D,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAC5E,OAAO,EAAE,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC3E,OAAO,EACL,iBAAiB,EACjB,OAAO,EACP,KAAK,IAAI,EACT,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,cAAc,GACpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,5 @@
1
+ export { compareLabels, compareValues } from "./compare.js";
2
+ export { useHiddenColumns } from "./hidden-columns.js";
3
+ export { DEFAULT_PAGE_SIZE, useGrid, } from "./use-grid.js";
4
+ export { EMPTY_VIEW, parseGridView } from "./views.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/grid/index.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAkB,MAAM,cAAc,CAAC;AAC5E,OAAO,EAAE,gBAAgB,EAAsB,MAAM,qBAAqB,CAAC;AAC3E,OAAO,EACL,iBAAiB,EACjB,OAAO,GAMR,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,UAAU,EAAE,aAAa,EAAsB,MAAM,YAAY,CAAC"}
@@ -0,0 +1,86 @@
1
+ import type { GridColumnSpec, GridFacet } from "./columns.js";
2
+ import type { GridViewState } from "./views.js";
3
+ /**
4
+ * Search, sort, filter, hide columns and paginate one list — the behaviour
5
+ * every list screen in the portfolio should have and, before this, three apps
6
+ * had written three times.
7
+ *
8
+ * It is a hook over the host's own column type rather than a table library,
9
+ * because the thing that differs between those three apps is entirely the
10
+ * rendering: the cells hold arbitrary markup, each has its own table
11
+ * primitives, and adopting a library would mean rewriting every call site's
12
+ * column API to arrive at the same arithmetic. So the arithmetic is here and
13
+ * the markup stays where it belongs.
14
+ *
15
+ * Everything is client-side. These lists arrive already scoped to the signed-in
16
+ * account, so there is nothing to gain from another round trip and a great deal
17
+ * to gain from a filter that answers as it is typed. A list long enough to need
18
+ * server-side paging should drive `GridController` from its own hook instead —
19
+ * the toolbar contract below is what makes that swap invisible to the UI.
20
+ */
21
+ export type SortDirection = "asc" | "desc";
22
+ export interface GridSort {
23
+ key: string;
24
+ direction: SortDirection;
25
+ }
26
+ export declare const DEFAULT_PAGE_SIZE = 25;
27
+ /**
28
+ * What a toolbar needs from whichever hook is driving a table.
29
+ *
30
+ * `useGrid` satisfies it, and so can an app's own server-driven hook, which is
31
+ * what lets the search box, filter menus, column picker and saved views be
32
+ * written once and behave identically either way.
33
+ */
34
+ export interface GridController {
35
+ query: string;
36
+ setQuery: (next: string) => void;
37
+ sort: GridSort | null;
38
+ toggleSort: (key: string) => void;
39
+ facetOptions: GridFacet[];
40
+ facets: Record<string, string[]>;
41
+ toggleFacet: (key: string, value: string) => void;
42
+ clearFacet: (key: string) => void;
43
+ activeFacetCount: number;
44
+ isFiltered: boolean;
45
+ reset: () => void;
46
+ total: number;
47
+ hiddenColumns: string[];
48
+ toggleColumn: (key: string) => void;
49
+ showAllColumns: () => void;
50
+ viewState: GridViewState;
51
+ applyView: (view: GridViewState) => void;
52
+ }
53
+ export interface UseGridOptions<T, C extends GridColumnSpec<T>> {
54
+ rows: T[];
55
+ columns: C[];
56
+ /** How the list is ordered before anyone touches a header. */
57
+ initialSort?: GridSort;
58
+ /** A pre-filled search term — how a deep link lands on one row. */
59
+ initialQuery?: string;
60
+ pageSize?: number;
61
+ /** Extra text per row to match on, for values no column displays. */
62
+ searchExtra?: (row: T) => string | null | undefined;
63
+ /**
64
+ * Identifies this grid so column choices can be remembered and saved views
65
+ * can be filed against it. Without one the grid still works; it just forgets.
66
+ */
67
+ storageKey?: string;
68
+ }
69
+ export interface Grid<T, C extends GridColumnSpec<T>> extends GridController {
70
+ /** The rows to render: this page of the searched, filtered, sorted list. */
71
+ rows: T[];
72
+ /** Everything matching the search and filters, before paging. */
73
+ matched: T[];
74
+ /** Render these, not the full set — respects what the reader switched off. */
75
+ visibleColumns: C[];
76
+ page: number;
77
+ setPage: (page: number) => void;
78
+ pageCount: number;
79
+ pageSize: number;
80
+ setPageSize: (size: number) => void;
81
+ /** 1-based index of the first row on this page; 0 when there are none. */
82
+ from: number;
83
+ to: number;
84
+ }
85
+ export declare function useGrid<T, C extends GridColumnSpec<T> = GridColumnSpec<T>>({ rows, columns, initialSort, initialQuery, pageSize: initialPageSize, searchExtra, storageKey, }: UseGridOptions<T, C>): Grid<T, C>;
86
+ //# sourceMappingURL=use-grid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-grid.d.ts","sourceRoot":"","sources":["../../src/grid/use-grid.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,157 @@
1
+ "use client";
2
+ import { useMemo, useState } from "react";
3
+ import { compareLabels, compareValues } from "./compare.js";
4
+ import { useHiddenColumns } from "./hidden-columns.js";
5
+ export const DEFAULT_PAGE_SIZE = 25;
6
+ export function useGrid({ rows, columns, initialSort, initialQuery, pageSize: initialPageSize = DEFAULT_PAGE_SIZE, searchExtra, storageKey, }) {
7
+ const [query, setQuery] = useState(initialQuery ?? "");
8
+ const [sort, setSort] = useState(initialSort ?? null);
9
+ const [facets, setFacets] = useState({});
10
+ const [pageSize, setPageSizeState] = useState(initialPageSize);
11
+ const [requestedPage, setPage] = useState(1);
12
+ const { hiddenColumns, setHidden, toggleColumn, showAllColumns } = useHiddenColumns(storageKey);
13
+ /** The columns actually rendered, in declared order. */
14
+ const visibleColumns = useMemo(() => columns.filter((column) => !hiddenColumns.includes(column.key)), [columns, hiddenColumns]);
15
+ /** Columns that opted into a filter, with the distinct values they hold. */
16
+ const facetOptions = useMemo(() => columns
17
+ .filter((column) => column.filterValue)
18
+ .map((column) => ({
19
+ key: column.key,
20
+ label: column.label ?? column.key,
21
+ values: [
22
+ ...new Set(rows.map((row) => column.filterValue?.(row)).filter((v) => Boolean(v))),
23
+ ].sort(compareLabels),
24
+ }))
25
+ .filter((facet) => facet.values.length > 1), [columns, rows]);
26
+ const matched = useMemo(() => {
27
+ const term = query.trim().toLowerCase();
28
+ const filtered = rows.filter((row) => {
29
+ // Every active filter must match — narrowing, not widening.
30
+ for (const [key, kept] of Object.entries(facets)) {
31
+ if (kept.length === 0)
32
+ continue;
33
+ const column = columns.find((c) => c.key === key);
34
+ const value = column?.filterValue?.(row);
35
+ if (!value || !kept.includes(value))
36
+ return false;
37
+ }
38
+ if (!term)
39
+ return true;
40
+ const haystack = [...columns.filter((c) => !c.unsearchable).map((c) => valueOf(c, row)), searchExtra?.(row)]
41
+ .filter((value) => value !== null && value !== undefined)
42
+ .map((value) => (value instanceof Date ? value.toISOString() : String(value)))
43
+ .join(" ")
44
+ .toLowerCase();
45
+ return haystack.includes(term);
46
+ });
47
+ if (!sort)
48
+ return filtered;
49
+ const column = columns.find((c) => c.key === sort.key);
50
+ const sortValue = column?.sortValue;
51
+ if (!sortValue)
52
+ return filtered;
53
+ // Copy before sorting: the caller's array is not ours to reorder.
54
+ return [...filtered].sort((a, b) => {
55
+ const result = compareValues(sortValue(a), sortValue(b));
56
+ return sort.direction === "asc" ? result : -result;
57
+ });
58
+ }, [rows, columns, query, facets, searchExtra, sort]);
59
+ const total = matched.length;
60
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
61
+ // Never strand someone on a page that no longer exists after a filter.
62
+ // Clamped here rather than corrected from an effect, which rendered the
63
+ // empty page once before putting it right.
64
+ const page = Math.min(requestedPage, pageCount);
65
+ const pageRows = useMemo(() => matched.slice((page - 1) * pageSize, page * pageSize), [matched, page, pageSize]);
66
+ /** Cycle a column: ascending → descending → unsorted. */
67
+ function toggleSort(key) {
68
+ setSort((current) => {
69
+ if (current?.key !== key)
70
+ return { key, direction: "asc" };
71
+ if (current.direction === "asc")
72
+ return { key, direction: "desc" };
73
+ return null;
74
+ });
75
+ }
76
+ /** A new search starts at the beginning of the results, not on page 4. */
77
+ function search(next) {
78
+ setQuery(next);
79
+ setPage(1);
80
+ }
81
+ function toggleFacet(key, value) {
82
+ setPage(1);
83
+ setFacets((current) => {
84
+ const kept = current[key] ?? [];
85
+ const next = kept.includes(value) ? kept.filter((v) => v !== value) : [...kept, value];
86
+ if (next.length === 0)
87
+ return withoutKey(current, key);
88
+ return { ...current, [key]: next };
89
+ });
90
+ }
91
+ function clearFacet(key) {
92
+ setPage(1);
93
+ setFacets((current) => withoutKey(current, key));
94
+ }
95
+ function setPageSize(size) {
96
+ setPageSizeState(size);
97
+ setPage(1);
98
+ }
99
+ const activeFacetCount = Object.values(facets).reduce((sum, values) => sum + values.length, 0);
100
+ const isFiltered = query.trim().length > 0 || activeFacetCount > 0;
101
+ function reset() {
102
+ setQuery("");
103
+ setFacets({});
104
+ setPage(1);
105
+ }
106
+ /** Restore a saved view: search, sort, filters and columns together. */
107
+ function applyView(view) {
108
+ setQuery(view.query);
109
+ setSort(view.sort);
110
+ setFacets(view.facets);
111
+ setHidden(view.hiddenColumns);
112
+ if (view.pageSize)
113
+ setPageSizeState(view.pageSize);
114
+ setPage(1);
115
+ }
116
+ return {
117
+ rows: pageRows,
118
+ matched,
119
+ total,
120
+ query,
121
+ setQuery: search,
122
+ sort,
123
+ toggleSort,
124
+ facetOptions,
125
+ facets,
126
+ toggleFacet,
127
+ clearFacet,
128
+ activeFacetCount,
129
+ isFiltered,
130
+ reset,
131
+ visibleColumns,
132
+ hiddenColumns,
133
+ toggleColumn,
134
+ showAllColumns,
135
+ viewState: { query, sort, facets, hiddenColumns, pageSize },
136
+ applyView,
137
+ page,
138
+ setPage,
139
+ pageCount,
140
+ pageSize,
141
+ setPageSize,
142
+ from: total === 0 ? 0 : (page - 1) * pageSize + 1,
143
+ to: Math.min(page * pageSize, total),
144
+ };
145
+ }
146
+ /** The value a column contributes to the search haystack. */
147
+ function valueOf(column, row) {
148
+ if (column.sortValue)
149
+ return column.sortValue(row);
150
+ if (column.filterValue)
151
+ return column.filterValue(row);
152
+ return undefined;
153
+ }
154
+ function withoutKey(facets, key) {
155
+ return Object.fromEntries(Object.entries(facets).filter(([candidate]) => candidate !== key));
156
+ }
157
+ //# sourceMappingURL=use-grid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-grid.js","sourceRoot":"","sources":["../../src/grid/use-grid.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import type { GridSort } from "./use-grid.js";
2
+ /**
3
+ * Everything a saved view captures: how the list was searched, ordered,
4
+ * filtered and which columns were showing.
5
+ *
6
+ * This shape is written to a database or a browser store and read back later,
7
+ * possibly by a newer version of the app, so it is validated on the way in
8
+ * rather than trusted. A view saved before `pageSize` existed is still a valid
9
+ * view — every field is checked independently and a bad one is dropped, which
10
+ * is why this returns a repaired state rather than a boolean.
11
+ */
12
+ export interface GridViewState {
13
+ query: string;
14
+ sort: GridSort | null;
15
+ /** Column key → the values kept. An absent key means "all values". */
16
+ facets: Record<string, string[]>;
17
+ hiddenColumns: string[];
18
+ /** Rows per page, when the view was saved with one. */
19
+ pageSize?: number;
20
+ }
21
+ export declare const EMPTY_VIEW: GridViewState;
22
+ /**
23
+ * Read a stored view, keeping what is intelligible and discarding what is not.
24
+ *
25
+ * Returns `null` only when the input is not an object at all. A partly corrupt
26
+ * view is worth more than no view: losing one stale filter is a smaller failure
27
+ * than a menu entry that throws when picked.
28
+ */
29
+ export declare function parseGridView(input: unknown): GridViewState | null;
30
+ //# sourceMappingURL=views.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"views.d.ts","sourceRoot":"","sources":["../../src/grid/views.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,IAAI,CAAC;IACtB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACjC,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,UAAU,EAAE,aAAwE,CAAC;AAElG;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CAalE"}
@@ -0,0 +1,51 @@
1
+ export const EMPTY_VIEW = { query: "", sort: null, facets: {}, hiddenColumns: [] };
2
+ /**
3
+ * Read a stored view, keeping what is intelligible and discarding what is not.
4
+ *
5
+ * Returns `null` only when the input is not an object at all. A partly corrupt
6
+ * view is worth more than no view: losing one stale filter is a smaller failure
7
+ * than a menu entry that throws when picked.
8
+ */
9
+ export function parseGridView(input) {
10
+ if (typeof input !== "object" || input === null || Array.isArray(input))
11
+ return null;
12
+ const raw = input;
13
+ return {
14
+ query: typeof raw.query === "string" ? raw.query : "",
15
+ sort: parseSort(raw.sort),
16
+ facets: parseFacets(raw.facets),
17
+ hiddenColumns: parseStrings(raw.hiddenColumns),
18
+ ...(typeof raw.pageSize === "number" && Number.isInteger(raw.pageSize) && raw.pageSize > 0
19
+ ? { pageSize: raw.pageSize }
20
+ : {}),
21
+ };
22
+ }
23
+ function parseSort(input) {
24
+ if (typeof input !== "object" || input === null)
25
+ return null;
26
+ const raw = input;
27
+ if (typeof raw.key !== "string" || raw.key.length === 0)
28
+ return null;
29
+ if (raw.direction !== "asc" && raw.direction !== "desc")
30
+ return null;
31
+ return { key: raw.key, direction: raw.direction };
32
+ }
33
+ function parseFacets(input) {
34
+ if (typeof input !== "object" || input === null || Array.isArray(input))
35
+ return {};
36
+ const out = {};
37
+ for (const [key, value] of Object.entries(input)) {
38
+ const values = parseStrings(value);
39
+ // An empty list means "all values" — the same as the key being absent, so
40
+ // it is dropped rather than stored as a filter that filters nothing.
41
+ if (values.length > 0)
42
+ out[key] = values;
43
+ }
44
+ return out;
45
+ }
46
+ function parseStrings(input) {
47
+ if (!Array.isArray(input))
48
+ return [];
49
+ return input.filter((value) => typeof value === "string");
50
+ }
51
+ //# sourceMappingURL=views.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"views.js","sourceRoot":"","sources":["../../src/grid/views.ts"],"names":[],"mappings":"AAsBA,MAAM,CAAC,MAAM,UAAU,GAAkB,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC;AAElG;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrF,MAAM,GAAG,GAAG,KAAgC,CAAC;IAE7C,OAAO;QACL,KAAK,EAAE,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE;QACrD,IAAI,EAAE,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC;QACzB,MAAM,EAAE,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC;QAC/B,aAAa,EAAE,YAAY,CAAC,GAAG,CAAC,aAAa,CAAC;QAC9C,GAAG,CAAC,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,GAAG,CAAC,QAAQ,GAAG,CAAC;YACxF,CAAC,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE;YAC5B,CAAC,CAAC,EAAE,CAAC;KACR,CAAC;AACJ,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC7D,MAAM,GAAG,GAAG,KAAgC,CAAC;IAC7C,IAAI,OAAO,GAAG,CAAC,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrE,IAAI,GAAG,CAAC,SAAS,KAAK,KAAK,IAAI,GAAG,CAAC,SAAS,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IACrE,OAAO,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC;AACpD,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACnF,MAAM,GAAG,GAA6B,EAAE,CAAC;IACzC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAgC,CAAC,EAAE,CAAC;QAC5E,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;QACnC,0EAA0E;QAC1E,qEAAqE;QACrE,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;IAC3C,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,SAAS,YAAY,CAAC,KAAc;IAClC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACrC,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,KAAK,EAAmB,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC;AAC7E,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connextar/house",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "The pieces every app we build needs and none of them should own a copy of.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -50,6 +50,10 @@
50
50
  "types": "./dist/ux/navigation-index.d.ts",
51
51
  "default": "./dist/ux/navigation-index.js"
52
52
  },
53
+ "./grid": {
54
+ "types": "./dist/grid/index.d.ts",
55
+ "default": "./dist/grid/index.js"
56
+ },
53
57
  "./wizard": {
54
58
  "types": "./dist/wizard/index.d.ts",
55
59
  "default": "./dist/wizard/index.js"