@connextar/house 0.2.0 → 0.3.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 (39) hide show
  1. package/README.md +82 -1
  2. package/dist/house.css +66 -0
  3. package/dist/ux/errors.d.ts +26 -0
  4. package/dist/ux/errors.d.ts.map +1 -0
  5. package/dist/ux/errors.js +33 -0
  6. package/dist/ux/errors.js.map +1 -0
  7. package/dist/ux/feedback.d.ts +23 -0
  8. package/dist/ux/feedback.d.ts.map +1 -0
  9. package/dist/ux/feedback.js +22 -0
  10. package/dist/ux/feedback.js.map +1 -0
  11. package/dist/ux/index.d.ts +20 -0
  12. package/dist/ux/index.d.ts.map +1 -0
  13. package/dist/ux/index.js +20 -0
  14. package/dist/ux/index.js.map +1 -0
  15. package/dist/ux/navigation-index.d.ts +10 -0
  16. package/dist/ux/navigation-index.d.ts.map +1 -0
  17. package/dist/ux/navigation-index.js +10 -0
  18. package/dist/ux/navigation-index.js.map +1 -0
  19. package/dist/ux/navigation-progress.d.ts +53 -0
  20. package/dist/ux/navigation-progress.d.ts.map +1 -0
  21. package/dist/ux/navigation-progress.js +245 -0
  22. package/dist/ux/navigation-progress.js.map +1 -0
  23. package/dist/ux/optimistic.d.ts +139 -0
  24. package/dist/ux/optimistic.d.ts.map +1 -0
  25. package/dist/ux/optimistic.js +242 -0
  26. package/dist/ux/optimistic.js.map +1 -0
  27. package/dist/ux/use-action.d.ts +60 -0
  28. package/dist/ux/use-action.d.ts.map +1 -0
  29. package/dist/ux/use-action.js +100 -0
  30. package/dist/ux/use-action.js.map +1 -0
  31. package/dist/ux/use-optimistic-list.d.ts +69 -0
  32. package/dist/ux/use-optimistic-list.d.ts.map +1 -0
  33. package/dist/ux/use-optimistic-list.js +115 -0
  34. package/dist/ux/use-optimistic-list.js.map +1 -0
  35. package/dist/ux/use-optimistic-value.d.ts +40 -0
  36. package/dist/ux/use-optimistic-value.d.ts.map +1 -0
  37. package/dist/ux/use-optimistic-value.js +68 -0
  38. package/dist/ux/use-optimistic-value.js.map +1 -0
  39. package/package.json +10 -2
