@venizia/ardor-react 0.4.8-0 → 0.4.8-10

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 (86) hide show
  1. package/README.md +8 -5
  2. package/dist/contexts/application.d.ts +4 -0
  3. package/dist/contexts/application.js +1 -0
  4. package/dist/hooks/element-target.d.ts +9 -0
  5. package/dist/hooks/element-target.js +7 -0
  6. package/dist/hooks/index.d.ts +6 -1
  7. package/dist/hooks/index.js +5 -1
  8. package/dist/hooks/redux/use-app-dispatch.d.ts +5 -1
  9. package/dist/hooks/redux/use-app-dispatch.js +5 -1
  10. package/dist/hooks/redux/use-app-selector.d.ts +1 -0
  11. package/dist/hooks/redux/use-app-selector.js +28 -2
  12. package/dist/hooks/resize-observation.d.ts +21 -0
  13. package/dist/hooks/resize-observation.js +83 -0
  14. package/dist/hooks/use-application-context.d.ts +2 -0
  15. package/dist/hooks/use-application-context.js +2 -0
  16. package/dist/hooks/use-artifact.d.ts +26 -0
  17. package/dist/hooks/use-artifact.js +24 -2
  18. package/dist/hooks/use-autosave.d.ts +8 -0
  19. package/dist/hooks/use-autosave.js +4 -0
  20. package/dist/hooks/use-before-unload.d.ts +3 -0
  21. package/dist/hooks/use-before-unload.js +1 -0
  22. package/dist/hooks/use-confirm.d.ts +7 -0
  23. package/dist/hooks/use-confirm.js +1 -0
  24. package/dist/hooks/use-copy-to-clipboard.d.ts +23 -1
  25. package/dist/hooks/use-copy-to-clipboard.js +39 -15
  26. package/dist/hooks/use-debounce.d.ts +6 -0
  27. package/dist/hooks/use-debounce.js +1 -0
  28. package/dist/hooks/use-document-visibility.d.ts +3 -0
  29. package/dist/hooks/use-document-visibility.js +10 -0
  30. package/dist/hooks/use-element-size.d.ts +16 -1
  31. package/dist/hooks/use-element-size.js +11 -20
  32. package/dist/hooks/use-injectable.d.ts +32 -1
  33. package/dist/hooks/use-injectable.js +17 -3
  34. package/dist/hooks/use-intersection.d.ts +45 -0
  35. package/dist/hooks/use-intersection.js +74 -0
  36. package/dist/hooks/use-media-query.d.ts +5 -0
  37. package/dist/hooks/use-media-query.js +14 -0
  38. package/dist/hooks/use-online-status.d.ts +3 -0
  39. package/dist/hooks/use-online-status.js +14 -0
  40. package/dist/hooks/use-resize-effect.d.ts +41 -0
  41. package/dist/hooks/use-resize-effect.js +44 -0
  42. package/dist/hooks/use-stored-state.d.ts +32 -2
  43. package/dist/hooks/use-stored-state.js +74 -11
  44. package/dist/hooks/use-window-dimensions.d.ts +1 -0
  45. package/dist/hooks/use-window-dimensions.js +1 -0
  46. package/package.json +16 -17
  47. package/dist/contexts/application.d.ts.map +0 -1
  48. package/dist/contexts/application.js.map +0 -1
  49. package/dist/contexts/index.d.ts.map +0 -1
  50. package/dist/contexts/index.js.map +0 -1
  51. package/dist/hooks/index.d.ts.map +0 -1
  52. package/dist/hooks/index.js.map +0 -1
  53. package/dist/hooks/redux/index.d.ts.map +0 -1
  54. package/dist/hooks/redux/index.js.map +0 -1
  55. package/dist/hooks/redux/use-app-dispatch.d.ts.map +0 -1
  56. package/dist/hooks/redux/use-app-dispatch.js.map +0 -1
  57. package/dist/hooks/redux/use-app-selector.d.ts.map +0 -1
  58. package/dist/hooks/redux/use-app-selector.js.map +0 -1
  59. package/dist/hooks/use-application-context.d.ts.map +0 -1
  60. package/dist/hooks/use-application-context.js.map +0 -1
  61. package/dist/hooks/use-artifact.d.ts.map +0 -1
  62. package/dist/hooks/use-artifact.js.map +0 -1
  63. package/dist/hooks/use-autosave.d.ts.map +0 -1
  64. package/dist/hooks/use-autosave.js.map +0 -1
  65. package/dist/hooks/use-before-unload.d.ts.map +0 -1
  66. package/dist/hooks/use-before-unload.js.map +0 -1
  67. package/dist/hooks/use-confirm.d.ts.map +0 -1
  68. package/dist/hooks/use-confirm.js.map +0 -1
  69. package/dist/hooks/use-copy-to-clipboard.d.ts.map +0 -1
  70. package/dist/hooks/use-copy-to-clipboard.js.map +0 -1
  71. package/dist/hooks/use-debounce.d.ts.map +0 -1
  72. package/dist/hooks/use-debounce.js.map +0 -1
  73. package/dist/hooks/use-element-size.d.ts.map +0 -1
  74. package/dist/hooks/use-element-size.js.map +0 -1
  75. package/dist/hooks/use-injectable.d.ts.map +0 -1
  76. package/dist/hooks/use-injectable.js.map +0 -1
  77. package/dist/hooks/use-sizer.d.ts +0 -10
  78. package/dist/hooks/use-sizer.d.ts.map +0 -1
  79. package/dist/hooks/use-sizer.js +0 -23
  80. package/dist/hooks/use-sizer.js.map +0 -1
  81. package/dist/hooks/use-stored-state.d.ts.map +0 -1
  82. package/dist/hooks/use-stored-state.js.map +0 -1
  83. package/dist/hooks/use-window-dimensions.d.ts.map +0 -1
  84. package/dist/hooks/use-window-dimensions.js.map +0 -1
  85. package/dist/index.d.ts.map +0 -1
  86. package/dist/index.js.map +0 -1
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ const subscribe = (listener) => {
3
+ document.addEventListener('visibilitychange', listener);
4
+ return () => document.removeEventListener('visibilitychange', listener);
5
+ };
6
+ /** `'visible'` or `'hidden'`, as the document says. `'visible'` in a server render. */
7
+ export const useDocumentVisibility = () => {
8
+ return React.useSyncExternalStore(subscribe, () => document.visibilityState, () => 'visible');
9
+ };
10
+ //# sourceMappingURL=use-document-visibility.js.map
@@ -1,19 +1,24 @@
1
1
  import React from 'react';
