@jsenv/navi 0.0.1

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 (123) hide show
  1. package/index.js +51 -0
  2. package/package.json +38 -0
  3. package/src/action_private_properties.js +11 -0
  4. package/src/action_proxy_test.html +353 -0
  5. package/src/action_run_states.js +5 -0
  6. package/src/actions.js +1377 -0
  7. package/src/browser_integration/browser_integration.js +191 -0
  8. package/src/browser_integration/document_back_and_forward.js +17 -0
  9. package/src/browser_integration/document_loading_signal.js +100 -0
  10. package/src/browser_integration/document_state_signal.js +9 -0
  11. package/src/browser_integration/document_url_signal.js +9 -0
  12. package/src/browser_integration/use_is_visited.js +19 -0
  13. package/src/browser_integration/via_history.js +199 -0
  14. package/src/browser_integration/via_navigation.js +168 -0
  15. package/src/components/action_execution/form_context.js +8 -0
  16. package/src/components/action_execution/render_actionable_component.jsx +27 -0
  17. package/src/components/action_execution/use_action.js +330 -0
  18. package/src/components/action_execution/use_execute_action.js +161 -0
  19. package/src/components/action_renderer.jsx +136 -0
  20. package/src/components/collect_form_element_values.js +79 -0
  21. package/src/components/demos/0_button_demo.html +155 -0
  22. package/src/components/demos/1_checkbox_demo.html +257 -0
  23. package/src/components/demos/2_input_textual_demo.html +354 -0
  24. package/src/components/demos/3_radio_demo.html +222 -0
  25. package/src/components/demos/4_select_demo.html +104 -0
  26. package/src/components/demos/5_list_scrollable_demo.html +153 -0
  27. package/src/components/demos/action/0_button_demo.html +204 -0
  28. package/src/components/demos/action/10_shortcuts_demo.html +189 -0
  29. package/src/components/demos/action/11_nested_shortcuts_demo.html +401 -0
  30. package/src/components/demos/action/1_input_text_demo.html +461 -0
  31. package/src/components/demos/action/2_form_multiple.html +303 -0
  32. package/src/components/demos/action/3_details_demo.html +172 -0
  33. package/src/components/demos/action/4_input_checkbox_demo.html +611 -0
  34. package/src/components/demos/action/6_checkbox_list_demo.html +109 -0
  35. package/src/components/demos/action/7_radio_list_demo.html +217 -0
  36. package/src/components/demos/action/8_editable_text_demo.html +442 -0
  37. package/src/components/demos/action/9_link_demo.html +172 -0
  38. package/src/components/demos/demo.md +0 -0
  39. package/src/components/demos/route/basic/basic.html +14 -0
  40. package/src/components/demos/route/basic/basic_route_demo.jsx +224 -0
  41. package/src/components/demos/route/multi/multi.html +14 -0
  42. package/src/components/demos/route/multi/multi_route_demo.jsx +277 -0
  43. package/src/components/details/details.jsx +248 -0
  44. package/src/components/details/summary_marker.jsx +141 -0
  45. package/src/components/editable_text/editable_text.jsx +96 -0
  46. package/src/components/error_boundary_context.js +9 -0
  47. package/src/components/form.jsx +144 -0
  48. package/src/components/input/button.jsx +333 -0
  49. package/src/components/input/checkbox_list.jsx +294 -0
  50. package/src/components/input/field.jsx +61 -0
  51. package/src/components/input/field_css.js +118 -0
  52. package/src/components/input/input.jsx +15 -0
  53. package/src/components/input/input_checkbox.jsx +370 -0
  54. package/src/components/input/input_radio.jsx +299 -0
  55. package/src/components/input/input_textual.jsx +338 -0
  56. package/src/components/input/radio_list.jsx +283 -0
  57. package/src/components/input/select.jsx +273 -0
  58. package/src/components/input/use_form_event.js +20 -0
  59. package/src/components/input/use_on_change.js +12 -0
  60. package/src/components/link/link.jsx +291 -0
  61. package/src/components/loader/loader_background.jsx +324 -0
  62. package/src/components/loader/loading_spinner.jsx +68 -0
  63. package/src/components/loader/network_speed.js +83 -0
  64. package/src/components/loader/rectangle_loading.jsx +225 -0
  65. package/src/components/route.jsx +15 -0
  66. package/src/components/selection/selection.js +5 -0
  67. package/src/components/selection/selection_context.jsx +262 -0
  68. package/src/components/shortcut/os.js +9 -0
  69. package/src/components/shortcut/shortcut_context.jsx +390 -0
  70. package/src/components/use_action_events.js +37 -0
  71. package/src/components/use_auto_focus.js +43 -0
  72. package/src/components/use_debounce_true.js +31 -0
  73. package/src/components/use_focus_group.js +19 -0
  74. package/src/components/use_initial_value.js +104 -0
  75. package/src/components/use_is_visited.js +19 -0
  76. package/src/components/use_ref_array.js +38 -0
  77. package/src/components/use_signal_sync.js +50 -0
  78. package/src/components/use_state_array.js +40 -0
  79. package/src/docs/actions.md +228 -0
  80. package/src/docs/demos/resource/action_status.jsx +42 -0
  81. package/src/docs/demos/resource/demo.md +1 -0
  82. package/src/docs/demos/resource/resource_demo_0.html +84 -0
  83. package/src/docs/demos/resource/resource_demo_10_post_gc.html +364 -0
  84. package/src/docs/demos/resource/resource_demo_11_describe_many.html +362 -0
  85. package/src/docs/demos/resource/resource_demo_2.html +173 -0
  86. package/src/docs/demos/resource/resource_demo_3_filtered_users.html +415 -0
  87. package/src/docs/demos/resource/resource_demo_4_details.html +284 -0
  88. package/src/docs/demos/resource/resource_demo_5_renderer_lazy.html +115 -0
  89. package/src/docs/demos/resource/resource_demo_6_gc.html +217 -0
  90. package/src/docs/demos/resource/resource_demo_7_child_gc.html +240 -0
  91. package/src/docs/demos/resource/resource_demo_8_proxy_gc.html +319 -0
  92. package/src/docs/demos/resource/resource_demo_9_describe_one.html +472 -0
  93. package/src/docs/demos/resource/tata.jsx +3 -0
  94. package/src/docs/demos/resource/toto.jsx +3 -0
  95. package/src/docs/demos/user_nav/user_nav.html +12 -0
  96. package/src/docs/demos/user_nav/user_nav.jsx +330 -0
  97. package/src/docs/resource_dependencies.md +103 -0
  98. package/src/docs/resource_with_params.md +80 -0
  99. package/src/notes.md +13 -0
  100. package/src/route/route.js +518 -0
  101. package/src/route/route.test.html +228 -0
  102. package/src/store/array_signal_store.js +537 -0
  103. package/src/store/local_storage_signal.js +17 -0
  104. package/src/store/resource_graph.js +1303 -0
  105. package/src/store/tests/resource_graph_autoreload_demo.html +12 -0
  106. package/src/store/tests/resource_graph_autoreload_demo.jsx +964 -0
  107. package/src/store/tests/resource_graph_dependencies.test.js +95 -0
  108. package/src/store/value_in_local_storage.js +187 -0
  109. package/src/symbol_object_signal.js +1 -0
  110. package/src/use_action_data.js +10 -0
  111. package/src/use_action_status.js +47 -0
  112. package/src/utils/add_many_event_listeners.js +15 -0
  113. package/src/utils/array_add_remove.js +61 -0
  114. package/src/utils/array_signal.js +15 -0
  115. package/src/utils/compare_two_js_values.js +172 -0
  116. package/src/utils/execute_with_cleanup.js +21 -0
  117. package/src/utils/get_caller_info.js +85 -0
  118. package/src/utils/iterable_weak_set.js +62 -0
  119. package/src/utils/js_value_weak_map.js +162 -0
  120. package/src/utils/js_value_weak_map_demo.html +690 -0
  121. package/src/utils/merge_two_js_values.js +53 -0
  122. package/src/utils/stringify_for_display.js +150 -0
  123. package/src/utils/weak_effect.js +48 -0
