@jsenv/navi 0.0.1 → 0.1.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/dist/jsenv_navi.js +22959 -0
- package/index.js +66 -16
- package/package.json +23 -11
- package/src/actions.js +50 -26
- package/src/browser_integration/browser_integration.js +31 -6
- package/src/browser_integration/via_history.js +42 -9
- package/src/components/action_execution/render_actionable_component.jsx +6 -4
- package/src/components/action_execution/use_action.js +51 -282
- package/src/components/action_execution/use_execute_action.js +106 -92
- package/src/components/action_execution/use_run_on_mount.js +9 -0
- package/src/components/action_renderer.jsx +21 -32
- package/src/components/demos/0_button_demo.html +574 -103
- package/src/components/demos/10_column_reordering_debug.html +277 -0
- package/src/components/demos/11_table_selection_debug.html +432 -0
- package/src/components/demos/1_checkbox_demo.html +579 -202
- package/src/components/demos/2_input_textual_demo.html +81 -138
- package/src/components/demos/3_radio_demo.html +0 -2
- package/src/components/demos/4_select_demo.html +19 -23
- package/src/components/demos/6_tablist_demo.html +77 -0
- package/src/components/demos/7_table_selection_demo.html +176 -0
- package/src/components/demos/8_table_fixed_headers_demo.html +584 -0
- package/src/components/demos/9_table_column_drag_demo.html +325 -0
- package/src/components/demos/action/0_button_demo.html +2 -4
- package/src/components/demos/action/1_input_text_demo.html +643 -222
- package/src/components/demos/action/3_details_demo.html +146 -115
- package/src/components/demos/action/4_input_checkbox_demo.html +442 -322
- package/src/components/demos/action/5_input_checkbox_state_demo.html +270 -0
- package/src/components/demos/action/6_checkbox_list_demo.html +304 -72
- package/src/components/demos/action/7_radio_list_demo.html +310 -170
- package/src/components/demos/action/{8_editable_text_demo.html → 8_editable_demo.html} +65 -76
- package/src/components/demos/action/9_link_demo.html +84 -62
- package/src/components/demos/ui_transition/0_action_renderer_ui_transition_demo.html +695 -0
- package/src/components/demos/ui_transition/1_nested_ui_transition_demo.html +429 -0
- package/src/components/demos/ui_transition/2_height_transition_test.html +295 -0
- package/src/components/details/details.jsx +62 -64
- package/src/components/edition/editable.jsx +186 -0
- package/src/components/field/README.md +247 -0
- package/src/components/{input → field}/button.jsx +151 -130
- package/src/components/field/checkbox_list.jsx +184 -0
- package/src/components/{collect_form_element_values.js → field/collect_form_element_values.js} +7 -4
- package/src/components/{input → field}/field_css.js +4 -1
- package/src/components/field/form.jsx +211 -0
- package/src/components/{input → field}/input.jsx +1 -0
- package/src/components/{input → field}/input_checkbox.jsx +132 -155
- package/src/components/{input → field}/input_radio.jsx +135 -46
- package/src/components/field/input_textual.jsx +418 -0
- package/src/components/field/label.jsx +32 -0
- package/src/components/field/radio_list.jsx +182 -0
- package/src/components/{input → field}/select.jsx +17 -32
- package/src/components/field/use_action_events.js +132 -0
- package/src/components/field/use_form_events.js +55 -0
- package/src/components/field/use_ui_state_controller.js +506 -0
- package/src/components/item_tracker/README.md +461 -0
- package/src/components/item_tracker/use_isolated_item_tracker.jsx +209 -0
- package/src/components/item_tracker/use_isolated_item_tracker_demo.html +148 -0
- package/src/components/item_tracker/use_isolated_item_tracker_demo.jsx +460 -0
- package/src/components/item_tracker/use_item_tracker.jsx +143 -0
- package/src/components/item_tracker/use_item_tracker_demo.html +207 -0
- package/src/components/item_tracker/use_item_tracker_demo.jsx +216 -0
- package/src/components/keyboard_shortcuts/active_keyboard_shortcuts.jsx +87 -0
- package/src/components/keyboard_shortcuts/aria_key_shortcuts.js +61 -0
- package/src/components/keyboard_shortcuts/keyboard_key_meta.js +17 -0
- package/src/components/keyboard_shortcuts/keyboard_shortcuts.js +371 -0
- package/src/components/link/link.jsx +65 -102
- package/src/components/link/link_with_icon.jsx +52 -0
- package/src/components/loader/loader_background.jsx +85 -64
- package/src/components/loader/rectangle_loading.jsx +38 -19
- package/src/components/route.jsx +8 -4
- package/src/components/selection/selection.jsx +1583 -0
- package/src/components/svg/font_sized_svg.jsx +45 -0
- package/src/components/svg/icon_and_text.jsx +21 -0
- package/src/components/svg/svg_mask_overlay.jsx +105 -0
- package/src/components/table/drag/table_drag.jsx +506 -0
- package/src/components/table/resize/table_resize.jsx +650 -0
- package/src/components/table/resize/table_size.js +43 -0
- package/src/components/table/selection/table_selection.js +106 -0
- package/src/components/table/selection/table_selection.jsx +203 -0
- package/src/components/table/sticky/sticky_group.js +354 -0
- package/src/components/table/sticky/table_sticky.js +25 -0
- package/src/components/table/sticky/table_sticky.jsx +501 -0
- package/src/components/table/table.jsx +721 -0
- package/src/components/table/table_css.js +211 -0
- package/src/components/table/table_ui.jsx +49 -0
- package/src/components/table/use_cells_and_columns.js +90 -0
- package/src/components/table/use_object_array_to_cells.js +46 -0
- package/src/components/table/z_indexes.js +23 -0
- package/src/components/tablist/tablist.jsx +99 -0
- package/src/components/text/overflow.jsx +15 -0
- package/src/components/text/text_and_count.jsx +28 -0
- package/src/components/ui_transition.jsx +128 -0
- package/src/components/use_auto_focus.js +58 -7
- package/src/components/use_batch_during_render.js +33 -0
- package/src/components/use_debounce_true.js +7 -7
- package/src/components/use_dependencies_diff.js +35 -0
- package/src/components/use_focus_group.js +4 -3
- package/src/components/use_initial_value.js +8 -34
- package/src/components/use_signal_sync.js +1 -1
- package/src/components/use_stable_callback.js +68 -0
- package/src/components/use_state_array.js +16 -9
- package/src/docs/actions.md +22 -0
- package/src/notes.md +33 -12
- package/src/route/route.js +97 -47
- package/src/store/resource_graph.js +2 -1
- package/src/store/tests/{resource_graph_dependencies.test.js → resource_graph_dependencies.test_manual.js} +13 -13
- package/src/utils/is_signal.js +20 -0
- package/src/utils/stringify_for_display.js +4 -23
- package/src/validation/constraints/confirm_constraint.js +14 -0
- package/src/validation/constraints/create_unique_value_constraint.js +27 -0
- package/src/validation/constraints/native_constraints.js +313 -0
- package/src/validation/constraints/readonly_constraint.js +36 -0
- package/src/validation/constraints/single_space_constraint.js +13 -0
- package/src/validation/custom_constraint_validation.js +599 -0
- package/src/validation/custom_message.js +18 -0
- package/src/validation/demos/browser_style.png +0 -0
- package/src/validation/demos/form_validation_demo.html +142 -0
- package/src/validation/demos/form_validation_demo_preact.html +87 -0
- package/src/validation/demos/form_validation_native_popover_demo.html +168 -0
- package/src/validation/demos/form_validation_vs_native_demo.html +172 -0
- package/src/validation/demos/validation_message_demo.html +203 -0
- package/src/validation/hooks/use_constraints.js +23 -0
- package/src/validation/hooks/use_custom_validation_ref.js +73 -0
- package/src/validation/hooks/use_validation_message.js +19 -0
- package/src/validation/validation_message.js +741 -0
- package/src/components/editable_text/editable_text.jsx +0 -96
- package/src/components/form.jsx +0 -144
- package/src/components/input/checkbox_list.jsx +0 -294
- package/src/components/input/field.jsx +0 -61
- package/src/components/input/input_textual.jsx +0 -338
- package/src/components/input/radio_list.jsx +0 -283
- package/src/components/input/use_form_event.js +0 -20
- package/src/components/input/use_on_change.js +0 -12
- package/src/components/selection/selection.js +0 -5
- package/src/components/selection/selection_context.jsx +0 -262
- package/src/components/shortcut/shortcut_context.jsx +0 -390
- package/src/components/use_action_events.js +0 -37
- package/src/utils/iterable_weak_set.js +0 -62
- /package/src/components/demos/action/{11_nested_shortcuts_demo.html → 11_nested_shortcuts_demo.xhtml} +0 -0
- /package/src/components/{shortcut → keyboard_shortcuts}/os.js +0 -0
- /package/src/route/{route.test.html → route.xtest.html} +0 -0
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
import { canInterceptKeys } from "@jsenv/dom";
|
|
2
|
-
import { createContext } from "preact";
|
|
3
|
-
import { useContext, useLayoutEffect, useRef } from "preact/hooks";
|
|
4
|
-
|
|
5
|
-
const SelectionContext = createContext(null);
|
|
6
|
-
|
|
7
|
-
export const SelectionProvider = ({ value = [], onChange, children }) => {
|
|
8
|
-
const selection = value || [];
|
|
9
|
-
const registryRef = useRef([]); // Array<value>
|
|
10
|
-
const anchorRef = useRef(null);
|
|
11
|
-
|
|
12
|
-
const contextValue = {
|
|
13
|
-
selection,
|
|
14
|
-
|
|
15
|
-
register: (value) => {
|
|
16
|
-
const registry = registryRef.current;
|
|
17
|
-
const existingIndex = registry.indexOf(value);
|
|
18
|
-
if (existingIndex >= 0) {
|
|
19
|
-
console.warn(
|
|
20
|
-
`SelectionContext: Attempted to register an already registered value: ${value}. All values must be unique.`,
|
|
21
|
-
);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
registry.push(value);
|
|
25
|
-
},
|
|
26
|
-
unregister: (value) => {
|
|
27
|
-
const registry = registryRef.current;
|
|
28
|
-
const index = registry.indexOf(value);
|
|
29
|
-
if (index >= 0) {
|
|
30
|
-
registry.splice(index, 1);
|
|
31
|
-
}
|
|
32
|
-
},
|
|
33
|
-
setAnchor: (value) => {
|
|
34
|
-
anchorRef.current = value;
|
|
35
|
-
},
|
|
36
|
-
isSelected: (itemValue) => {
|
|
37
|
-
return selection.includes(itemValue);
|
|
38
|
-
},
|
|
39
|
-
getAllItems: () => {
|
|
40
|
-
return registryRef.current;
|
|
41
|
-
},
|
|
42
|
-
getRange: (fromValue, toValue) => {
|
|
43
|
-
const registry = registryRef.current;
|
|
44
|
-
|
|
45
|
-
// Find indices of fromValue and toValue
|
|
46
|
-
let fromIndex = -1;
|
|
47
|
-
let toIndex = -1;
|
|
48
|
-
let index = 0;
|
|
49
|
-
for (const valueCandidate of registry) {
|
|
50
|
-
if (valueCandidate === fromValue) {
|
|
51
|
-
fromIndex = index;
|
|
52
|
-
}
|
|
53
|
-
if (valueCandidate === toValue) {
|
|
54
|
-
toIndex = index;
|
|
55
|
-
}
|
|
56
|
-
index++;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (fromIndex >= 0 && toIndex >= 0) {
|
|
60
|
-
// Select all items between fromIndex and toIndex (inclusive)
|
|
61
|
-
const start = Math.min(fromIndex, toIndex);
|
|
62
|
-
const end = Math.max(fromIndex, toIndex);
|
|
63
|
-
const valueInRangeArray = registry.slice(start, end + 1);
|
|
64
|
-
return valueInRangeArray;
|
|
65
|
-
}
|
|
66
|
-
return [];
|
|
67
|
-
},
|
|
68
|
-
|
|
69
|
-
// basic methods to manipulate selection
|
|
70
|
-
set: (newSelection, event = null) => {
|
|
71
|
-
if (
|
|
72
|
-
newSelection.length === selection.length &&
|
|
73
|
-
newSelection.every((value, index) => value === selection[index])
|
|
74
|
-
) {
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
onChange?.(newSelection, event);
|
|
78
|
-
},
|
|
79
|
-
add: (arrayOfValueToAddToSelection, event = null) => {
|
|
80
|
-
const selectionWithValues = [];
|
|
81
|
-
for (const value of selection) {
|
|
82
|
-
selectionWithValues.push(value);
|
|
83
|
-
}
|
|
84
|
-
let modified = false;
|
|
85
|
-
for (const valueToAdd of arrayOfValueToAddToSelection) {
|
|
86
|
-
if (selectionWithValues.includes(valueToAdd)) {
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
modified = true;
|
|
90
|
-
selectionWithValues.push(valueToAdd);
|
|
91
|
-
}
|
|
92
|
-
if (modified) {
|
|
93
|
-
onChange?.(selectionWithValues, event);
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
remove: (arrayOfValueToRemoveFromSelection, event = null) => {
|
|
97
|
-
let modified = false;
|
|
98
|
-
const selectionWithoutValues = [];
|
|
99
|
-
for (const value of selection) {
|
|
100
|
-
if (arrayOfValueToRemoveFromSelection.includes(value)) {
|
|
101
|
-
modified = true;
|
|
102
|
-
// If we're removing the last selected value, clear it
|
|
103
|
-
if (value === anchorRef.current) {
|
|
104
|
-
anchorRef.current = null;
|
|
105
|
-
}
|
|
106
|
-
} else {
|
|
107
|
-
selectionWithoutValues.push(value);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (modified) {
|
|
112
|
-
onChange?.(selectionWithoutValues, event);
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
// Convenience method for multi-select: toggle, addFromLastSelectedTo
|
|
117
|
-
toggle: (value, event = null) => {
|
|
118
|
-
if (selection.includes(value)) {
|
|
119
|
-
contextValue.remove([value], event);
|
|
120
|
-
} else {
|
|
121
|
-
contextValue.add([value], event);
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
// Convenience method for shift-click: add range from last selected to target value
|
|
125
|
-
setFromAnchorTo: (value, event = null) => {
|
|
126
|
-
const anchorValue = anchorRef.current;
|
|
127
|
-
|
|
128
|
-
// Make sure the last selected value is still in the current selection
|
|
129
|
-
if (anchorValue && selection.includes(anchorValue)) {
|
|
130
|
-
const range = contextValue.getRange(anchorValue, value);
|
|
131
|
-
contextValue.set(range, event);
|
|
132
|
-
} else {
|
|
133
|
-
// No valid previous selection, just select this one
|
|
134
|
-
contextValue.set([value], event);
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
getValueAfter: (value) => {
|
|
139
|
-
const registry = registryRef.current;
|
|
140
|
-
const index = registry.indexOf(value);
|
|
141
|
-
if (index < 0 || index >= registry.length - 1) {
|
|
142
|
-
return null; // No next value
|
|
143
|
-
}
|
|
144
|
-
return registry[index + 1];
|
|
145
|
-
},
|
|
146
|
-
getValueBefore: (value) => {
|
|
147
|
-
const registry = registryRef.current;
|
|
148
|
-
const index = registry.indexOf(value);
|
|
149
|
-
if (index <= 0) {
|
|
150
|
-
return null; // No previous value
|
|
151
|
-
}
|
|
152
|
-
return registry[index - 1];
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
return (
|
|
157
|
-
<SelectionContext.Provider value={contextValue}>
|
|
158
|
-
{children}
|
|
159
|
-
</SelectionContext.Provider>
|
|
160
|
-
);
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
export const useSelectionContext = () => {
|
|
164
|
-
return useContext(SelectionContext);
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
export const useRegisterSelectionValue = (value) => {
|
|
168
|
-
const selectionContext = useSelectionContext();
|
|
169
|
-
|
|
170
|
-
useLayoutEffect(() => {
|
|
171
|
-
if (selectionContext) {
|
|
172
|
-
selectionContext.register(value);
|
|
173
|
-
return () => selectionContext.unregister(value);
|
|
174
|
-
}
|
|
175
|
-
return undefined;
|
|
176
|
-
}, [selectionContext, value]);
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
export const clickToSelect = (clickEvent, { selectionContext, value }) => {
|
|
180
|
-
if (clickEvent.defaultPrevented) {
|
|
181
|
-
// If the click was prevented by another handler, do not interfere
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const isMultiSelect = clickEvent.metaKey || clickEvent.ctrlKey;
|
|
186
|
-
const isShiftSelect = clickEvent.shiftKey;
|
|
187
|
-
const isSingleSelect = !isMultiSelect && !isShiftSelect;
|
|
188
|
-
|
|
189
|
-
if (isSingleSelect) {
|
|
190
|
-
// Single select - replace entire selection with just this item
|
|
191
|
-
selectionContext.set([value], clickEvent);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
if (isMultiSelect) {
|
|
195
|
-
// here no need to prevent nav on <a> but it means cmd + click will both multi select
|
|
196
|
-
// and open in a new tab
|
|
197
|
-
selectionContext.toggle(value, clickEvent);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
if (isShiftSelect) {
|
|
201
|
-
clickEvent.preventDefault(); // Prevent navigation
|
|
202
|
-
selectionContext.setFromAnchorTo(value, clickEvent);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
export const keydownToSelect = (keydownEvent, { selectionContext, value }) => {
|
|
208
|
-
if (!canInterceptKeys(keydownEvent)) {
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (keydownEvent.key === "Shift") {
|
|
213
|
-
selectionContext.setAnchor(value);
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const isMultiSelect = keydownEvent.metaKey || keydownEvent.ctrlKey;
|
|
218
|
-
const isShiftSelect = keydownEvent.shiftKey;
|
|
219
|
-
const { key } = keydownEvent;
|
|
220
|
-
if (key === "a") {
|
|
221
|
-
if (!isMultiSelect) {
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
keydownEvent.preventDefault(); // prevent default select all text behavior
|
|
225
|
-
selectionContext.set(selectionContext.getAllItems(), keydownEvent);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (key === "ArrowDown") {
|
|
229
|
-
const nextValue = selectionContext.getValueAfter(value);
|
|
230
|
-
if (!nextValue) {
|
|
231
|
-
return; // No next value to select
|
|
232
|
-
}
|
|
233
|
-
keydownEvent.preventDefault(); // Prevent default scrolling behavior
|
|
234
|
-
if (isShiftSelect) {
|
|
235
|
-
selectionContext.setFromAnchorTo(nextValue, keydownEvent);
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (isMultiSelect) {
|
|
239
|
-
selectionContext.add([nextValue], keydownEvent);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
selectionContext.set([nextValue], keydownEvent);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
if (key === "ArrowUp") {
|
|
246
|
-
const previousValue = selectionContext.getValueBefore(value);
|
|
247
|
-
if (!previousValue) {
|
|
248
|
-
return; // No previous value to select
|
|
249
|
-
}
|
|
250
|
-
keydownEvent.preventDefault(); // Prevent default scrolling behavior
|
|
251
|
-
if (isShiftSelect) {
|
|
252
|
-
selectionContext.setFromAnchorTo(previousValue, keydownEvent);
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
if (isMultiSelect) {
|
|
256
|
-
selectionContext.add([previousValue], keydownEvent);
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
selectionContext.set([previousValue], keydownEvent);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
};
|
|
@@ -1,390 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
};
|