2
+ import { type TResizeBox } from './resize-observation.js';
3
+ /** Which element `useElementSize` measures, which box it reads, and how it rounds. */
2
4
  export interface IUseElementSizeOptions {
3
5
  /** The element to measure. Leave it out to measure the element the returned `ref` is given to. */
4
6
  ref?: React.RefObject<Element | null>;
5
7
  /** @default 'content-box' */
6
- box?: 'content-box' | 'border-box';
8
+ box?: TResizeBox;
7
9
  /** Round width and height to whole pixels, so a fractional size does not render again. @default false */
8
10
  round?: boolean;
9
11
  }
12
+ /** The size `useElementSize` read, in CSS pixels. */
10
13
  export interface IUseElementSizeReturn {
11
14
  width: number;
12
15
  height: number;
13
16
  /** `false` until the first size is read, so 0 by 0 can be told from a measured empty element. */
14
17
  measured: boolean;
15
18
  }
19
+ /** The size of `useElementSize` called without a `ref` option, with the ref to give an element. */
16
20
  export interface IUseElementSizeRefReturn extends IUseElementSizeReturn {
21
+ /** Give it to the element to measure. */
17
22
  ref: React.RefCallback<Element>;
18
23
  }
19
24
  /**
@@ -23,6 +28,16 @@ export interface IUseElementSizeRefReturn extends IUseElementSizeReturn {
23
28
  * is read when the effect runs, so it follows a new `ref`, `box` or `round`, not a `ref.current`
24
29
  * that is replaced later; pass a stable `ref`. A new element, `box` or `round` starts again from
25
30
  * 0 by 0 with `measured` false.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * import { useElementSize } from '@venizia/ardor';
35
+ *
36
+ * export function Panel() {
37
+ * const { ref, width, height, measured } = useElementSize();
38
+ * return <div ref={ref}>{measured ? `${width} x ${height}` : '...'}</div>;
39
+ * }
40
+ * ```
26
41
  */
