@venizia/ardor-react 0.4.8-6 → 0.4.8-8

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.
@@ -1,17 +1,26 @@
1
1
  import { type TElementTarget } from './element-target.js';
2
- /** The element `useIntersection` watches and when it delivers an entry. */
2
+ /** The element or elements `useIntersection` watches, against which root, and when it delivers an entry. */
3
3
  export interface IUseIntersectionOptions {
4
- /** Read after every render: an element, a ref to one, or `null`. */
5
- target: TElementTarget;
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;
6
11
  /** @default '0px' */
7
12
  rootMargin?: string;
8
- /** The ratio of the element in view at which an entry is delivered; a single number, not an array. @default 0 */
9
- threshold?: number;
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[];
10
15
  }
16
+ type TEntry = IntersectionObserverEntry | undefined;
11
17
  /**
12
- * The latest `IntersectionObserverEntry` of the target, or `undefined` before the first one and
13
- * where there is no `IntersectionObserver`. The state changes only when `isIntersecting` or the
14
- * ratio changed. A new element, `rootMargin` or `threshold` starts again from `undefined`.
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.
15
24
  *
16
25
  * @example
17
26
  * ```tsx
@@ -25,5 +34,12 @@ export interface IUseIntersectionOptions {
25
34
  * }
26
35
  * ```
27
36
  */
28
- export declare const useIntersection: (opts: IUseIntersectionOptions) => IntersectionObserverEntry | undefined;
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 {};
29
45
  //# sourceMappingURL=use-intersection.d.ts.map
@@ -1,50 +1,74 @@
1
1
  import React from 'react';
2
2
  import { ElementTargets } from './element-target.js';
3
- /**
4
- * The latest `IntersectionObserverEntry` of the target, or `undefined` before the first one and
5
- * where there is no `IntersectionObserver`. The state changes only when `isIntersecting` or the
6
- * ratio changed. A new element, `rootMargin` or `threshold` starts again from `undefined`.
7
- *
8
- * @example
9
- * ```tsx
10
- * import React from 'react';
11
- * import { useIntersection } from '@venizia/ardor';
12
- *
13
- * export function Lazy() {
14
- * const ref = React.useRef<HTMLDivElement>(null);
15
- * const entry = useIntersection({ target: ref, threshold: 0.5 });
16
- * return <div ref={ref}>{entry?.isIntersecting ? 'visible' : 'hidden'}</div>;
17
- * }
18
- * ```
19
- */
20
- export const useIntersection = (opts) => {
21
- const { target, rootMargin, threshold } = opts;
22
- const [element, setElement] = React.useState(null);
23
- const [entry, setEntry] = React.useState();
24
- // oxlint-disable-next-line react-hooks/exhaustive-deps -- the target is read after every render, and a state set to the same element renders nothing
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
25
30
  React.useEffect(() => {
26
- setElement(ElementTargets.resolve({ target }));
31
+ const next = resolveScope({ target, root });
32
+ setScope((current) => (sameScope(current, next) ? current : next));
27
33
  });
28
34
  React.useEffect(() => {
29
- setEntry(undefined);
30
- if (!element || typeof IntersectionObserver === 'undefined') {
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') {
31
38
  return () => { };
32
39
  }
33
- const observer = new IntersectionObserver((entries) => {
34
- const latest = entries.at(-1);
35
- if (!latest) {
36
- return;
37
- }
38
- setEntry((current) => {
39
- return current?.isIntersecting === latest.isIntersecting &&
40
- current.intersectionRatio === latest.intersectionRatio
41
- ? current
42
- : latest;
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;
43
55
  });
44
- }, { rootMargin, threshold });
45
- observer.observe(element);
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
+ }
46
64
  return () => observer.disconnect();
47
- }, [element, rootMargin, threshold]);
48
- return entry;
49
- };
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
+ }
50
74
  //# sourceMappingURL=use-intersection.js.map
@@ -1,8 +1,8 @@
1
1
  import { type IStorageCodec, type TStorageArea } from '@venizia/ardor-kernel';
2
2
  /** What `useStoredState` stores, where and how. */
3
3
  export interface IUseStoredStateOptions<TValue> {
4
- /** The storage key. */
5
- 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
6
  /** The value while the key holds nothing. */
7
7
  defaultValue: TValue;
8
8
  /** @default 'local' */
@@ -37,8 +37,9 @@ export const useStoredState = (opts) => {
37
37
  }, [keep]);
38
38
  const isDelayed = writeDelayMs > 0;