@@ -0,0 +1,60 @@
1
+ export interface ActionOptions<R> {
2
+ /**
3
+ * What to say when it works. A function gets the result, and may return
4
+ * nothing for "this one is not worth a toast".
5
+ */
6
+ success?: string | ((result: R) => string | null | undefined);
7
+ /** The heading when it fails; the thrown message becomes the detail. */
8
+ error?: string;
9
+ onSuccess?: (result: R) => void;
10
+ /**
11
+ * Runs *in addition* to the failure being reported — usually to put back
12
+ * whatever was changed ahead of the answer. Silencing the report is a
13
+ * separate decision, and a deliberate one: see `silent`.
14
+ */
15
+ onError?: (error: unknown) => void;
16
+ /**
17
+ * Say nothing on failure. Only for a control that shows the refusal itself,
18
+ * in place. A rolled-back change with no explanation is the failure mode
19
+ * this whole layer exists to avoid.
20
+ */
21
+ silent?: boolean;
22
+ }
23
+ export interface Action<A extends unknown[], R> {
24
+ /**
25
+ * Run it. Never throws: a failure is reported and comes back as `undefined`,
26
+ * because the caller is an `onClick` and an unhandled rejection there is an
27
+ * error in the console and nothing on the screen.
28
+ */
29
+ run: (...args: A) => Promise<R | undefined>;
30
+ /** In flight. Feed this straight to a button's `loading`. */
31
+ pending: boolean;
32
+ /** The last failure's wording, for showing it in place rather than in a toast. */
33
+ error: string | null;
34
+ }
35
+ /**
36
+ * One asynchronous thing a control does, with the pending flag that control
37
+ * needs and the reporting it would otherwise repeat.
38
+ *
39
+ * Re-entry is refused rather than queued. Two clicks on **Pay** are one
40
+ * intention, and the second is almost always the person telling you the first
41
+ * one gave them nothing to look at — which is the very problem the `pending`
42
+ * flag fixes.
43
+ */
44
+ export declare function useAction<A extends unknown[], R>(action: (...args: A) => Promise<R>, options?: ActionOptions<R>): Action<A, R>;
45
+ export interface KeyedAction {
46
+ /** Run something for one row; a second run for the same row is refused. */
47
+ run: <R>(key: string, action: () => Promise<R>, options?: ActionOptions<R>) => Promise<R | undefined>;
48
+ isPending: (key: string) => boolean;
49
+ /** Any row busy at all — for disabling a whole toolbar. */
50
+ pending: boolean;
51
+ /** The busy rows, where a component would rather read the set itself. */
52
+ keys: ReadonlySet<string>;
53
+ }
54
+ /**
55
+ * The same, for a list where each row has its own button. Keeps a set of busy
56
+ * keys so one row's spinner does not disable the rest of the table — the
57
+ * commonest small cruelty in a list screen.
58
+ */
59
+ export declare function useKeyedAction(defaults?: ActionOptions<unknown>): KeyedAction;
60
+ //# sourceMappingURL=use-action.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-action.d.ts","sourceRoot":"","sources":["../../src/ux/use-action.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IAC9D,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,MAAM,CAAC,CAAC,SAAS,OAAO,EAAE,EAAE,CAAC;IAC5C;;;;OAIG;IACH,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IAC5C,6DAA6D;IAC7D,OAAO,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,OAAO,EAAE,EAAE,CAAC,EAC9C,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAClC,OAAO,GAAE,aAAa,CAAC,CAAC,CAAM,GAC7B,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAyCd;AAED,MAAM,WAAW,WAAW;IAC1B,2EAA2E;IAC3E,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IACtG,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACpC,2DAA2D;IAC3D,OAAO,EAAE,OAAO,CAAC;IACjB,yEAAyE;IACzE,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,GAAE,aAAa,CAAC,OAAO,CAAM,GAAG,WAAW,CAyCjF"}
@@ -0,0 +1,100 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { errorMessage } from "./errors.js";
4
+ import { useActionReporter } from "./feedback.js";
5
+ /**
6
+ * One asynchronous thing a control does, with the pending flag that control
7
+ * needs and the reporting it would otherwise repeat.
8
+ *
9
+ * Re-entry is refused rather than queued. Two clicks on **Pay** are one
10
+ * intention, and the second is almost always the person telling you the first
11
+ * one gave them nothing to look at — which is the very problem the `pending`
12
+ * flag fixes.
13
+ */
14
+ export function useAction(action, options = {}) {
15
+ const report = useActionReporter();
16
+ const [pending, setPending] = React.useState(false);
17
+ const [error, setError] = React.useState(null);
18
+ // The "latest ref" pattern: written after the render, so the asynchronous
19
+ // half always reads current values without any of these callbacks having to
20
+ // change identity — a callback that changes on every render is how a
21
+ // memoised row ends up re-rendering for nothing.
22
+ const latest = React.useRef({ action, options, report });
23
+ React.useEffect(() => {
24
+ latest.current = { action, options, report };
25
+ });
26
+ const inFlight = React.useRef(false);
27
+ const run = React.useCallback(async (...args) => {
28
+ if (inFlight.current)
29
+ return undefined;
30
+ inFlight.current = true;
31
+ setPending(true);
32
+ setError(null);
33
+ const { action: fn, options: opts, report: tell } = latest.current;
34
+ try {
35
+ const result = await fn(...args);
36
+ const message = typeof opts.success === "function" ? opts.success(result) : opts.success;
37
+ if (message)
38
+ tell({ tone: "success", title: message });
39
+ opts.onSuccess?.(result);
40
+ return result;
41
+ }
42
+ catch (thrown) {
43
+ const detail = errorMessage(thrown);
44
+ setError(detail);
45
+ opts.onError?.(thrown);
46
+ if (!opts.silent)
47
+ tell({ tone: "error", title: opts.error ?? "That didn't work", description: detail });
48
+ return undefined;
49
+ }
50
+ finally {
51
+ inFlight.current = false;
52
+ setPending(false);
53
+ }
54
+ }, []);
55
+ return { run, pending, error };
56
+ }
57
+ /**
58
+ * The same, for a list where each row has its own button. Keeps a set of busy
59
+ * keys so one row's spinner does not disable the rest of the table — the
60
+ * commonest small cruelty in a list screen.
61
+ */
62
+ export function useKeyedAction(defaults = {}) {
63
+ const report = useActionReporter();
64
+ const [keys, setKeys] = React.useState(() => new Set());
65
+ const latest = React.useRef({ defaults, report });
66
+ React.useEffect(() => {
67
+ latest.current = { defaults, report };
68
+ });
69
+ const busy = React.useRef(new Set());
70
+ const run = React.useCallback(async (key, action, options) => {
71
+ if (busy.current.has(key))
72
+ return undefined;
73
+ busy.current.add(key);
74
+ setKeys(new Set(busy.current));
75
+ const { defaults: base, report: tell } = latest.current;
76
+ const opts = { ...base, ...options };
77
+ try {
78
+ const result = await action();
79
+ const message = typeof opts.success === "function" ? opts.success(result) : opts.success;
80
+ if (message)
81
+ tell({ tone: "success", title: message });
82
+ opts.onSuccess?.(result);
83
+ return result;
84
+ }
85
+ catch (thrown) {
86
+ opts.onError?.(thrown);
87
+ if (!opts.silent) {
88
+ tell({ tone: "error", title: opts.error ?? "That didn't work", description: errorMessage(thrown) });
89
+ }
90
+ return undefined;
91
+ }
92
+ finally {
93
+ busy.current.delete(key);
94
+ setKeys(new Set(busy.current));
95
+ }
96
+ }, []);
97
+ const isPending = React.useCallback((key) => keys.has(key), [keys]);
98
+ return { run, isPending, pending: keys.size > 0, keys };
99
+ }
100
+ //# sourceMappingURL=use-action.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-action.js","sourceRoot":"","sources":["../../src/ux/use-action.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ import { tempKey, type RowKey } from "./optimistic.js";
2
+ export interface OptimisticListOptions<T> {
3
+ /** How a row is identified. Must be stable for the life of the row. */
4
+ key: (row: T) => RowKey;
5
+ /** Heading for the toast when a write fails. */
6
+ error?: string;
7
+ /**
8
+ * Run after a write lands. This is where the caller refetches — the hook
9
+ * cannot know whether that is `router.refresh()`, a reload callback or
10
+ * nothing at all, and it deliberately keeps working if it is nothing.
11
+ */
12
+ onSettled?: () => void;
13
+ }
14
+ /** Per-call overrides. */
15
+ export interface WriteOptions {
16
+ /** What to say when it works. Nothing said by default: the row moving is the feedback. */
17
+ success?: string;
18
+ /** Heading when it fails. */
19
+ error?: string;
20
+ /** Skip the list-level `onSettled` for this one write. */
21
+ silentSettle?: boolean;
22
+ }
23
+ export interface OptimisticList<T> {
24
+ /** What to render: the server's rows with every un-dropped change laid over. */
25
+ items: T[];
26
+ /** This row has a write in flight — dim it, spin its button, leave the rest alone. */
27
+ isPending: (key: RowKey) => boolean;
28
+ /** Anything at all in flight. */
29
+ pending: boolean;
30
+ /** Show a new row now; the commit's returned row replaces the stand-in. */
31
+ insert: (row: T, commit: () => Promise<T | void>, options?: WriteOptions) => Promise<boolean>;
32
+ /** Show an edit now. The commit may return the row as the server has it. */
33
+ update: (key: RowKey, fields: Partial<T>, commit: () => Promise<T | void>, options?: WriteOptions) => Promise<boolean>;
34
+ /** Take a row off the list now. */
35
+ remove: (key: RowKey, commit: () => Promise<unknown>, options?: WriteOptions) => Promise<boolean>;
36
+ /** Put a row before another one (`null` = last) now. */
37
+ move: (key: RowKey, before: RowKey | null, commit: () => Promise<unknown>, options?: WriteOptions) => Promise<boolean>;
38
+ /**
39
+ * Several rows under one write — "mark all read", "archive the selected".
40
+ * They settle and roll back together, because the request did.
41
+ */
42
+ bulk: (changes: readonly {
43
+ key: RowKey;
44
+ fields: Partial<T>;
45
+ }[], commit: () => Promise<unknown>, options?: WriteOptions) => Promise<boolean>;
46
+ }
47
+ /**
48
+ * A list the screen can change before the server has agreed.
49
+ *
50
+ * `base` is whatever the server last said — a server component's prop, or
51
+ * state filled by a fetch. Never write to it: the hook keeps its changes
52
+ * beside it and folds them in on render, which is what lets fresh rows take
53
+ * over cleanly whenever they turn up. See `optimistic.ts` for the rules.
54
+ *
55
+ * ```tsx
56
+ * const list = useOptimisticList(tasks, { key: (t) => t.id, onSettled: router.refresh });
57
+ *
58
+ * <Checkbox
59
+ * checked={task.done}
60
+ * disabled={list.isPending(task.id)}
61
+ * onCheckedChange={(done) =>
62
+ * list.update(task.id, { done }, () => commitApi(`/api/tasks/${task.id}`, { method: "PATCH", … }))
63
+ * }
64
+ * />
65
+ * ```
66
+ */
67
+ export declare function useOptimisticList<T>(base: readonly T[], options: OptimisticListOptions<T>): OptimisticList<T>;
68
+ export { tempKey };
69
+ //# sourceMappingURL=use-optimistic-list.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-optimistic-list.d.ts","sourceRoot":"","sources":["../../src/ux/use-optimistic-list.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,OAAO,EAEP,KAAK,MAAM,EACZ,MAAM,iBAAiB,CAAC;AAIzB,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACtC,uEAAuE;IACvE,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC;IACxB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAED,0BAA0B;AAC1B,MAAM,WAAW,YAAY;IAC3B,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,gFAAgF;IAChF,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,sFAAsF;IACtF,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACpC,iCAAiC;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,OAAO,CAAC,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9F,4EAA4E;IAC5E,MAAM,EAAE,CACN,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,EAClB,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAC/B,OAAO,CAAC,EAAE,YAAY,KACnB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB,mCAAmC;IACnC,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,YAAY,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAClG,wDAAwD;IACxD,IAAI,EAAE,CACJ,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,YAAY,KACnB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB;;;OAGG;IACH,IAAI,EAAE,CACJ,OAAO,EAAE,SAAS;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;KAAE,EAAE,EACvD,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,YAAY,KACnB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CA8H7G;AAED,OAAO,EAAE,OAAO,EAAE,CAAC"}
@@ -0,0 +1,115 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { errorMessage } from "./errors.js";
4
+ import { useActionReporter } from "./feedback.js";
5
+ import { applyPatches, nextPatchId, reconcile, settlePatch, supersede, tempKey, } from "./optimistic.js";
6
+ const NO_PATCHES = [];
7
+ /**
8
+ * A list the screen can change before the server has agreed.
9
+ *
10
+ * `base` is whatever the server last said — a server component's prop, or
11
+ * state filled by a fetch. Never write to it: the hook keeps its changes
12
+ * beside it and folds them in on render, which is what lets fresh rows take
13
+ * over cleanly whenever they turn up. See `optimistic.ts` for the rules.
14
+ *
15
+ * ```tsx
16
+ * const list = useOptimisticList(tasks, { key: (t) => t.id, onSettled: router.refresh });
17
+ *
18
+ * <Checkbox
19
+ * checked={task.done}
20
+ * disabled={list.isPending(task.id)}
21
+ * onCheckedChange={(done) =>
22
+ * list.update(task.id, { done }, () => commitApi(`/api/tasks/${task.id}`, { method: "PATCH", … }))
23
+ * }
24
+ * />
25
+ * ```
26
+ */
27
+ export function useOptimisticList(base, options) {
28
+ const report = useActionReporter();
29
+ const [patches, setPatches] = React.useState(NO_PATCHES);
30
+ // Everything the asynchronous half needs, read at the time it runs.
31
+ const latest = React.useRef({ base, options, report });
32
+ React.useEffect(() => {
33
+ latest.current = { base, options, report };
34
+ });
35
+ // What is still worth applying, worked out fresh on every render rather than
36
+ // stored. A settled patch knows what the rows said when it landed, so
37
+ // `reconcile` can tell "nothing has refetched yet" from "new rows are here"
38
+ // without this hook having to watch the array — and it must not watch it:
39
+ // `useOptimisticList(rows.filter(…), …)` hands it a different array every
40
+ // render, and any state keyed on that identity renders forever.
41
+ const live = reconcile(patches, base, options.key);
42
+ const write = React.useCallback(async (batch, commit, per) => {
43
+ // Queued at the end, where it wins on render, and *without* clearing
44
+ // what is already held for these rows. An earlier change that succeeded
45
+ // is still true: rolling this one back must not take it down too.
46
+ //
47
+ // Reconciling here as well is the only pruning the stored queue gets.
48
+ // Render works out what to apply without touching state — it has to, or
49
+ // a caller passing `rows.filter(…)` would render forever — so this is
50
+ // where changes the rows have caught up with are actually let go of.
51
+ setPatches((previous) => [...reconcile(previous, latest.current.base, latest.current.options.key), ...batch]);
52
+ const ids = new Set(batch.map((patch) => patch.id));
53
+ const { options: opts, report: tell } = latest.current;
54
+ try {
55
+ const result = (await commit());
56
+ // A single write may be answered with the row as the server has it; a
57
+ // batch has no one row to be answered with.
58
+ const answer = batch.length === 1 ? (result ?? undefined) : undefined;
59
+ setPatches((previous) => {
60
+ const { base: rows, options: current } = latest.current;
61
+ const landed = previous
62
+ .filter((held) => ids.has(held.id))
63
+ .map((held) => settlePatch(held, answer, rows, current.key));
64
+ // Now that this one has landed it supersedes what was already held
65
+ // for the same rows. Writes still in flight are left: each has a
66
+ // request of its own that can still come back refused.
67
+ const keys = new Set(landed.map((held) => held.key));
68
+ const kept = supersede(previous.filter((held) => !ids.has(held.id)), keys);
69
+ return [...kept, ...landed];
70
+ });
71
+ if (per?.success)
72
+ tell({ tone: "success", title: per.success });
73
+ if (!per?.silentSettle)
74
+ opts.onSettled?.();
75
+ return true;
76
+ }
77
+ catch (thrown) {
78
+ // Roll back: the rows go back to whatever the server last said, which
79
+ // `base` still holds untouched.
80
+ setPatches((previous) => previous.filter((held) => !ids.has(held.id)));
81
+ tell({
82
+ tone: "error",
83
+ title: per?.error ?? opts.error ?? "That didn't save",
84
+ description: errorMessage(thrown),
85
+ });
86
+ return false;
87
+ }
88
+ }, []);
89
+ const insert = React.useCallback((row, commit, per) => {
90
+ const key = latest.current.options.key(row);
91
+ return write([{ id: nextPatchId(), kind: "insert", key, row, at: "end", settled: false }], commit, per);
92
+ }, [write]);
93
+ const update = React.useCallback((key, fields, commit, per) => write([{ id: nextPatchId(), kind: "update", key, fields, settled: false }], commit, per), [write]);
94
+ const remove = React.useCallback((key, commit, per) => write([{ id: nextPatchId(), kind: "remove", key, settled: false }], commit, per), [write]);
95
+ const move = React.useCallback((key, before, commit, per) => write([{ id: nextPatchId(), kind: "move", key, before, settled: false }], commit, per), [write]);
96
+ const bulk = React.useCallback((changes, commit, per) => write(changes.map(({ key, fields }) => ({
97
+ id: nextPatchId(),
98
+ kind: "update",
99
+ key,
100
+ fields,
101
+ settled: false,
102
+ })), commit, per), [write]);
103
+ const items = applyPatches(base, live, options.key);
104
+ const busy = React.useMemo(() => {
105
+ const keys = new Set();
106
+ for (const patch of live)
107
+ if (!patch.settled)
108
+ keys.add(patch.key);
109
+ return keys;
110
+ }, [live]);
111
+ const isPending = React.useCallback((key) => busy.has(key), [busy]);
112
+ return { items, isPending, pending: busy.size > 0, insert, update, remove, move, bulk };
113
+ }
114
+ export { tempKey };
115
+ //# sourceMappingURL=use-optimistic-list.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-optimistic-list.js","sourceRoot":"","sources":["../../src/ux/use-optimistic-list.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ export interface OptimisticValueOptions {
2
+ /** Heading for the toast when the write fails. */
3
+ error?: string;
4
+ /** Run after the write lands — usually `router.refresh()`. */
5
+ onSettled?: () => void;
6
+ }
7
+ export interface OptimisticValue<T> {
8
+ /** What to render: the change if one is held, otherwise the server's value. */
9
+ value: T;
10
+ /** A write is in flight. */
11
+ pending: boolean;
12
+ /**
13
+ * Show `next` now, then commit it. The commit may resolve with the value as
14
+ * the server has it — and must resolve with **that value's own shape** or
15
+ * nothing at all. A write route often answers with some other record
16
+ * entirely, which would replace the value with something that is not one:
17
+ * `async () => { await save(); }` is the right wrapper in that case.
18
+ */
19
+ set: (next: T, commit: () => Promise<T | void>, options?: {
20
+ success?: string;
21
+ error?: string;
22
+ }) => Promise<boolean>;
23
+ /** The same for an object value: lay these fields over it. */
24
+ merge: (fields: Partial<T>, commit: () => Promise<T | void>, options?: {
25
+ success?: string;
26
+ error?: string;
27
+ }) => Promise<boolean>;
28
+ }
29
+ /**
30
+ * One value the screen can change before the server has agreed: a switch, a
31
+ * status, a counter, a settings record.
32
+ *
33
+ * Same rules as `useOptimisticList` and for the same reasons — the change is
34
+ * held beside the server's value rather than replacing it, so a failure rolls
35
+ * back to something real and a refresh always wins. A switch is the case where
36
+ * getting this wrong is most obvious: flipping back a second after it was
37
+ * flipped, with no explanation, is worse than not moving at all.
38
+ */
39
+ export declare function useOptimisticValue<T>(base: T, options?: OptimisticValueOptions): OptimisticValue<T>;
40
+ //# sourceMappingURL=use-optimistic-value.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-optimistic-value.d.ts","sourceRoot":"","sources":["../../src/ux/use-optimistic-value.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,sBAAsB;IACrC,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,+EAA+E;IAC/E,KAAK,EAAE,CAAC,CAAC;IACT,4BAA4B;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;OAMG;IACH,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACpH,8DAA8D;IAC9D,KAAK,EAAE,CACL,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,EAClB,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,EAC/B,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,KAC3C,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,GAAE,sBAA2B,GAAG,eAAe,CAAC,CAAC,CAAC,CA8DvG"}
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { errorMessage } from "./errors.js";
4
+ import { useActionReporter } from "./feedback.js";
5
+ import { sameRow } from "./optimistic.js";
6
+ /**
7
+ * One value the screen can change before the server has agreed: a switch, a
8
+ * status, a counter, a settings record.
9
+ *
10
+ * Same rules as `useOptimisticList` and for the same reasons — the change is
11
+ * held beside the server's value rather than replacing it, so a failure rolls
12
+ * back to something real and a refresh always wins. A switch is the case where
13
+ * getting this wrong is most obvious: flipping back a second after it was
14
+ * flipped, with no explanation, is worse than not moving at all.
15
+ */
16
+ export function useOptimisticValue(base, options = {}) {
17
+ const report = useActionReporter();
18
+ // `witness` is what the server's value was when the write landed. A settled
19
+ // hold stands until that changes, which is how "nothing has refreshed yet"
20
+ // is told apart from "the server now says something else". Worked out on
21
+ // each render rather than stored, so an object rebuilt by the parent on
22
+ // every render cannot start a render loop.
23
+ const [held, setHeld] = React.useState(null);
24
+ const latest = React.useRef({ options, report });
25
+ React.useEffect(() => {
26
+ latest.current = { options, report };
27
+ });
28
+ // Nothing is stored when this flips: the next `set` replaces the hold
29
+ // anyway, and clearing it from an effect would only add a render.
30
+ const overtaken = held !== null && held.settled && !sameRow(held.witness, base);
31
+ const value = held && !overtaken ? held.value : base;
32
+ const valueRef = React.useRef(value);
33
+ const baseRef = React.useRef(base);
34
+ React.useEffect(() => {
35
+ valueRef.current = value;
36
+ baseRef.current = base;
37
+ });
38
+ const set = React.useCallback(async (next, commit, per) => {
39
+ setHeld({ value: next, settled: false });
40
+ const { options: opts, report: tell } = latest.current;
41
+ try {
42
+ const result = (await commit());
43
+ // The server's own value where it sent one, so a clamped or normalised
44
+ // answer shows now rather than after the next refresh.
45
+ setHeld({
46
+ value: result === undefined || result === null ? next : result,
47
+ settled: true,
48
+ witness: baseRef.current,
49
+ });
50
+ if (per?.success)
51
+ tell({ tone: "success", title: per.success });
52
+ opts.onSettled?.();
53
+ return true;
54
+ }
55
+ catch (thrown) {
56
+ setHeld(null);
57
+ tell({
58
+ tone: "error",
59
+ title: per?.error ?? opts.error ?? "That didn't save",
60
+ description: errorMessage(thrown),
61
+ });
62
+ return false;
63
+ }
64
+ }, []);
65
+ const merge = React.useCallback((fields, commit, per) => set({ ...valueRef.current, ...fields }, commit, per), [set]);
66
+ return { value, pending: held !== null && !held.settled, set, merge };
67
+ }
68
+ //# sourceMappingURL=use-optimistic-value.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-optimistic-value.js","sourceRoot":"","sources":["../../src/ux/use-optimistic-value.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AA8B1C;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAAI,IAAO,EAAE,UAAkC,EAAE;IACjF,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAC;IACnC,4EAA4E;IAC5E,2EAA2E;IAC3E,yEAAyE;IACzE,wEAAwE;IACxE,2CAA2C;IAC3C,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAqD,IAAI,CAAC,CAAC;IAEjG,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;IACjD,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE;QACnB,MAAM,CAAC,OAAO,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,sEAAsE;IACtE,kEAAkE;IAClE,MAAM,SAAS,GAAG,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAChF,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAErD,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACrC,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACnC,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE;QACnB,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAC;QACzB,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IACzB,CAAC,CAAC,CAAC;IAEH,MAAM,GAAG,GAAG,KAAK,CAAC,WAAW,CAC3B,KAAK,EAAE,IAAO,EAAE,MAA+B,EAAE,GAA0C,EAAoB,EAAE;QAC/G,OAAO,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;QACzC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC;QACvD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,CAAC,MAAM,MAAM,EAAE,CAAkB,CAAC;YACjD,uEAAuE;YACvE,uDAAuD;YACvD,OAAO,CAAC;gBACN,KAAK,EAAE,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;gBAC9D,OAAO,EAAE,IAAI;gBACb,OAAO,EAAE,OAAO,CAAC,OAAO;aACzB,CAAC,CAAC;YACH,IAAI,GAAG,EAAE,OAAO;gBAAE,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YAChE,IAAI,CAAC,SAAS,EAAE,EAAE,CAAC;YACnB,OAAO,IAAI,CAAC;QACd,CAAC;QAAC,OAAO,MAAM,EAAE,CAAC;YAChB,OAAO,CAAC,IAAI,CAAC,CAAC;YACd,IAAI,CAAC;gBACH,IAAI,EAAE,OAAO;gBACb,KAAK,EAAE,GAAG,EAAE,KAAK,IAAI,IAAI,CAAC,KAAK,IAAI,kBAAkB;gBACrD,WAAW,EAAE,YAAY,CAAC,MAAM,CAAC;aAClC,CAAC,CAAC;YACH,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC,EACD,EAAE,CACH,CAAC;IAEF,MAAM,KAAK,GAAG,KAAK,CAAC,WAAW,CAC7B,CAAC,MAAkB,EAAE,MAA+B,EAAE,GAA0C,EAAE,EAAE,CAClG,GAAG,CAAC,EAAE,GAAI,QAAQ,CAAC,OAAkB,EAAE,GAAI,MAAiB,EAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EACnF,CAAC,GAAG,CAAC,CACN,CAAC;IAEF,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;AACxE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connextar/house",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {
@@ -42,6 +42,14 @@
42
42
  "types": "./dist/trail/index.d.ts",
43
43
  "default": "./dist/trail/index.js"
44
44
  },
45
+ "./ux": {
46
+ "types": "./dist/ux/index.d.ts",
47
+ "default": "./dist/ux/index.js"
48
+ },
49
+ "./ux/navigation": {
50
+ "types": "./dist/ux/navigation-index.d.ts",
51
+ "default": "./dist/ux/navigation-index.js"
52
+ },
45
53
  "./editor": {
46
54
  "types": "./dist/editor/index.d.ts",
47
55
  "default": "./dist/editor/index.js"
@@ -70,7 +78,7 @@
70
78
  "test": "vitest run",
71
79
  "test:watch": "vitest",
72
80
  "build": "rm -rf dist && tsc --project tsconfig.build.json && cp src/styles/house.css dist/house.css",
73
- "verify": "npm run check:ci && npm run test && npm run build",
81
+ "verify": "npm run check:ci && npm run build && npm run test",
74
82
  "prepublishOnly": "npm run verify",
75
83
  "check:ci": "npm run lint:check && npm run format:check && npm run typecheck"
76
84
  },