27
42
  export declare function useElementSize(opts: IUseElementSizeOptions & {
28
43
  ref: React.RefObject<Element | null>;
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { ResizeObservation } from './resize-observation.js';
2
3
  const ZERO_SIZE = { width: 0, height: 0, measured: false };
3
4
  const readSize = (opts) => {
4
5
  const { entry, box, round } = opts;
@@ -16,36 +17,26 @@ export function useElementSize(opts = {}) {
16
17
  React.useEffect(() => {
17
18
  setSize((current) => (current.measured ? ZERO_SIZE : current));
18
19
  const target = ref ? ref.current : element;
19
- if (!target || typeof ResizeObserver === 'undefined') {
20
+ if (!target) {
20
21
  return () => { };
21
22
  }
22
- let frame;
23
- let latest;
24
- const observer = new ResizeObserver((entries) => {
25
- latest = entries[entries.length - 1];
26
- if (frame !== undefined) {
27
- return;
28
- }
29
- frame = requestAnimationFrame(() => {
30
- frame = undefined;
31
- if (!latest) {
23
+ const observation = ResizeObservation.observe({
24
+ box,
25
+ onFrame: (entries) => {
26
+ const entry = entries.at(-1);
27
+ if (!entry) {
32
28
  return;
33
29
  }
34
- const next = readSize({ entry: latest, box, round });
30
+ const next = readSize({ entry, box, round });
35
31
  setSize((current) => {
36
32
  return current.measured && current.width === next.width && current.height === next.height
37
33
  ? current
38
34
  : next;
39
35
  });
40
- });
36
+ },
41
37
  });
42
- observer.observe(target, { box });
43
- return () => {
44
- observer.disconnect();
45
- if (frame !== undefined) {
46
- cancelAnimationFrame(frame);
47
- }
48
- };
38
+ observation.sync([target]);
39
+ return observation.stop;
49
40
  }, [ref, element, box, round]);
50
41
  return ref ? size : { ...size, ref: setElement };
51
42
  }
@@ -1,14 +1,31 @@
1
1
  import { type Container, type TClass } from '@venizia/ignis-inversion';
2
2
  import type { CoreBindings, ValueOf } from '@venizia/ardor-kernel';
3
+ /** Declaration-merge string keys into this interface to make them valid `key` values of the injectable hooks. */
3
4
  export interface IUseInjectableKeysOverrides {
4
5
  }
6
+ /** The binding keys the kernel defines (the string values of `CoreBindings`). */
5
7
  export type TUseInjectableKeysDefault = Extract<ValueOf<typeof CoreBindings>, string>;
8
+ /** A key the injectable hooks accept: a kernel binding key or one added to `IUseInjectableKeysOverrides`. */
6
9
  export type TUseInjectableKeys = TUseInjectableKeysDefault | keyof IUseInjectableKeysOverrides;
10
+ /**
11
+ * Resolve by `key` or by `target` class, never both.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { CoreBindings, type TUseInjectableOptions } from '@venizia/ardor';
16
+ *
17
+ * const byKey: TUseInjectableOptions = { key: CoreBindings.DEFAULT_REST_DATA_PROVIDER };
18
+ * class Greeter {}
19
+ * const byTarget: TUseInjectableOptions = { target: Greeter };
20
+ * ```
21
+ */
7
22
  export type TUseInjectableOptions = {
23
+ /** The container to resolve from. @default The application's container (`ApplicationContext`) */
8
24
  container?: Container;
9
25
  key: TUseInjectableKeys;
10
26
  target?: never;
11
27
  } | {
28
+ /** The container to resolve from. @default The application's container (`ApplicationContext`) */
12
29
  container?: Container;
13
30
  key?: never;
14
31
  target: TClass<unknown>;
@@ -16,9 +33,23 @@ export type TUseInjectableOptions = {
16
33
  /**
17
34
  * The container an injectable hook resolves from: the one passed in, else the application's. Shared,
18
35
  * so a hook never checks one container and resolves from another.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * import { CoreBindings, useApplicationContainer } from '@venizia/ardor';
40
+ *
41
+ * export function BindingCheck() {
42
+ * const container = useApplicationContainer();
43
+ * return <span>{container.isBound({ key: CoreBindings.DEFAULT_REST_DATA_PROVIDER }) ? 'bound' : 'unbound'}</span>;
44
+ * }
45
+ * ```
19
46
  */
20
- export declare const useInjectableContainer: (opts?: {
47
+ export declare const useApplicationContainer: (opts?: {
21
48
  container?: Container;
22
49
  }) => Container;
50
+ /**
51
+ * Resolves an instance from the container by `key` or by `target` class. Throws when the key is not
52
+ * bound, when the class has no recorded binding key, or when neither `key` nor `target` is given.
53
+ */
23
54
  export declare const useInjectable: <TInstance>(opts: TUseInjectableOptions) => TInstance;
24
55
  //# sourceMappingURL=use-injectable.d.ts.map
@@ -4,19 +4,33 @@ import { ApplicationContext } from '../contexts/application.js';
4
4
  /**
5
5
  * The container an injectable hook resolves from: the one passed in, else the application's. Shared,
6
6
  * so a hook never checks one container and resolves from another.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { CoreBindings, useApplicationContainer } from '@venizia/ardor';
11
+ *
12
+ * export function BindingCheck() {
13
+ * const container = useApplicationContainer();
14
+ * return <span>{container.isBound({ key: CoreBindings.DEFAULT_REST_DATA_PROVIDER }) ? 'bound' : 'unbound'}</span>;
15
+ * }
16
+ * ```
7
17
  */
8
- export const useInjectableContainer = (opts) => {
18
+ export const useApplicationContainer = (opts) => {
9
19
  const applicationContext = React.useContext(ApplicationContext);
10
20
  const container = opts?.container ?? applicationContext.container;
11
21
  if (!container) {
12
22
  throw getError({
13
- message: '[useInjectable] Failed to determine injectable container!',
23
+ message: '[useApplicationContainer] Failed to determine the application container!',
14
24
  });
15
25
  }
16
26
  return container;
17
27
  };
28
+ /**
29
+ * Resolves an instance from the container by `key` or by `target` class. Throws when the key is not
30
+ * bound, when the class has no recorded binding key, or when neither `key` nor `target` is given.
31
+ */
18
32
  export const useInjectable = (opts) => {
19
- const container = useInjectableContainer({ container: opts?.container });
33
+ const container = useApplicationContainer({ container: opts?.container });
20
34
  const { key, target } = opts;
21
35
  if (key) {
22
36
  if (!container.isBound({ key })) {
@@ -0,0 +1,45 @@
1
+ import { type TElementTarget } from './element-target.js';
2
+ /** The element or elements `useIntersection` watches, against which root, and when it delivers an entry. */
3
+ export interface IUseIntersectionOptions {
4
+ /** Read after every render: an element, a ref to one, or `null`; or a list of them to watch several. */
5
+ target: TElementTarget | TElementTarget[];
6
+ /**
7
+ * The scrolling container the target is measured against. A ref is read after every render, and nothing is
8
+ * observed while its `current` is `null`. A literal `null`, or leaving it out, is the viewport.
9
+ */
10
+ root?: TElementTarget;
11
+ /** @default '0px' */
12
+ rootMargin?: string;
13
+ /** The ratio of the element in view at which an entry is delivered; a number or a list of them. @default 0 */
14
+ threshold?: number | number[];
15
+ }
16
+ type TEntry = IntersectionObserverEntry | undefined;
17
+ /**
18
+ * The latest `IntersectionObserverEntry` of the target, or `undefined` before the first one and where there is
19
+ * no `IntersectionObserver`. The state changes only when `isIntersecting` or the ratio changed.
20
+ *
21
+ * With a list as `target` it returns an array in target order: the latest entry of each target, `undefined` for
22
+ * one with no element or not reported yet. A new element, `root`, `rootMargin` or `threshold` starts again from
23
+ * `undefined` for every target; the browser reports each target again at once.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * import React from 'react';
28
+ * import { useIntersection } from '@venizia/ardor';
29
+ *
30
+ * export function Lazy() {
31
+ * const ref = React.useRef<HTMLDivElement>(null);
32
+ * const entry = useIntersection({ target: ref, threshold: 0.5 });
33
+ * return <div ref={ref}>{entry?.isIntersecting ? 'visible' : 'hidden'}</div>;
34
+ * }
35
+ * ```
36
+ */
37
+ export declare function useIntersection(opts: IUseIntersectionOptions & {
38
+ target: TElementTarget[];
39
+ }): TEntry[];
40
+ export declare function useIntersection(opts: IUseIntersectionOptions & {
41
+ target: TElementTarget;
42
+ }): TEntry;
43
+ export declare function useIntersection(opts: IUseIntersectionOptions): TEntry | TEntry[];
44
+ export {};
45
+ //# sourceMappingURL=use-intersection.d.ts.map
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+ import { ElementTargets } from './element-target.js';
3
+ const EMPTY_SCOPE = { elements: [], root: null, isRootPending: false };
4
+ const NO_ENTRIES = new Map();
5
+ const resolveScope = (opts) => {
6
+ const { target, root } = opts;
7
+ const targets = Array.isArray(target) ? target : [target];
8
+ const rootElement = root === undefined ? null : ElementTargets.resolve({ target: root });
9
+ return {
10
+ elements: targets.map((item) => ElementTargets.resolve({ target: item })),
11
+ root: rootElement,
12
+ isRootPending: rootElement === null && root !== undefined && root !== null,
13
+ };
14
+ };
15
+ const sameScope = (a, b) => {
16
+ return (a.root === b.root &&
17
+ a.isRootPending === b.isRootPending &&
18
+ a.elements.length === b.elements.length &&
19
+ a.elements.every((element, index) => element === b.elements[index]));
20
+ };
21
+ export function useIntersection(opts) {
22
+ const { target, root, rootMargin, threshold } = opts;
23
+ const isList = Array.isArray(target);
24
+ const count = isList ? target.length : 1;
25
+ // A number list is compared by value, so a list made anew in each render observes nothing again.
26
+ const thresholdKey = threshold === undefined ? undefined : JSON.stringify(threshold);
27
+ const [scope, setScope] = React.useState(EMPTY_SCOPE);
28
+ const [entries, setEntries] = React.useState(NO_ENTRIES);
29
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- the target and root are read after every render, and a state set to an equal scope renders nothing
30
+ React.useEffect(() => {
31
+ const next = resolveScope({ target, root });
32
+ setScope((current) => (sameScope(current, next) ? current : next));
33
+ });
34
+ React.useEffect(() => {
35
+ setEntries((current) => (current.size === 0 ? current : NO_ENTRIES));
36
+ const watched = new Set(scope.elements.filter((element) => element !== null));
37
+ if (watched.size === 0 || scope.isRootPending || typeof IntersectionObserver === 'undefined') {
38
+ return () => { };
39
+ }
40
+ const observer = new IntersectionObserver((delivered) => {
41
+ setEntries((current) => {
42
+ let next = current;
43
+ for (const entry of delivered) {
44
+ const known = next.get(entry.target);
45
+ if (known?.isIntersecting === entry.isIntersecting &&
46
+ known.intersectionRatio === entry.intersectionRatio) {
47
+ continue;
48
+ }
49
+ if (next === current) {
50
+ next = new Map(current);
51
+ }
52
+ next.set(entry.target, entry);
53
+ }
54
+ return next;
55
+ });
56
+ }, {
57
+ root: scope.root ?? undefined,
58
+ rootMargin,
59
+ threshold: thresholdKey === undefined ? undefined : JSON.parse(thresholdKey),
60
+ });
61
+ for (const element of watched) {
62
+ observer.observe(element);
63
+ }
64
+ return () => observer.disconnect();
65
+ }, [scope, rootMargin, thresholdKey]);
66
+ return React.useMemo(() => {
67
+ const read = Array.from({ length: count }, (_, index) => {
68
+ const element = scope.elements[index];
69
+ return element ? entries.get(element) : undefined;
70
+ });
71
+ return isList ? read : read[0];
72
+ }, [count, isList, scope, entries]);
73
+ }
74
+ //# sourceMappingURL=use-intersection.js.map
@@ -0,0 +1,5 @@
1
+ /** Whether the media query matches now, and again after each change. `false` in a server render. */
2
+ export declare const useMediaQuery: (opts: {
3
+ query: string;
4
+ }) => boolean;
5
+ //# sourceMappingURL=use-media-query.d.ts.map
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ /** Whether the media query matches now, and again after each change. `false` in a server render. */
3
+ export const useMediaQuery = (opts) => {
4
+ const { query } = opts;
5
+ const subscribe = React.useMemo(() => {
6
+ return (listener) => {
7
+ const list = window.matchMedia(query);
8
+ list.addEventListener('change', listener);
9
+ return () => list.removeEventListener('change', listener);
10
+ };
11
+ }, [query]);
12
+ return React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
13
+ };
14
+ //# sourceMappingURL=use-media-query.js.map
@@ -0,0 +1,3 @@
1
+ /** Whether the browser is online, kept by the `online` and `offline` events. `true` in a server render. */
2
+ export declare const useOnlineStatus: () => boolean;
3
+ //# sourceMappingURL=use-online-status.d.ts.map
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ const subscribe = (listener) => {
3
+ window.addEventListener('online', listener);
4
+ window.addEventListener('offline', listener);
5
+ return () => {
6
+ window.removeEventListener('online', listener);
7
+ window.removeEventListener('offline', listener);
8
+ };
9
+ };
10
+ /** Whether the browser is online, kept by the `online` and `offline` events. `true` in a server render. */
11
+ export const useOnlineStatus = () => {
12
+ return React.useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
13
+ };
14
+ //# sourceMappingURL=use-online-status.js.map
@@ -0,0 +1,41 @@
1
+ import { type TElementTarget } from './element-target.js';
2
+ import { type TResizeBox } from './resize-observation.js';
3
+ /** What `useResizeEffect` watches and the callback it calls. */
4
+ export interface IUseResizeEffectOptions {
5
+ /** Read after every render: an element, a ref to one, or `null`, which is skipped. */
6
+ targets: TElementTarget[];
7
+ /** Receives the latest entry of each element that resized since the last call. */
8
+ onResize: (entries: ResizeObserverEntry[]) => void;
9
+ /** @default 'content-box' */
10
+ box?: TResizeBox;
11
+ /** Also observe the direct children of each target, and again whenever they change. @default false */
12
+ children?: boolean;
13
+ /**
14
+ * Call `onResize` inside the `ResizeObserver` callback, after layout and before paint, instead of on
15
+ * the next animation frame, once per observer callback. What `onResize` draws or styles directly
16
+ * shows in the frame that resized; a state update renders later. Resizing an observed element from
17
+ * it can report "ResizeObserver loop completed with undelivered notifications".
18
+ * @default false
19
+ */
20
+ beforePaint?: boolean;
21
+ }
22
+ /**
23
+ * Calls `onResize` at most once per animation frame when any of the targets resizes, or inside the
24
+ * observer callback with `beforePaint`. A new `onResize` is used without observing again, and the
25
+ * targets are read after every render, so an element held in state, a ref whose element changed and
26
+ * a list that grows or shrinks are followed.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * import React from 'react';
31
+ * import { useResizeEffect } from '@venizia/ardor';
32
+ *
33
+ * export function Panel() {
34
+ * const ref = React.useRef<HTMLDivElement>(null);
35
+ * useResizeEffect({ targets: [ref], onResize: (entries) => console.log(entries.length) });
36
+ * return <div ref={ref} />;
37
+ * }
38
+ * ```
39
+ */
40
+ export declare const useResizeEffect: (opts: IUseResizeEffectOptions) => void;
41
+ //# sourceMappingURL=use-resize-effect.d.ts.map
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { ElementTargets } from './element-target.js';
3
+ import { ResizeObservation } from './resize-observation.js';
4
+ /**
5
+ * Calls `onResize` at most once per animation frame when any of the targets resizes, or inside the
6
+ * observer callback with `beforePaint`. A new `onResize` is used without observing again, and the
7
+ * targets are read after every render, so an element held in state, a ref whose element changed and
8
+ * a list that grows or shrinks are followed.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * import React from 'react';
13
+ * import { useResizeEffect } from '@venizia/ardor';
14
+ *
15
+ * export function Panel() {
16
+ * const ref = React.useRef<HTMLDivElement>(null);
17
+ * useResizeEffect({ targets: [ref], onResize: (entries) => console.log(entries.length) });
18
+ * return <div ref={ref} />;
19
+ * }
20
+ * ```
21
+ */
22
+ export const useResizeEffect = (opts) => {
23
+ const { targets, onResize, box = 'content-box', children = false, beforePaint = false } = opts;
24
+ const handleResize = React.useRef(onResize);
25
+ const observation = React.useRef(undefined);
26
+ React.useEffect(() => {
27
+ handleResize.current = onResize;
28
+ return () => { };
29
+ }, [onResize]);
30
+ React.useEffect(() => {
31
+ const created = ResizeObservation.observe({
32
+ box,
33
+ children,
34
+ beforePaint,
35
+ onFrame: (entries) => handleResize.current(entries),
36
+ });
37
+ observation.current = created;
38
+ return created.stop;
39
+ }, [box, children, beforePaint]);
40
+ React.useEffect(() => {
41
+ observation.current?.sync(targets.flatMap((target) => ElementTargets.resolve({ target }) ?? []));
42
+ });
43
+ };
44
+ //# sourceMappingURL=use-resize-effect.js.map
@@ -1,21 +1,51 @@
1
1
  import { type IStorageCodec, type TStorageArea } from '@venizia/ardor-kernel';
2
+ /** What `useStoredState` stores, where and how. */
2
3
  export interface IUseStoredStateOptions<TValue> {
3
- key: string;
4
+ /** The storage key. `null` turns the storage off: nothing is read, written or watched, `value` starts from `defaultValue` and `setValue` keeps it in memory only, as `useState` does. */
5
+ key: string | null;
6
+ /** The value while the key holds nothing. */
4
7
  defaultValue: TValue;
5
8
  /** @default 'local' */
6
9
  storage?: TStorageArea;
7
10
  /** @default JSON */
8
11
  codec?: IStorageCodec<TValue>;
12
+ /** Write storage once, this many milliseconds after the last `setValue`; `value` changes at once. Above 0, also pass a stable `codec`. @default 0 */
13
+ writeDelayMs?: number;
9
14
  }
15
+ /** The value `useStoredState` holds, and how to change or remove it. */
10
16
  export interface IUseStoredStateReturn<TValue> {
17
+ /** The stored value, or `defaultValue` when the key holds nothing. */
11
18
  value: TValue;
19
+ /** Stores a value, or the result of an updater called with the current one. */
12
20
  setValue: (next: TValue | ((current: TValue) => TValue)) => void;
21
+ /** Removes the key, so `value` is `defaultValue` again. */
13
22
  remove: () => void;
14
23
  }
24
+ /** The call signatures of `useStoredState`. */
25
+ export interface IUseStoredState {
26
+ /** With a codec, the value type is the codec's: a literal `defaultValue` is checked against it, not widened past it. */
27
+ <TValue>(opts: Omit<IUseStoredStateOptions<TValue>, 'defaultValue' | 'codec'> & {
28
+ defaultValue: NoInfer<TValue>;
29
+ codec: IStorageCodec<TValue>;
30
+ }): IUseStoredStateReturn<TValue>;
31
+ <TValue>(opts: IUseStoredStateOptions<TValue>): IUseStoredStateReturn<TValue>;
32
+ }
15
33
  /**
16
34
  * A value kept in `localStorage` or `sessionStorage`, in step with other hooks on the key, with code
17
35
  * that writes through `BrowserStorage`, and with other tabs. Pass a stable `codec` and `defaultValue`.
18
36
  * A write or a removal that the storage refuses is kept in this hook until the storage next changes.
37
+ * With `writeDelayMs` the value waits in the same place for the delay: a change of the storage from
38
+ * outside drops it, and an unmount, a new key or storage, a `pagehide` event or a hidden page writes it at once.
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * import { useStoredState } from '@venizia/ardor';
43
+ *
44
+ * export function ThemeToggle() {
45
+ * const { value, setValue } = useStoredState({ key: '@app/prefs/theme', defaultValue: 'light' });
46
+ * return <button onClick={() => setValue(value === 'light' ? 'dark' : 'light')}>{value}</button>;
47
+ * }
48
+ * ```
19
49
  */
20
- export declare const useStoredState: <TValue>(opts: IUseStoredStateOptions<TValue>) => IUseStoredStateReturn<TValue>;
50
+ export declare const useStoredState: IUseStoredState;
21
51
  //# sourceMappingURL=use-stored-state.d.ts.map
@@ -1,26 +1,73 @@
1
1
  import React from 'react';
2
- import { BrowserStorage } from '@venizia/ardor-kernel';
3
- /** Reads the stored text as it is, `null` when there is none. */
4
- const RAW_TEXT = {
5
- encode: (text) => text ?? '',
6
- decode: (text) => text,
7
- };
2
+ import { BrowserStorage, StorageCodecs, } from '@venizia/ardor-kernel';
3
+ const RAW_TEXT = StorageCodecs.text();
8
4
  const isUpdater = (next) => typeof next === 'function';
9
5
  /**
10
6
  * A value kept in `localStorage` or `sessionStorage`, in step with other hooks on the key, with code
11
7
  * that writes through `BrowserStorage`, and with other tabs. Pass a stable `codec` and `defaultValue`.
12
8
  * A write or a removal that the storage refuses is kept in this hook until the storage next changes.
9
+ * With `writeDelayMs` the value waits in the same place for the delay: a change of the storage from
10
+ * outside drops it, and an unmount, a new key or storage, a `pagehide` event or a hidden page writes it at once.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * import { useStoredState } from '@venizia/ardor';
15
+ *
16
+ * export function ThemeToggle() {
17
+ * const { value, setValue } = useStoredState({ key: '@app/prefs/theme', defaultValue: 'light' });
18
+ * return <button onClick={() => setValue(value === 'light' ? 'dark' : 'light')}>{value}</button>;
19
+ * }
20
+ * ```
13
21
  */
14
22
  export const useStoredState = (opts) => {
15
- const { key, defaultValue, storage, codec } = opts;
23
+ const { key, defaultValue, storage, codec, writeDelayMs = 0 } = opts;
16
24
  const decoded = React.useRef(undefined);
17
25
  const keptRef = React.useRef(undefined);
26
+ const timerRef = React.useRef(undefined);
18
27
  const [kept, setKept] = React.useState();
28
+ // Every change of the kept value ends a pending write; only a delayed setValue starts one.
19
29
  const keep = React.useCallback((next) => {
30
+ clearTimeout(timerRef.current);
31
+ timerRef.current = undefined;
20
32
  keptRef.current = next;
21
33
  setKept(next);
22
34
  }, []);
35
+ const write = React.useCallback((entry) => {
36
+ keep(BrowserStorage.set(entry) ? undefined : entry);
37
+ }, [keep]);
38
+ const isDelayed = writeDelayMs > 0;
39
+ const flush = React.useCallback(() => {
40
+ const pending = keptRef.current;
41
+ if (timerRef.current !== undefined && pending?.key != null) {
42
+ write({ ...pending, key: pending.key });
43
+ }
44
+ }, [write]);
45
+ // A new key or storage, an unmount, and a page that closes or hides write the pending value to the
46
+ // key it was set for: closing a tab does not unmount.
47
+ React.useEffect(() => {
48
+ const onHide = () => {
49
+ if (document.visibilityState === 'hidden') {
50
+ flush();
51
+ }
52
+ };
53
+ // The value kept in memory under a `null` key does not follow the hook to a real key, or back to a later `null`.
54
+ if (key !== null && keptRef.current?.key === null) {
55
+ keep(undefined);
56
+ }
57
+ if (isDelayed) {
58
+ window.addEventListener('pagehide', flush);
59
+ document.addEventListener('visibilitychange', onHide);
60
+ }
61
+ return () => {
62
+ window.removeEventListener('pagehide', flush);
63
+ document.removeEventListener('visibilitychange', onHide);
64
+ flush();
65
+ };
66
+ }, [key, storage, isDelayed, flush, keep]);
23
67
  const subscribe = React.useCallback((listener) => {
68
+ if (key === null) {
69
+ return () => { };
70
+ }
24
71
  return BrowserStorage.subscribe({
25
72
  key,
26
73
  storage,
@@ -31,6 +78,9 @@ export const useStoredState = (opts) => {
31
78
  });
32
79
  }, [key, storage, keep]);
33
80
  const getSnapshot = React.useCallback(() => {
81
+ if (key === null) {
82
+ return defaultValue;
83
+ }
34
84
  const text = BrowserStorage.get({ key, storage, fallback: null, codec: RAW_TEXT });
35
85
  if (text === null) {
36
86
  return defaultValue;
@@ -48,11 +98,24 @@ export const useStoredState = (opts) => {
48
98
  const setValue = React.useCallback((next) => {
49
99
  const hit = keptRef.current;
50
100
  const current = hit?.key === key && hit.storage === storage ? hit.value : getSnapshot();
51
- const resolved = isUpdater(next) ? next(current) : next;
52
- const isStored = BrowserStorage.set({ key, value: resolved, storage, codec });
53
- keep(isStored ? undefined : { key, storage, value: resolved });
54
- }, [key, storage, codec, getSnapshot, keep]);
101
+ const nextValue = isUpdater(next) ? next(current) : next;
102
+ if (key === null) {
103
+ keep({ key, storage, codec, value: nextValue });
104
+ return;
105
+ }
106
+ const entry = { key, storage, codec, value: nextValue };
107
+ if (!(writeDelayMs > 0)) {
108
+ write(entry);
109
+ return;
110
+ }
111
+ keep(entry);
112
+ timerRef.current = setTimeout(() => write(entry), writeDelayMs);
113
+ }, [key, storage, codec, writeDelayMs, getSnapshot, keep, write]);
55
114
  const remove = React.useCallback(() => {
115
+ if (key === null) {
116
+ keep(undefined);
117
+ return;
118
+ }
56
119
  const isRemoved = BrowserStorage.remove({ key, storage });
57
120
  keep(isRemoved ? undefined : { key, storage, value: defaultValue });
58
121
  }, [key, storage, defaultValue, keep]);
@@ -1,3 +1,4 @@
1
+ /** The window's `innerWidth` and `innerHeight`, updated on resize; 0 x 0 where there is no window. */
1
2
  export declare const useWindowDimensions: () => {
2
3
  width: number;
3
4
  height: number;
@@ -6,6 +6,7 @@ const readWindowDimensions = () => {
6
6
  ? { width: window.innerWidth, height: window.innerHeight }
7
7
  : { width: 0, height: 0 };
8
8
  };
9
+ /** The window's `innerWidth` and `innerHeight`, updated on resize; 0 x 0 where there is no window. */
9
10
  export const useWindowDimensions = () => {
10
11
  const [windowDimensions, setWindowDimensions] = React.useState(readWindowDimensions);
11
12
  React.useEffect(() => {