39
39
  const flush = React.useCallback(() => {
40
- if (timerRef.current !== undefined && keptRef.current) {
41
- write(keptRef.current);
40
+ const pending = keptRef.current;
41
+ if (timerRef.current !== undefined && pending?.key != null) {
42
+ write({ ...pending, key: pending.key });
42
43
  }
43
44
  }, [write]);
44
45
  // A new key or storage, an unmount, and a page that closes or hides write the pending value to the
@@ -49,6 +50,10 @@ export const useStoredState = (opts) => {
49
50
  flush();
50
51
  }
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
+ }
52
57
  if (isDelayed) {
53
58
  window.addEventListener('pagehide', flush);
54
59
  document.addEventListener('visibilitychange', onHide);
@@ -58,8 +63,11 @@ export const useStoredState = (opts) => {
58
63
  document.removeEventListener('visibilitychange', onHide);
59
64
  flush();
60
65
  };
61
- }, [key, storage, isDelayed, flush]);
66
+ }, [key, storage, isDelayed, flush, keep]);
62
67
  const subscribe = React.useCallback((listener) => {
68
+ if (key === null) {
69
+ return () => { };
70
+ }
63
71
  return BrowserStorage.subscribe({
64
72
  key,
65
73
  storage,
@@ -70,6 +78,9 @@ export const useStoredState = (opts) => {
70
78
  });
71
79
  }, [key, storage, keep]);
72
80
  const getSnapshot = React.useCallback(() => {
81
+ if (key === null) {
82
+ return defaultValue;
83
+ }
73
84
  const text = BrowserStorage.get({ key, storage, fallback: null, codec: RAW_TEXT });
74
85
  if (text === null) {
75
86
  return defaultValue;
@@ -87,7 +98,12 @@ export const useStoredState = (opts) => {
87
98
  const setValue = React.useCallback((next) => {
88
99
  const hit = keptRef.current;
89
100
  const current = hit?.key === key && hit.storage === storage ? hit.value : getSnapshot();
90
- const entry = { key, storage, codec, value: isUpdater(next) ? next(current) : next };
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 };
91
107
  if (!(writeDelayMs > 0)) {
92
108
  write(entry);
93
109
  return;
@@ -96,6 +112,10 @@ export const useStoredState = (opts) => {
96
112
  timerRef.current = setTimeout(() => write(entry), writeDelayMs);
97
113
  }, [key, storage, codec, writeDelayMs, getSnapshot, keep, write]);
98
114
  const remove = React.useCallback(() => {
115
+ if (key === null) {
116
+ keep(undefined);
117
+ return;
118
+ }
99
119
  const isRemoved = BrowserStorage.remove({ key, storage });
100
120
  keep(isRemoved ? undefined : { key, storage, value: defaultValue });
101
121
  }, [key, storage, defaultValue, keep]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venizia/ardor-react",
3
- "version": "0.4.8-6",
3
+ "version": "0.4.8-8",
4
4
  "description": "ARDOR React bindings - the application context, the injectable and logger hooks that read it, typed Redux hook factories, and the framework-agnostic UI hooks (debounce, autosave, confirm, clipboard, element size, window dimensions). No react-admin.",
5
5
  "keywords": [
6
6
  "ardor",
@@ -59,7 +59,7 @@
59
59
  "prepublishOnly": "make rebuild"
60
60
  },
61
61
  "dependencies": {
62
- "@venizia/ardor-kernel": "0.4.8-6"
62
+ "@venizia/ardor-kernel": "0.4.8-8"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@happy-dom/global-registrator": "^21.0.4",
@@ -69,11 +69,11 @@
69
69
  "@types/bun": "^1.4.3",
70
70
  "@types/react": "^19",
71
71
  "@types/react-dom": "^19",
72
- "@venizia/dev-configs": "^0.4.8-2",
73
- "@venizia/ignis-filter": "^0.4.7-3",
74
- "@venizia/ignis-helpers": "^0.4.8-5",
75
- "@venizia/ignis-inversion": "^0.4.7-2",
76
- "@venizia/ignis-kernel": "^0.4.8-5",
72
+ "@venizia/dev-configs": "^0.4.8-4",
73
+ "@venizia/ignis-filter": "^0.4.7-4",
74
+ "@venizia/ignis-helpers": "^0.4.8-7",
75
+ "@venizia/ignis-inversion": "^0.4.7-3",
76
+ "@venizia/ignis-kernel": "^0.4.8-7",
77
77
  "eslint": "^10.13.0",
78
78
  "oxlint": "1.89.0",
79
79
  "oxlint-tsgolint": "7.0.2004",
@@ -87,10 +87,10 @@
87
87
  },
88
88
  "peerDependencies": {
89
89
  "@reduxjs/toolkit": ">=2.13.0",
90
- "@venizia/ignis-filter": "^0.4.7-3",
91
- "@venizia/ignis-helpers": "^0.4.8-5",
92
- "@venizia/ignis-inversion": "^0.4.7-2",
93
- "@venizia/ignis-kernel": "^0.4.8-5",
90
+ "@venizia/ignis-filter": "^0.4.7-4",
91
+ "@venizia/ignis-helpers": "^0.4.8-7",
92
+ "@venizia/ignis-inversion": "^0.4.7-3",
93
+ "@venizia/ignis-kernel": "^0.4.8-7",
94
94
  "react": ">=19.3.1",
95
95
  "react-redux": ">=9.3.0",
96
96
  "reflect-metadata": ">=0.2.2"
@@ -111,7 +111,7 @@
111
111
  "react",
112
112
  "react-redux"
113
113
  ],
114
- "limit": "3540 B"
114
+ "limit": "3875 B"
115
115
  }
116
116
  ],
117
117
  "engines": {