@@ -0,0 +1,390 @@
1
+ import { canInterceptKeys } from "@jsenv/dom";
2
+ import { requestAction } from "@jsenv/validation";
3
+ import { createContext } from "preact";
4
+ import { useContext, useEffect, useRef, useState } from "preact/hooks";
5
+ import { useAction } from "../action_execution/use_action.js";
6
+ import { useExecuteAction } from "../action_execution/use_execute_action.js";
7
+ import { useActionEvents } from "../use_action_events.js";
8
+ import { isMac } from "./os.js";
9
+
10
+ import.meta.css = /* css */ `
11
+ .navi_shortcut_container {
12
+ /* Visually hidden container - doesn't affect layout */
13
+ position: absolute;
14
+ width: 1px;
15
+ height: 1px;
16
+ padding: 0;
17
+ margin: -1px;
18
+ overflow: hidden;
19
+ clip: rect(0, 0, 0, 0);
20
+ white-space: nowrap;
21
+ border: 0;
22
+
23
+ /* Ensure it's not interactable */
24
+ opacity: 0;
25
+ pointer-events: none;
26
+ }
27
+
28
+ .navi_shortcut_button {
29
+ /* Visually hidden but accessible to screen readers */
30
+ position: absolute;
31
+ width: 1px;
32
+ height: 1px;
33
+ padding: 0;
34
+ margin: -1px;
35
+ overflow: hidden;
36
+ clip: rect(0, 0, 0, 0);
37
+ white-space: nowrap;
38
+ border: 0;
39
+
40
+ /* Ensure it's not focusable via tab navigation */
41
+ opacity: 0;
42
+ pointer-events: none;
43
+ }
44
+ `;
45
+
46
+ const ShortcutContext = createContext();
47
+ export const useShortcutContext = () => {
48
+ return useContext(ShortcutContext);
49
+ };
50
+
51
+ export const ShortcutProvider = ({
52
+ elementRef,
53
+ shortcuts,
54
+ onActionPrevented,
55
+ onActionStart,
56
+ onActionAbort,
57
+ onActionError,
58
+ onActionEnd,
59
+ allowConcurrentActions,
60
+ children,
61
+ }) => {
62
+ if (!elementRef) {
63
+ throw new Error(
64
+ "ShortcutProvider requires an elementRef to attach shortcuts to.",
65
+ );
66
+ }
67
+
68
+ const shortcutElements = [];
69
+ shortcuts.forEach((shortcut) => {
70
+ const combinationString = useAriaKeyShortcuts(shortcut.key);
71
+ shortcutElements.push(
72
+ <button
73
+ className="navi_shortcut_button"
74
+ key={combinationString}
75
+ aria-keyshortcuts={combinationString}
76
+ tabIndex="-1"
77
+ action={shortcut.action}
78
+ data-action={shortcut.action.name}
79
+ data-confirm-message={shortcut.confirmMessage}
80
+ >
81
+ {shortcut.description}
82
+ </button>,
83
+ );
84
+ });
85
+ const shortcutElementRef = useRef();
86
+ const shortcutHiddenElement = (
87
+ <div ref={shortcutElementRef} className="navi_shortcut_container">
88
+ {shortcutElements}
89
+ </div>
90
+ );
91
+
92
+ const executeAction = useExecuteAction(shortcutElementRef);
93
+ const [shortcutActionIsBusy, setShortcutActionIsBusy] = useState(false);
94
+ useActionEvents(shortcutElementRef, {
95
+ onPrevented: onActionPrevented,
96
+ onAction: (actionEvent) => {
97
+ // action can be a function or an action object, whem a function we must "wrap" it in a function returning that function
98
+ // otherwise setState would call that action immediately
99
+ setAction(() => actionEvent.detail.action);
100
+ executeAction(actionEvent, { requester: elementRef.current });
101
+ },
102
+ onStart: (e) => {
103
+ if (!allowConcurrentActions) {
104
+ setShortcutActionIsBusy(true);
105
+ }
106
+ onActionStart?.(e);
107
+ },
108
+ onAbort: (e) => {
109
+ setShortcutActionIsBusy(false);
110
+ onActionAbort?.(e);
111
+ },
112
+ onError: (e) => {
113
+ setShortcutActionIsBusy(false);
114
+ onActionError?.(e);
115
+ },
116
+ onEnd: (e) => {
117
+ setShortcutActionIsBusy(false);
118
+ onActionEnd?.(e);
119
+ },
120
+ });
121
+
122
+ const [action, setAction] = useState(null);
123
+ for (const shortcut of shortcuts) {
124
+ shortcut.action = useAction(shortcut.action);
125
+ }
126
+
127
+ useKeyboardShortcuts(elementRef, shortcuts, (shortcut, event) => {
128
+ if (shortcutActionIsBusy) {
129
+ return;
130
+ }
131
+ event.preventDefault();
132
+ const { action } = shortcut;
133
+ requestAction(action, {
134
+ event,
135
+ target: shortcutElementRef.current,
136
+ requester: elementRef.current,
137
+ confirmMessage: shortcut.confirmMessage,
138
+ });
139
+ });
140
+
141
+ return (
142
+ <ShortcutContext.Provider
143
+ value={{
144
+ shortcutAction: action,
145
+ shortcutActionIsBusy,
146
+ }}
147
+ >
148
+ {children}
149
+ {shortcutHiddenElement}
150
+ </ShortcutContext.Provider>
151
+ );
152
+ };
153
+
154
+ export const useKeyboardShortcuts = (elementRef, shortcuts, onShortcut) => {
155
+ const shortcutsRef = useRef(shortcuts);
156
+ shortcutsRef.current = shortcuts;
157
+
158
+ const onShortcutRef = useRef(onShortcut);
159
+ onShortcutRef.current = onShortcut;
160
+
161
+ useEffect(() => {
162
+ const element = elementRef.current;
163
+
164
+ const onKeydown = (event) => {
165
+ if (!canInterceptKeys(event)) {
166
+ return;
167
+ }
168
+
169
+ let shortcutFound;
170
+ for (const shortcutCandidate of shortcutsRef.current) {
171
+ const { enabled = true, key } = shortcutCandidate;
172
+ if (!enabled) {
173
+ continue;
174
+ }
175
+
176
+ // Handle platform-specific combination objects
177
+ let actualCombination;
178
+ let crossPlatformCombination;
179
+
180
+ if (typeof key === "object" && key !== null) {
181
+ actualCombination = isMac ? key.mac : key.other;
182
+ } else {
183
+ actualCombination = key;
184
+
185
+ // Auto-generate cross-platform combination if needed
186
+ if (containsPlatformSpecificKeys(key)) {
187
+ crossPlatformCombination = generateCrossPlatformCombination(key);
188
+ }
189
+ }
190
+
191
+ // Check both the actual combination and cross-platform combination
192
+ const matchesActual =
193
+ actualCombination &&
194
+ eventIsMatchingKeyCombination(event, actualCombination);
195
+ const matchesCrossPlatform =
196
+ crossPlatformCombination &&
197
+ crossPlatformCombination !== actualCombination &&
198
+ eventIsMatchingKeyCombination(event, crossPlatformCombination);
199
+
200
+ if (!matchesActual && !matchesCrossPlatform) {
201
+ continue;
202
+ }
203
+ if (shortcutCandidate.when && !shortcutCandidate.when(event)) {
204
+ continue;
205
+ }
206
+ shortcutFound = shortcutCandidate;
207
+ break;
208
+ }
209
+ if (!shortcutFound) {
210
+ return;
211
+ }
212
+ onShortcutRef.current(shortcutFound, event);
213
+ };
214
+
215
+ element.addEventListener("keydown", onKeydown);
216
+ return () => {
217
+ element.removeEventListener("keydown", onKeydown);
218
+ };
219
+ }, []);
220
+ };
221
+
222
+ // Configuration for mapping shortcut key names to browser event properties
223
+ const modifierKeyMapping = {
224
+ metaKey: {
225
+ names: ["meta"],
226
+ macNames: ["command", "cmd"],
227
+ },
228
+ ctrlKey: {
229
+ names: ["control", "ctrl"],
230
+ },
231
+ shiftKey: {
232
+ names: ["shift"],
233
+ },
234
+ altKey: {
235
+ names: ["alt"],
236
+ macNames: ["option"],
237
+ },
238
+ };
239
+ // Maps canonical browser key names to their user-friendly aliases.
240
+ // Used for both event matching and ARIA normalization.
241
+ const keyMapping = {
242
+ " ": { alias: ["space"] },
243
+ "escape": { alias: ["esc"] },
244
+ "arrowup": { alias: ["up"] },
245
+ "arrowdown": { alias: ["down"] },
246
+ "arrowleft": { alias: ["left"] },
247
+ "arrowright": { alias: ["right"] },
248
+ "delete": { alias: ["del"] },
249
+ // Platform-specific mappings
250
+ ...(isMac
251
+ ? { delete: { alias: ["backspace"] } }
252
+ : { backspace: { alias: ["delete"] } }),
253
+ };
254
+ const keyToAriaKeyMapping = {
255
+ // Platform-specific ARIA names
256
+ command: "meta",
257
+ option: "altgraph", // Mac option key uses "altgraph" in ARIA spec
258
+
259
+ // Regular keys - platform-specific normalization
260
+ delete: isMac ? "backspace" : "delete", // Mac delete key is backspace semantically
261
+ backspace: isMac ? "backspace" : "delete",
262
+ };
263
+
264
+ const eventIsMatchingKeyCombination = (event, keyCombination) => {
265
+ const keys = keyCombination.toLowerCase().split("+");
266
+
267
+ for (const key of keys) {
268
+ let modifierFound = false;
269
+
270
+ // Check if this key is a modifier
271
+ for (const [eventProperty, config] of Object.entries(modifierKeyMapping)) {
272
+ const allNames = [...config.names];
273
+
274
+ // Add Mac-specific names only if we're on Mac and they exist
275
+ if (isMac && config.macNames) {
276
+ allNames.push(...config.macNames);
277
+ }
278
+
279
+ if (allNames.includes(key)) {
280
+ // Check if the corresponding event property is pressed
281
+ if (!event[eventProperty]) {
282
+ return false;
283
+ }
284
+ modifierFound = true;
285
+ break;
286
+ }
287
+ }
288
+ if (modifierFound) {
289
+ continue;
290
+ }
291
+
292
+ // If it's not a modifier, check if it matches the actual key
293
+ if (!isSameKey(event.key, key)) {
294
+ return false;
295
+ }
296
+ }
297
+ return true;
298
+ };
299
+
300
+ const isSameKey = (browserEventKey, key) => {
301
+ browserEventKey = browserEventKey.toLowerCase();
302
+ key = key.toLowerCase();
303
+
304
+ if (browserEventKey === key) {
305
+ return true;
306
+ }
307
+
308
+ // Check if either key is an alias for the other
309
+ for (const [canonicalKey, config] of Object.entries(keyMapping)) {
310
+ const allKeys = [canonicalKey, ...config.alias];
311
+ if (allKeys.includes(browserEventKey) && allKeys.includes(key)) {
312
+ return true;
313
+ }
314
+ }
315
+
316
+ return false;
317
+ };
318
+
319
+ const normalizeKey = (key) => {
320
+ key = key.toLowerCase();
321
+
322
+ // Find the canonical form for this key
323
+ for (const [canonicalKey, config] of Object.entries(keyMapping)) {
324
+ const allKeys = [canonicalKey, ...config.alias];
325
+ if (allKeys.includes(key)) {
326
+ return canonicalKey;
327
+ }
328
+ }
329
+
330
+ return key;
331
+ };
332
+
333
+ const normalizeKeyCombination = (combination) => {
334
+ const lowerCaseCombination = combination.toLowerCase();
335
+ const keys = lowerCaseCombination.split("+");
336
+
337
+ // First normalize keys to their canonical form, then apply ARIA mapping
338
+ for (let i = 0; i < keys.length; i++) {
339
+ let key = normalizeKey(keys[i]);
340
+
341
+ // Then apply ARIA-specific mappings if they exist
342
+ if (keyToAriaKeyMapping[key]) {
343
+ key = keyToAriaKeyMapping[key];
344
+ }
345
+
346
+ keys[i] = key;
347
+ }
348
+
349
+ return keys.join("+");
350
+ };
351
+
352
+ // http://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts
353
+ const useAriaKeyShortcuts = (key) => {
354
+ let actualCombination;
355
+
356
+ // Handle platform-specific combination objects
357
+ if (typeof key === "object" && key !== null) {
358
+ actualCombination = isMac ? key.mac : key.other;
359
+ } else {
360
+ actualCombination = key;
361
+ }
362
+
363
+ if (actualCombination) {
364
+ return normalizeKeyCombination(actualCombination);
365
+ }
366
+
367
+ return "";
368
+ };
369
+
370
+ const containsPlatformSpecificKeys = (combination) => {
371
+ const lowerCombination = combination.toLowerCase();
372
+ const macSpecificKeys = ["command", "cmd"];
373
+
374
+ return macSpecificKeys.some((key) => lowerCombination.includes(key));
375
+ };
376
+
377
+ const generateCrossPlatformCombination = (combination) => {
378
+ let crossPlatform = combination;
379
+
380
+ if (isMac) {
381
+ // No need to convert anything TO Windows/Linux-specific format since we're on Mac
382
+ return null;
383
+ }
384
+
385
+ // If not on Mac but combination contains Mac-specific keys, generate Windows equivalent
386
+ crossPlatform = crossPlatform.replace(/\bcommand\b/gi, "control");
387
+ crossPlatform = crossPlatform.replace(/\bcmd\b/gi, "control");
388
+
389
+ return crossPlatform;
390
+ };
@@ -0,0 +1,37 @@
1
+ import { useLayoutEffect } from "preact/hooks";
2
+ import { addManyEventListeners } from "../utils/add_many_event_listeners.js";
3
+
4
+ export const useActionEvents = (
5
+ elementRef,
6
+ {
7
+ /**
8
+ * @param {Event} e - L'événement original
9
+ * @param {"form_reset" | "blur_invalid" | "escape_key"} reason - Raison du cancel
10
+ */
11
+ onCancel,
12
+ onPrevented,
13
+ onAction,
14
+ onStart,
15
+ onAbort,
16
+ onError,
17
+ onEnd,
18
+ },
19
+ ) => {
20
+ useLayoutEffect(() => {
21
+ const element = elementRef.current;
22
+
23
+ return addManyEventListeners(element, {
24
+ cancel: (e) => {
25
+ onCancel?.(e, e.detail.reason);
26
+ },
27
+ actionprevented: onPrevented,
28
+ action: onAction,
29
+ actionstart: onStart,
30
+ actionabort: onAbort,
31
+ actionerror: (e) => {
32
+ onError?.(e.detail.error);
33
+ },
34
+ actionend: onEnd,
35
+ });
36
+ }, [onCancel, onPrevented, onAction, onStart, onError, onEnd]);
37
+ };
@@ -0,0 +1,43 @@
1
+ // autoFocus does not work so we focus in a useLayoutEffect,
2
+ // see https://github.com/preactjs/preact/issues/1255
3
+
4
+ import { useEffect, useLayoutEffect } from "preact/hooks";
5
+
6
+ export const useAutoFocus = (
7
+ focusableElementRef,
8
+ autoFocus,
9
+ { autoFocusVisible, autoSelect } = {},
10
+ ) => {
11
+ useLayoutEffect(() => {
12
+ if (!autoFocus) {
13
+ return null;
14
+ }
15
+ const activeElement = document.activeElement;
16
+ const focusableElement = focusableElementRef.current;
17
+ focusableElement.focus({ focusVisible: autoFocusVisible });
18
+ if (autoSelect) {
19
+ focusableElement.select();
20
+ // Keep the beginning of the text visible instead of scrolling to the end
21
+ focusableElement.scrollLeft = 0;
22
+ }
23
+ return () => {
24
+ if (
25
+ document.activeElement === focusableElement ||
26
+ document.activeElement === document.body
27
+ ) {
28
+ // if the input is focused when the component is unmounted,
29
+ // we restore focus to the element that was focused before
30
+ // the input was focused
31
+ if (document.body.contains(activeElement)) {
32
+ activeElement.focus();
33
+ }
34
+ }
35
+ };
36
+ }, []);
37
+ useEffect(() => {
38
+ if (autoFocus) {
39
+ const focusableElement = focusableElementRef.current;
40
+ focusableElement.scrollIntoView({ inline: "nearest", block: "nearest" });
41
+ }
42
+ }, []);
43
+ };
@@ -0,0 +1,31 @@
1
+ import { useEffect, useRef, useState } from "preact/hooks";
2
+
3
+ export const useDebounceTrue = (value, delay = 300) => {
4
+ const [debouncedValue, setDebouncedValue] = useState(false);
5
+ const timerRef = useRef(null);
6
+
7
+ useEffect(() => {
8
+ // If value becomes true, start a timer
9
+ if (value) {
10
+ timerRef.current = setTimeout(() => {
11
+ setDebouncedValue(true);
12
+ }, delay);
13
+ } else {
14
+ // If value becomes false, clear any pending timer and immediately set to false
15
+ if (timerRef.current) {
16
+ clearTimeout(timerRef.current);
17
+ timerRef.current = null;
18
+ }
19
+ setDebouncedValue(false);
20
+ }
21
+
22
+ // Cleanup function
23
+ return () => {
24
+ if (timerRef.current) {
25
+ clearTimeout(timerRef.current);
26
+ }
27
+ };
28
+ }, [value, delay]);
29
+
30
+ return debouncedValue;
31
+ };
@@ -0,0 +1,19 @@
1
+ import { initFocusGroup } from "@jsenv/dom";
2
+ import { useLayoutEffect } from "preact/hooks";
3
+
4
+ export const useFocusGroup = (elementRef, options) => {
5
+ const { direction, skipTab, loop, name, enabled } = options;
6
+
7
+ useLayoutEffect(() => {
8
+ if (!enabled) {
9
+ return null;
10
+ }
11
+ const focusGroup = initFocusGroup(elementRef.current, {
12
+ direction,
13
+ skipTab,
14
+ loop,
15
+ name,
16
+ });
17
+ return focusGroup.cleanup;
18
+ }, [direction, skipTab, loop, name]);
19
+ };
@@ -0,0 +1,104 @@
1
+ import { useRef } from "preact/hooks";
2
+
3
+ let debug = false;
4
+
5
+ /**
6
+ * Picks the best initial value from three options using a simple priority system.
7
+ *
8
+ * @param {any} externalValue - Value from props or parent component
9
+ * @param {any} fallbackValue - Backup value if external value isn't useful
10
+ * @param {any} defaultValue - Final fallback (usually empty/neutral value)
11
+ *
12
+ * @returns {any} The chosen value using this priority:
13
+ * 1. externalValue (if provided and different from default)
14
+ * 2. fallbackValue (if external value is missing/same as default)
15
+ * 3. defaultValue (if nothing else works)
16
+ *
17
+ * @example
18
+ * resolveInitialValue("hello", "backup", "") → "hello"
19
+ * resolveInitialValue(undefined, "backup", "") → "backup"
20
+ * resolveInitialValue("", "backup", "") → "backup" (empty same as default)
21
+ */
22
+ export const resolveInitialValue = (
23
+ externalValue,
24
+ fallbackValue,
25
+ defaultValue,
26
+ ) => {
27
+ if (externalValue !== undefined && externalValue !== defaultValue) {
28
+ return externalValue;
29
+ }
30
+ if (fallbackValue !== undefined) {
31
+ return fallbackValue;
32
+ }
33
+ return defaultValue;
34
+ };
35
+
36
+ /**
37
+ * Hook that syncs external value changes to a setState function.
38
+ * Always syncs when external value changes, regardless of what it changes to.
39
+ *
40
+ * @param {any} externalValue - Value from props or parent component to watch for changes
41
+ * @param {any} defaultValue - Default value to use when external value is undefined
42
+ * @param {Function} setValue - Function to call when external value changes
43
+ * @param {string} name - Parameter name for debugging
44
+ */
45
+ export const useExternalValueSync = (
46
+ externalValue,
47
+ defaultValue,
48
+ setValue,
49
+ name = "",
50
+ ) => {
51
+ // Track external value changes and sync them
52
+ const previousExternalValueRef = useRef(externalValue);
53
+ if (externalValue !== previousExternalValueRef.current) {
54
+ previousExternalValueRef.current = externalValue;
55
+ // Always sync external value changes - use defaultValue only when external is undefined
56
+ const valueToSet =
57
+ externalValue === undefined ? defaultValue : externalValue;
58
+ if (debug) {
59
+ console.debug(
60
+ `useExternalValueSync(${name}) syncing external value change: ${valueToSet}`,
61
+ );
62
+ }
63
+ setValue(valueToSet);
64
+ }
65
+ };
66
+
67
+ /**
68
+ * Hook that handles initial value setup and external value synchronization.
69
+ *
70
+ * @param {string} name - Parameter name for debugging
71
+ * @param {any} externalValue - Value from props or parent component
72
+ * @param {any} fallbackValue - Backup value if external value isn't useful
73
+ * @param {any} defaultValue - Final fallback value
74
+ * @param {Function} setValue - Function to call when value needs to be set
75
+ *
76
+ * @returns {any} The resolved initial value
77
+ */
78
+ export const useInitialValue = (
79
+ name,
80
+ externalValue,
81
+ fallbackValue,
82
+ defaultValue,
83
+ setValue,
84
+ ) => {
85
+ const initialValue = resolveInitialValue(
86
+ externalValue,
87
+ fallbackValue,
88
+ defaultValue,
89
+ );
90
+
91
+ // Set initial value on mount
92
+ const mountedRef = useRef(false);
93
+ if (!mountedRef.current) {
94
+ mountedRef.current = true;
95
+ if (name) {
96
+ setValue(initialValue);
97
+ }
98
+ }
99
+
100
+ // Use the new sync hook
101
+ useExternalValueSync(externalValue, defaultValue, setValue, name);
102
+
103
+ return initialValue;
104
+ };
@@ -0,0 +1,19 @@
1
+ import {
2
+ isVisited,
3
+ visitedUrlsSignal,
4
+ } from "../browser_integration/browser_integration.js";
5
+
6
+ /**
7
+ * Hook that reactively checks if a URL is visited.
8
+ * Re-renders when the visited URL set changes.
9
+ *
10
+ * @param {string} url - The URL to check
11
+ * @returns {boolean} Whether the URL has been visited
12
+ */
13
+ export const useIsVisited = (url) => {
14
+ // Access the signal to create reactive dependency
15
+ // eslint-disable-next-line no-unused-expressions
16
+ visitedUrlsSignal.value;
17
+
18
+ return isVisited(url);
19
+ };
@@ -0,0 +1,38 @@
1
+ import { createRef } from "preact";
2
+ import { useMemo, useRef } from "preact/hooks";
3
+
4
+ export const useRefArray = (items, keyFromItem) => {
5
+ const refMapRef = useRef(new Map());
6
+ const previousKeySetRef = useRef(new Set());
7
+
8
+ return useMemo(() => {
9
+ const refMap = refMapRef.current;
10
+ const previousKeySet = previousKeySetRef.current;
11
+ const currentKeySet = new Set();
12
+ const refArray = [];
13
+
14
+ for (let i = 0; i < items.length; i++) {
15
+ const item = items[i];
16
+ const key = keyFromItem(item);
17
+ currentKeySet.add(key);
18
+
19
+ const refForKey = refMap.get(key);
20
+ if (refForKey) {
21
+ refArray[i] = refForKey;
22
+ } else {
23
+ const newRef = createRef();
24
+ refMap.set(key, newRef);
25
+ refArray[i] = newRef;
26
+ }
27
+ }
28
+
29
+ for (const key of previousKeySet) {
30
+ if (!currentKeySet.has(key)) {
31
+ refMap.delete(key);
32
+ }
33
+ }
34
+ previousKeySetRef.current = currentKeySet;
35
+
36
+ return refArray;
37
+ }, [items]);
38
+ };