@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.
- package/index.js +51 -0
- package/package.json +38 -0
- package/src/action_private_properties.js +11 -0
- package/src/action_proxy_test.html +353 -0
- package/src/action_run_states.js +5 -0
- package/src/actions.js +1377 -0
- package/src/browser_integration/browser_integration.js +191 -0
- package/src/browser_integration/document_back_and_forward.js +17 -0
- package/src/browser_integration/document_loading_signal.js +100 -0
- package/src/browser_integration/document_state_signal.js +9 -0
- package/src/browser_integration/document_url_signal.js +9 -0
- package/src/browser_integration/use_is_visited.js +19 -0
- package/src/browser_integration/via_history.js +199 -0
- package/src/browser_integration/via_navigation.js +168 -0
- package/src/components/action_execution/form_context.js +8 -0
- package/src/components/action_execution/render_actionable_component.jsx +27 -0
- package/src/components/action_execution/use_action.js +330 -0
- package/src/components/action_execution/use_execute_action.js +161 -0
- package/src/components/action_renderer.jsx +136 -0
- package/src/components/collect_form_element_values.js +79 -0
- package/src/components/demos/0_button_demo.html +155 -0
- package/src/components/demos/1_checkbox_demo.html +257 -0
- package/src/components/demos/2_input_textual_demo.html +354 -0
- package/src/components/demos/3_radio_demo.html +222 -0
- package/src/components/demos/4_select_demo.html +104 -0
- package/src/components/demos/5_list_scrollable_demo.html +153 -0
- package/src/components/demos/action/0_button_demo.html +204 -0
- package/src/components/demos/action/10_shortcuts_demo.html +189 -0
- package/src/components/demos/action/11_nested_shortcuts_demo.html +401 -0
- package/src/components/demos/action/1_input_text_demo.html +461 -0
- package/src/components/demos/action/2_form_multiple.html +303 -0
- package/src/components/demos/action/3_details_demo.html +172 -0
- package/src/components/demos/action/4_input_checkbox_demo.html +611 -0
- package/src/components/demos/action/6_checkbox_list_demo.html +109 -0
- package/src/components/demos/action/7_radio_list_demo.html +217 -0
- package/src/components/demos/action/8_editable_text_demo.html +442 -0
- package/src/components/demos/action/9_link_demo.html +172 -0
- package/src/components/demos/demo.md +0 -0
- package/src/components/demos/route/basic/basic.html +14 -0
- package/src/components/demos/route/basic/basic_route_demo.jsx +224 -0
- package/src/components/demos/route/multi/multi.html +14 -0
- package/src/components/demos/route/multi/multi_route_demo.jsx +277 -0
- package/src/components/details/details.jsx +248 -0
- package/src/components/details/summary_marker.jsx +141 -0
- package/src/components/editable_text/editable_text.jsx +96 -0
- package/src/components/error_boundary_context.js +9 -0
- package/src/components/form.jsx +144 -0
- package/src/components/input/button.jsx +333 -0
- package/src/components/input/checkbox_list.jsx +294 -0
- package/src/components/input/field.jsx +61 -0
- package/src/components/input/field_css.js +118 -0
- package/src/components/input/input.jsx +15 -0
- package/src/components/input/input_checkbox.jsx +370 -0
- package/src/components/input/input_radio.jsx +299 -0
- package/src/components/input/input_textual.jsx +338 -0
- package/src/components/input/radio_list.jsx +283 -0
- package/src/components/input/select.jsx +273 -0
- package/src/components/input/use_form_event.js +20 -0
- package/src/components/input/use_on_change.js +12 -0
- package/src/components/link/link.jsx +291 -0
- package/src/components/loader/loader_background.jsx +324 -0
- package/src/components/loader/loading_spinner.jsx +68 -0
- package/src/components/loader/network_speed.js +83 -0
- package/src/components/loader/rectangle_loading.jsx +225 -0
- package/src/components/route.jsx +15 -0
- package/src/components/selection/selection.js +5 -0
- package/src/components/selection/selection_context.jsx +262 -0
- package/src/components/shortcut/os.js +9 -0
- package/src/components/shortcut/shortcut_context.jsx +390 -0
- package/src/components/use_action_events.js +37 -0
- package/src/components/use_auto_focus.js +43 -0
- package/src/components/use_debounce_true.js +31 -0
- package/src/components/use_focus_group.js +19 -0
- package/src/components/use_initial_value.js +104 -0
- package/src/components/use_is_visited.js +19 -0
- package/src/components/use_ref_array.js +38 -0
- package/src/components/use_signal_sync.js +50 -0
- package/src/components/use_state_array.js +40 -0
- package/src/docs/actions.md +228 -0
- package/src/docs/demos/resource/action_status.jsx +42 -0
- package/src/docs/demos/resource/demo.md +1 -0
- package/src/docs/demos/resource/resource_demo_0.html +84 -0
- package/src/docs/demos/resource/resource_demo_10_post_gc.html +364 -0
- package/src/docs/demos/resource/resource_demo_11_describe_many.html +362 -0
- package/src/docs/demos/resource/resource_demo_2.html +173 -0
- package/src/docs/demos/resource/resource_demo_3_filtered_users.html +415 -0
- package/src/docs/demos/resource/resource_demo_4_details.html +284 -0
- package/src/docs/demos/resource/resource_demo_5_renderer_lazy.html +115 -0
- package/src/docs/demos/resource/resource_demo_6_gc.html +217 -0
- package/src/docs/demos/resource/resource_demo_7_child_gc.html +240 -0
- package/src/docs/demos/resource/resource_demo_8_proxy_gc.html +319 -0
- package/src/docs/demos/resource/resource_demo_9_describe_one.html +472 -0
- package/src/docs/demos/resource/tata.jsx +3 -0
- package/src/docs/demos/resource/toto.jsx +3 -0
- package/src/docs/demos/user_nav/user_nav.html +12 -0
- package/src/docs/demos/user_nav/user_nav.jsx +330 -0
- package/src/docs/resource_dependencies.md +103 -0
- package/src/docs/resource_with_params.md +80 -0
- package/src/notes.md +13 -0
- package/src/route/route.js +518 -0
- package/src/route/route.test.html +228 -0
- package/src/store/array_signal_store.js +537 -0
- package/src/store/local_storage_signal.js +17 -0
- package/src/store/resource_graph.js +1303 -0
- package/src/store/tests/resource_graph_autoreload_demo.html +12 -0
- package/src/store/tests/resource_graph_autoreload_demo.jsx +964 -0
- package/src/store/tests/resource_graph_dependencies.test.js +95 -0
- package/src/store/value_in_local_storage.js +187 -0
- package/src/symbol_object_signal.js +1 -0
- package/src/use_action_data.js +10 -0
- package/src/use_action_status.js +47 -0
- package/src/utils/add_many_event_listeners.js +15 -0
- package/src/utils/array_add_remove.js +61 -0
- package/src/utils/array_signal.js +15 -0
- package/src/utils/compare_two_js_values.js +172 -0
- package/src/utils/execute_with_cleanup.js +21 -0
- package/src/utils/get_caller_info.js +85 -0
- package/src/utils/iterable_weak_set.js +62 -0
- package/src/utils/js_value_weak_map.js +162 -0
- package/src/utils/js_value_weak_map_demo.html +690 -0
- package/src/utils/merge_two_js_values.js +53 -0
- package/src/utils/stringify_for_display.js +150 -0
- 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
|
+
};
|