@jsenv/navi 0.0.1 → 0.1.0

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 (138) hide show
  1. package/dist/jsenv_navi.js +22954 -0
  2. package/index.js +66 -16
  3. package/package.json +22 -11
  4. package/src/actions.js +50 -26
  5. package/src/browser_integration/browser_integration.js +31 -6
  6. package/src/browser_integration/via_history.js +42 -9
  7. package/src/components/action_execution/render_actionable_component.jsx +6 -4
  8. package/src/components/action_execution/use_action.js +51 -282
  9. package/src/components/action_execution/use_execute_action.js +106 -92
  10. package/src/components/action_execution/use_run_on_mount.js +9 -0
  11. package/src/components/action_renderer.jsx +21 -32
  12. package/src/components/demos/0_button_demo.html +574 -103
  13. package/src/components/demos/10_column_reordering_debug.html +277 -0
  14. package/src/components/demos/11_table_selection_debug.html +432 -0
  15. package/src/components/demos/1_checkbox_demo.html +579 -202
  16. package/src/components/demos/2_input_textual_demo.html +81 -138
  17. package/src/components/demos/3_radio_demo.html +0 -2
  18. package/src/components/demos/4_select_demo.html +19 -23
  19. package/src/components/demos/6_tablist_demo.html +77 -0
  20. package/src/components/demos/7_table_selection_demo.html +176 -0
  21. package/src/components/demos/8_table_fixed_headers_demo.html +584 -0
  22. package/src/components/demos/9_table_column_drag_demo.html +325 -0
  23. package/src/components/demos/action/0_button_demo.html +2 -4
  24. package/src/components/demos/action/1_input_text_demo.html +643 -222
  25. package/src/components/demos/action/3_details_demo.html +146 -115
  26. package/src/components/demos/action/4_input_checkbox_demo.html +442 -322
  27. package/src/components/demos/action/5_input_checkbox_state_demo.html +270 -0
  28. package/src/components/demos/action/6_checkbox_list_demo.html +304 -72
  29. package/src/components/demos/action/7_radio_list_demo.html +310 -170
  30. package/src/components/demos/action/{8_editable_text_demo.html → 8_editable_demo.html} +65 -76
  31. package/src/components/demos/action/9_link_demo.html +84 -62
  32. package/src/components/demos/ui_transition/0_action_renderer_ui_transition_demo.html +695 -0
  33. package/src/components/demos/ui_transition/1_nested_ui_transition_demo.html +429 -0
  34. package/src/components/demos/ui_transition/2_height_transition_test.html +295 -0
  35. package/src/components/details/details.jsx +62 -64
  36. package/src/components/edition/editable.jsx +186 -0
  37. package/src/components/field/README.md +247 -0
  38. package/src/components/{input → field}/button.jsx +151 -130
  39. package/src/components/field/checkbox_list.jsx +184 -0
  40. package/src/components/{collect_form_element_values.js → field/collect_form_element_values.js} +7 -4
  41. package/src/components/{input → field}/field_css.js +4 -1
  42. package/src/components/field/form.jsx +211 -0
  43. package/src/components/{input → field}/input.jsx +1 -0
  44. package/src/components/{input → field}/input_checkbox.jsx +132 -155
  45. package/src/components/{input → field}/input_radio.jsx +135 -46
  46. package/src/components/{input → field}/input_textual.jsx +247 -173
  47. package/src/components/field/label.jsx +32 -0
  48. package/src/components/field/radio_list.jsx +182 -0
  49. package/src/components/{input → field}/select.jsx +17 -32
  50. package/src/components/field/use_action_events.js +132 -0
  51. package/src/components/field/use_form_events.js +55 -0
  52. package/src/components/field/use_ui_state_controller.js +506 -0
  53. package/src/components/item_tracker/README.md +461 -0
  54. package/src/components/item_tracker/use_isolated_item_tracker.jsx +209 -0
  55. package/src/components/item_tracker/use_isolated_item_tracker_demo.html +148 -0
  56. package/src/components/item_tracker/use_isolated_item_tracker_demo.jsx +460 -0
  57. package/src/components/item_tracker/use_item_tracker.jsx +143 -0
  58. package/src/components/item_tracker/use_item_tracker_demo.html +207 -0
  59. package/src/components/item_tracker/use_item_tracker_demo.jsx +216 -0
  60. package/src/components/keyboard_shortcuts/active_keyboard_shortcuts.jsx +87 -0
  61. package/src/components/keyboard_shortcuts/aria_key_shortcuts.js +61 -0
  62. package/src/components/keyboard_shortcuts/keyboard_key_meta.js +17 -0
  63. package/src/components/keyboard_shortcuts/keyboard_shortcuts.js +371 -0
  64. package/src/components/link/link.jsx +65 -102
  65. package/src/components/link/link_with_icon.jsx +52 -0
  66. package/src/components/loader/loader_background.jsx +85 -64
  67. package/src/components/loader/rectangle_loading.jsx +38 -19
  68. package/src/components/route.jsx +8 -4
  69. package/src/components/selection/selection.jsx +1583 -0
  70. package/src/components/svg/font_sized_svg.jsx +45 -0
  71. package/src/components/svg/icon_and_text.jsx +21 -0
  72. package/src/components/svg/svg_mask_overlay.jsx +105 -0
  73. package/src/components/table/drag/table_drag.jsx +506 -0
  74. package/src/components/table/resize/table_resize.jsx +650 -0
  75. package/src/components/table/resize/table_size.js +43 -0
  76. package/src/components/table/selection/table_selection.js +106 -0
  77. package/src/components/table/selection/table_selection.jsx +203 -0
  78. package/src/components/table/sticky/sticky_group.js +354 -0
  79. package/src/components/table/sticky/table_sticky.js +25 -0
  80. package/src/components/table/sticky/table_sticky.jsx +501 -0
  81. package/src/components/table/table.jsx +721 -0
  82. package/src/components/table/table_css.js +211 -0
  83. package/src/components/table/table_ui.jsx +49 -0
  84. package/src/components/table/use_cells_and_columns.js +90 -0
  85. package/src/components/table/use_object_array_to_cells.js +46 -0
  86. package/src/components/table/z_indexes.js +23 -0
  87. package/src/components/tablist/tablist.jsx +99 -0
  88. package/src/components/text/overflow.jsx +15 -0
  89. package/src/components/text/text_and_count.jsx +28 -0
  90. package/src/components/ui_transition.jsx +128 -0
  91. package/src/components/use_auto_focus.js +58 -7
  92. package/src/components/use_batch_during_render.js +33 -0
  93. package/src/components/use_debounce_true.js +7 -7
  94. package/src/components/use_dependencies_diff.js +35 -0
  95. package/src/components/use_focus_group.js +4 -3
  96. package/src/components/use_initial_value.js +8 -34
  97. package/src/components/use_signal_sync.js +1 -1
  98. package/src/components/use_stable_callback.js +68 -0
  99. package/src/components/use_state_array.js +16 -9
  100. package/src/docs/actions.md +22 -0
  101. package/src/notes.md +33 -12
  102. package/src/route/route.js +97 -47
  103. package/src/store/resource_graph.js +2 -1
  104. package/src/store/tests/{resource_graph_dependencies.test.js → resource_graph_dependencies.test_manual.js} +13 -13
  105. package/src/utils/is_signal.js +20 -0
  106. package/src/utils/stringify_for_display.js +4 -23
  107. package/src/validation/constraints/confirm_constraint.js +14 -0
  108. package/src/validation/constraints/create_unique_value_constraint.js +27 -0
  109. package/src/validation/constraints/native_constraints.js +313 -0
  110. package/src/validation/constraints/readonly_constraint.js +36 -0
  111. package/src/validation/constraints/single_space_constraint.js +13 -0
  112. package/src/validation/custom_constraint_validation.js +599 -0
  113. package/src/validation/custom_message.js +18 -0
  114. package/src/validation/demos/browser_style.png +0 -0
  115. package/src/validation/demos/form_validation_demo.html +142 -0
  116. package/src/validation/demos/form_validation_demo_preact.html +87 -0
  117. package/src/validation/demos/form_validation_native_popover_demo.html +168 -0
  118. package/src/validation/demos/form_validation_vs_native_demo.html +172 -0
  119. package/src/validation/demos/validation_message_demo.html +203 -0
  120. package/src/validation/hooks/use_constraints.js +23 -0
  121. package/src/validation/hooks/use_custom_validation_ref.js +73 -0
  122. package/src/validation/hooks/use_validation_message.js +19 -0
  123. package/src/validation/validation_message.js +741 -0
  124. package/src/components/editable_text/editable_text.jsx +0 -96
  125. package/src/components/form.jsx +0 -144
  126. package/src/components/input/checkbox_list.jsx +0 -294
  127. package/src/components/input/field.jsx +0 -61
  128. package/src/components/input/radio_list.jsx +0 -283
  129. package/src/components/input/use_form_event.js +0 -20
  130. package/src/components/input/use_on_change.js +0 -12
  131. package/src/components/selection/selection.js +0 -5
  132. package/src/components/selection/selection_context.jsx +0 -262
  133. package/src/components/shortcut/shortcut_context.jsx +0 -390
  134. package/src/components/use_action_events.js +0 -37
  135. package/src/utils/iterable_weak_set.js +0 -62
  136. /package/src/components/demos/action/{11_nested_shortcuts_demo.html → 11_nested_shortcuts_demo.xhtml} +0 -0
  137. /package/src/components/{shortcut → keyboard_shortcuts}/os.js +0 -0
  138. /package/src/route/{route.test.html → route.xtest.html} +0 -0
@@ -0,0 +1,17 @@
1
+ import { isMac } from "./os.js";
2
+
3
+ // Maps canonical browser key names to their user-friendly aliases.
4
+ // Used for both event matching and ARIA normalization.
5
+ export const keyMapping = {
6
+ " ": { alias: ["space"] },
7
+ "escape": { alias: ["esc"] },
8
+ "arrowup": { alias: ["up"] },
9
+ "arrowdown": { alias: ["down"] },
10
+ "arrowleft": { alias: ["left"] },
11
+ "arrowright": { alias: ["right"] },
12
+ "delete": { alias: ["del"] },
13
+ // Platform-specific mappings
14
+ ...(isMac
15
+ ? { delete: { alias: ["backspace"] } }
16
+ : { backspace: { alias: ["delete"] } }),
17
+ };
@@ -0,0 +1,371 @@
1
+ import { activeElementSignal, canInterceptKeys } from "@jsenv/dom";
2
+ import { effect, signal } from "@preact/signals";
3
+ import { useEffect, useRef } from "preact/hooks";
4
+
5
+ import { requestAction } from "../../validation/custom_constraint_validation.js";
6
+ import { useAction } from "../action_execution/use_action.js";
7
+ import { useExecuteAction } from "../action_execution/use_execute_action.js";
8
+ import { useActionEvents } from "../field/use_action_events.js";
9
+ import { keyMapping } from "./keyboard_key_meta.js";
10
+ import { isMac } from "./os.js";
11
+
12
+ export const activeShortcutsSignal = signal([]);
13
+ const shortcutsMap = new Map();
14
+
15
+ const areShortcutsEqual = (shortcutA, shortcutB) => {
16
+ return (
17
+ shortcutA.key === shortcutB.key &&
18
+ shortcutA.description === shortcutB.description &&
19
+ shortcutA.enabled === shortcutB.enabled
20
+ );
21
+ };
22
+
23
+ const areShortcutArraysEqual = (arrayA, arrayB) => {
24
+ if (arrayA.length !== arrayB.length) {
25
+ return false;
26
+ }
27
+
28
+ for (let i = 0; i < arrayA.length; i++) {
29
+ if (!areShortcutsEqual(arrayA[i], arrayB[i])) {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ return true;
35
+ };
36
+
37
+ const updateActiveShortcuts = () => {
38
+ const activeElement = activeElementSignal.peek();
39
+ const currentActiveShortcuts = activeShortcutsSignal.peek();
40
+ const activeShortcuts = [];
41
+ for (const [element, { shortcuts }] of shortcutsMap) {
42
+ if (element === activeElement || element.contains(activeElement)) {
43
+ activeShortcuts.push(...shortcuts);
44
+ }
45
+ }
46
+
47
+ // Only update if shortcuts have actually changed
48
+ if (!areShortcutArraysEqual(currentActiveShortcuts, activeShortcuts)) {
49
+ activeShortcutsSignal.value = activeShortcuts;
50
+ }
51
+ };
52
+ effect(() => {
53
+ // eslint-disable-next-line no-unused-expressions
54
+ activeElementSignal.value;
55
+ updateActiveShortcuts();
56
+ });
57
+ const addShortcuts = (element, shortcuts) => {
58
+ shortcutsMap.set(element, { shortcuts });
59
+ updateActiveShortcuts();
60
+ };
61
+ const removeShortcuts = (element) => {
62
+ shortcutsMap.delete(element);
63
+ updateActiveShortcuts();
64
+ };
65
+
66
+ export const useKeyboardShortcuts = (
67
+ elementRef,
68
+ shortcuts,
69
+ {
70
+ onActionPrevented,
71
+ onActionStart,
72
+ onActionAbort,
73
+ onActionError,
74
+ onActionEnd,
75
+ allowConcurrentActions,
76
+ } = {},
77
+ ) => {
78
+ if (!elementRef) {
79
+ throw new Error(
80
+ "useKeyboardShortcuts requires an elementRef to attach shortcuts to.",
81
+ );
82
+ }
83
+
84
+ const executeAction = useExecuteAction(elementRef);
85
+ const shortcutActionIsBusyRef = useRef(false);
86
+ useActionEvents(elementRef, {
87
+ actionOrigin: "keyboard_shortcut",
88
+ onPrevented: onActionPrevented,
89
+ onAction: (actionEvent) => {
90
+ const { shortcut } = actionEvent.detail.meta || {};
91
+ if (!shortcut) {
92
+ // not a shortcut (an other interaction triggered the action, don't request it again)
93
+ return;
94
+ }
95
+ // action can be a function or an action object, whem a function we must "wrap" it in a function returning that function
96
+ // otherwise setState would call that action immediately
97
+ // setAction(() => actionEvent.detail.action);
98
+ executeAction(actionEvent, {
99
+ requester: document.activeElement,
100
+ });
101
+ },
102
+ onStart: (e) => {
103
+ const { shortcut } = e.detail.meta || {};
104
+ if (!shortcut) {
105
+ return;
106
+ }
107
+ if (!allowConcurrentActions) {
108
+ shortcutActionIsBusyRef.current = true;
109
+ }
110
+ shortcut.onStart?.(e);
111
+ onActionStart?.(e);
112
+ },
113
+ onAbort: (e) => {
114
+ const { shortcut } = e.detail.meta || {};
115
+ if (!shortcut) {
116
+ return;
117
+ }
118
+ shortcutActionIsBusyRef.current = false;
119
+ shortcut.onAbort?.(e);
120
+ onActionAbort?.(e);
121
+ },
122
+ onError: (error, e) => {
123
+ const { shortcut } = e.detail.meta || {};
124
+ if (!shortcut) {
125
+ return;
126
+ }
127
+ shortcutActionIsBusyRef.current = false;
128
+ shortcut.onError?.(error, e);
129
+ onActionError?.(error, e);
130
+ },
131
+ onEnd: (e) => {
132
+ const { shortcut } = e.detail.meta || {};
133
+ if (!shortcut) {
134
+ return;
135
+ }
136
+ shortcutActionIsBusyRef.current = false;
137
+ shortcut.onEnd?.(e);
138
+ onActionEnd?.(e);
139
+ },
140
+ });
141
+
142
+ const shortcutDeps = [];
143
+ for (const shortcut of shortcuts) {
144
+ shortcutDeps.push(
145
+ shortcut.key,
146
+ shortcut.description,
147
+ shortcut.enabled,
148
+ shortcut.confirmMessage,
149
+ );
150
+ shortcut.action = useAction(shortcut.action);
151
+ }
152
+
153
+ useEffect(() => {
154
+ const element = elementRef.current;
155
+ const shortcutsCopy = [];
156
+ for (const shortcutCandidate of shortcuts) {
157
+ shortcutsCopy.push({
158
+ ...shortcutCandidate,
159
+ handler: (keyboardEvent) => {
160
+ if (shortcutCandidate.handler) {
161
+ return shortcutCandidate.handler(keyboardEvent);
162
+ }
163
+ if (shortcutActionIsBusyRef.current) {
164
+ return false;
165
+ }
166
+ const { action } = shortcutCandidate;
167
+ return requestAction(element, action, {
168
+ event: keyboardEvent,
169
+ requester: document.activeElement,
170
+ confirmMessage: shortcutCandidate.confirmMessage,
171
+ actionOrigin: "keyboard_shortcut",
172
+ meta: {
173
+ shortcut: shortcutCandidate,
174
+ },
175
+ });
176
+ },
177
+ });
178
+ }
179
+
180
+ addShortcuts(element, shortcuts);
181
+
182
+ const onKeydown = (event) => {
183
+ applyKeyboardShortcuts(shortcutsCopy, event);
184
+ };
185
+ element.addEventListener("keydown", onKeydown);
186
+ return () => {
187
+ element.removeEventListener("keydown", onKeydown);
188
+ removeShortcuts(element);
189
+ };
190
+ }, [shortcutDeps]);
191
+ };
192
+
193
+ const applyKeyboardShortcuts = (shortcuts, keyboardEvent) => {
194
+ if (!canInterceptKeys(keyboardEvent)) {
195
+ return null;
196
+ }
197
+ for (const shortcutCandidate of shortcuts) {
198
+ let { enabled = true, key } = shortcutCandidate;
199
+ if (!enabled) {
200
+ continue;
201
+ }
202
+
203
+ if (typeof key === "function") {
204
+ const keyReturnValue = key(keyboardEvent);
205
+ if (!keyReturnValue) {
206
+ continue;
207
+ }
208
+ key = keyReturnValue;
209
+ }
210
+ if (!key) {
211
+ console.error(shortcutCandidate);
212
+ throw new TypeError(`key is required in keyboard shortcut, got ${key}`);
213
+ }
214
+
215
+ // Handle platform-specific combination objects
216
+ let actualCombination;
217
+ let crossPlatformCombination;
218
+ if (typeof key === "object" && key !== null) {
219
+ actualCombination = isMac ? key.mac : key.other;
220
+ } else {
221
+ actualCombination = key;
222
+ if (containsPlatformSpecificKeys(key)) {
223
+ crossPlatformCombination = generateCrossPlatformCombination(key);
224
+ }
225
+ }
226
+
227
+ // Check both the actual combination and cross-platform combination
228
+ const matchesActual =
229
+ actualCombination &&
230
+ keyboardEventIsMatchingKeyCombination(keyboardEvent, actualCombination);
231
+ const matchesCrossPlatform =
232
+ crossPlatformCombination &&
233
+ crossPlatformCombination !== actualCombination &&
234
+ keyboardEventIsMatchingKeyCombination(
235
+ keyboardEvent,
236
+ crossPlatformCombination,
237
+ );
238
+
239
+ if (!matchesActual && !matchesCrossPlatform) {
240
+ continue;
241
+ }
242
+ if (typeof enabled === "function" && !enabled(keyboardEvent)) {
243
+ continue;
244
+ }
245
+ const returnValue = shortcutCandidate.handler(keyboardEvent);
246
+ if (returnValue) {
247
+ keyboardEvent.preventDefault();
248
+ }
249
+ return shortcutCandidate;
250
+ }
251
+ return null;
252
+ };
253
+ const containsPlatformSpecificKeys = (combination) => {
254
+ const lowerCombination = combination.toLowerCase();
255
+ const macSpecificKeys = ["command", "cmd"];
256
+
257
+ return macSpecificKeys.some((key) => lowerCombination.includes(key));
258
+ };
259
+ const generateCrossPlatformCombination = (combination) => {
260
+ let crossPlatform = combination;
261
+
262
+ if (isMac) {
263
+ // No need to convert anything TO Windows/Linux-specific format since we're on Mac
264
+ return null;
265
+ }
266
+ // If not on Mac but combination contains Mac-specific keys, generate Windows equivalent
267
+ crossPlatform = crossPlatform.replace(/\bcommand\b/gi, "control");
268
+ crossPlatform = crossPlatform.replace(/\bcmd\b/gi, "control");
269
+
270
+ return crossPlatform;
271
+ };
272
+ const keyboardEventIsMatchingKeyCombination = (event, keyCombination) => {
273
+ const keys = keyCombination.toLowerCase().split("+");
274
+
275
+ for (const key of keys) {
276
+ let modifierFound = false;
277
+
278
+ // Check if this key is a modifier
279
+ for (const [eventProperty, config] of Object.entries(modifierKeyMapping)) {
280
+ const allNames = [...config.names];
281
+
282
+ // Add Mac-specific names only if we're on Mac and they exist
283
+ if (isMac && config.macNames) {
284
+ allNames.push(...config.macNames);
285
+ }
286
+
287
+ if (allNames.includes(key)) {
288
+ // Check if the corresponding event property is pressed
289
+ if (!event[eventProperty]) {
290
+ return false;
291
+ }
292
+ modifierFound = true;
293
+ break;
294
+ }
295
+ }
296
+ if (modifierFound) {
297
+ continue;
298
+ }
299
+
300
+ // Check if it's a range pattern like "a-z" or "0-9"
301
+ if (key.includes("-") && key.length === 3) {
302
+ const [startChar, dash, endChar] = key;
303
+ if (dash === "-") {
304
+ // Only check ranges for single alphanumeric characters
305
+ const eventKey = event.key.toLowerCase();
306
+ if (eventKey.length !== 1) {
307
+ return false; // Not a single character key
308
+ }
309
+
310
+ // Only allow a-z and 0-9 ranges
311
+ const isValidRange =
312
+ (startChar >= "a" && endChar <= "z") ||
313
+ (startChar >= "0" && endChar <= "9");
314
+
315
+ if (!isValidRange) {
316
+ return false; // Invalid range pattern
317
+ }
318
+
319
+ const eventKeyCode = eventKey.charCodeAt(0);
320
+ const startCode = startChar.charCodeAt(0);
321
+ const endCode = endChar.charCodeAt(0);
322
+
323
+ if (eventKeyCode >= startCode && eventKeyCode <= endCode) {
324
+ continue; // Range matched
325
+ }
326
+ return false; // Range not matched
327
+ }
328
+ }
329
+
330
+ // If it's not a modifier or range, check if it matches the actual key
331
+ if (!isSameKey(event.key, key)) {
332
+ return false;
333
+ }
334
+ }
335
+ return true;
336
+ };
337
+ // Configuration for mapping shortcut key names to browser event properties
338
+ const modifierKeyMapping = {
339
+ metaKey: {
340
+ names: ["meta"],
341
+ macNames: ["command", "cmd"],
342
+ },
343
+ ctrlKey: {
344
+ names: ["control", "ctrl"],
345
+ },
346
+ shiftKey: {
347
+ names: ["shift"],
348
+ },
349
+ altKey: {
350
+ names: ["alt"],
351
+ macNames: ["option"],
352
+ },
353
+ };
354
+ const isSameKey = (browserEventKey, key) => {
355
+ browserEventKey = browserEventKey.toLowerCase();
356
+ key = key.toLowerCase();
357
+
358
+ if (browserEventKey === key) {
359
+ return true;
360
+ }
361
+
362
+ // Check if either key is an alias for the other
363
+ for (const [canonicalKey, config] of Object.entries(keyMapping)) {
364
+ const allKeys = [canonicalKey, ...config.alias];
365
+ if (allKeys.includes(browserEventKey) && allKeys.includes(key)) {
366
+ return true;
367
+ }
368
+ }
369
+
370
+ return false;
371
+ };
@@ -1,20 +1,22 @@
1
- import { closeValidationMessage, useConstraints } from "@jsenv/validation";
2
1
  import { forwardRef } from "preact/compat";
3
- import { useImperativeHandle, useLayoutEffect, useRef } from "preact/hooks";
2
+ import {
3
+ useContext,
4
+ useImperativeHandle,
5
+ useLayoutEffect,
6
+ useRef,
7
+ } from "preact/hooks";
8
+
4
9
  import { useIsVisited } from "../../browser_integration/use_is_visited.js";
5
- import { useActionStatus } from "../../use_action_status.js";
10
+ import { closeValidationMessage } from "../../validation/custom_constraint_validation.js";
11
+ import { useConstraints } from "../../validation/hooks/use_constraints.js";
6
12
  import { renderActionableComponent } from "../action_execution/render_actionable_component.jsx";
7
- import { LoaderBackground } from "../loader/loader_background.jsx";
13
+ import { useRequestedActionStatus } from "../field/use_action_events.js";
14
+ import { useKeyboardShortcuts } from "../keyboard_shortcuts/keyboard_shortcuts.js";
15
+ import { LoadableInlineElement } from "../loader/loader_background.jsx";
8
16
  import {
9
- clickToSelect,
10
- keydownToSelect,
11
- useRegisterSelectionValue,
12
- useSelectionContext,
13
- } from "../selection/selection_context.jsx";
14
- import {
15
- ShortcutProvider,
16
- useShortcutContext,
17
- } from "../shortcut/shortcut_context.jsx";
17
+ SelectionContext,
18
+ useSelectableElement,
19
+ } from "../selection/selection.jsx";
18
20
  import { useAutoFocus } from "../use_auto_focus.js";
19
21
 
20
22
  /*
@@ -45,17 +47,17 @@ import.meta.css = /* css */ `
45
47
  pointer-events: none;
46
48
  }
47
49
 
48
- .navi_link[data-with-selection] {
50
+ .navi_link[aria-selected] {
49
51
  position: relative;
50
52
  }
51
53
 
52
- .navi_link[data-with-selection] input[type="checkbox"] {
54
+ .navi_link[aria-selected] input[type="checkbox"] {
53
55
  position: absolute;
54
56
  opacity: 0;
55
57
  }
56
58
 
57
59
  /* Visual feedback for selected state */
58
- .navi_link[data-selected] {
60
+ .navi_link[aria-selected="true"] {
59
61
  background-color: light-dark(#bbdefb, #2563eb);
60
62
  }
61
63
 
@@ -66,6 +68,10 @@ import.meta.css = /* css */ `
66
68
  .navi_link[data-visited] {
67
69
  color: light-dark(#6a1b9a, #ab47bc);
68
70
  }
71
+
72
+ .navi_link[data-no-text-decoration] {
73
+ text-decoration: none;
74
+ }
69
75
  `;
70
76
 
71
77
  export const Link = forwardRef((props, ref) => {
@@ -76,20 +82,12 @@ export const Link = forwardRef((props, ref) => {
76
82
  });
77
83
 
78
84
  const LinkBasic = forwardRef((props, ref) => {
79
- const selectionContext = useSelectionContext();
80
-
85
+ const selectionContext = useContext(SelectionContext);
81
86
  if (selectionContext) {
82
- return (
83
- <LinkWithSelection
84
- ref={ref}
85
- selectionContext={selectionContext}
86
- {...props}
87
- />
88
- );
87
+ return <LinkWithSelection ref={ref} {...props} />;
89
88
  }
90
89
  return <LinkPlain ref={ref} {...props} />;
91
90
  });
92
-
93
91
  const LinkPlain = forwardRef((props, ref) => {
94
92
  const {
95
93
  className = "",
@@ -107,24 +105,24 @@ const LinkPlain = forwardRef((props, ref) => {
107
105
  href,
108
106
  ...rest
109
107
  } = props;
110
-
111
108
  const innerRef = useRef();
112
109
  useImperativeHandle(ref, () => innerRef.current);
110
+ const isVisited = useIsVisited(href);
113
111
 
114
112
  useAutoFocus(innerRef, autoFocus);
115
113
  useConstraints(innerRef, constraints);
116
-
117
114
  const shouldDimColor = readOnly || disabled;
118
115
  useDimColorWhen(innerRef, shouldDimColor);
119
116
 
120
- const isVisited = useIsVisited(href);
121
-
122
117
  return (
123
- <LoaderBackground loading={loading} color="light-dark(#355fcc, #3b82f6)">
118
+ <LoadableInlineElement
119
+ loading={loading}
120
+ color="light-dark(#355fcc, #3b82f6)"
121
+ >
124
122
  <a
125
123
  {...rest}
126
- href={href}
127
124
  ref={innerRef}
125
+ href={href}
128
126
  className={["navi_link", ...className.split(" ")].join(" ")}
129
127
  aria-busy={loading}
130
128
  inert={disabled}
@@ -152,48 +150,26 @@ const LinkPlain = forwardRef((props, ref) => {
152
150
  >
153
151
  {children}
154
152
  </a>
155
- </LoaderBackground>
153
+ </LoadableInlineElement>
156
154
  );
157
155
  });
158
-
159
156
  const LinkWithSelection = forwardRef((props, ref) => {
160
- const {
161
- selectionContext,
162
- name,
163
- value,
164
- children,
165
- onClick,
166
- onKeyDown,
167
- ...rest
168
- } = props;
169
- const isSelected = selectionContext.isSelected(value);
170
- useRegisterSelectionValue(value);
157
+ const { selection, selectionController } = useContext(SelectionContext);
158
+ const { value = props.href, children, ...rest } = props;
159
+ const innerRef = useRef();
160
+ useImperativeHandle(ref, () => innerRef.current);
161
+ const { selected } = useSelectableElement(innerRef, {
162
+ selection,
163
+ selectionController,
164
+ });
171
165
 
172
166
  return (
173
167
  <LinkPlain
174
- ref={ref}
175
168
  {...rest}
176
- onClick={(e) => {
177
- clickToSelect(e, { selectionContext, value });
178
- onClick?.(e);
179
- }}
180
- onKeyDown={(e) => {
181
- keydownToSelect(e, { selectionContext, value });
182
- onKeyDown?.(e);
183
- }}
184
- data-with-selection=""
185
- data-selected={isSelected ? "" : undefined}
169
+ ref={innerRef}
170
+ data-value={value}
171
+ aria-selected={selected}
186
172
  >
187
- <input
188
- className="navi_link_checkbox"
189
- type="checkbox"
190
- name={name}
191
- value={value}
192
- checked={isSelected}
193
- // Prevent direct checkbox interaction - only via link clicks or keyboard nav (arrows)
194
- disabled
195
- tabIndex={-1} // Don't interfere with link tab order (might be overkill because there is already [disabled])
196
- />
197
173
  {children}
198
174
  </LinkPlain>
199
175
  );
@@ -239,53 +215,40 @@ const useDimColorWhen = (elementRef, shouldDim) => {
239
215
  const LinkWithAction = forwardRef((props, ref) => {
240
216
  const {
241
217
  shortcuts = [],
218
+ readOnly,
242
219
  onActionPrevented,
243
220
  onActionStart,
244
221
  onActionAbort,
245
222
  onActionError,
246
223
  onActionEnd,
224
+ children,
225
+ loading,
247
226
  ...rest
248
227
  } = props;
249
228
  const innerRef = useRef();
250
229
  useImperativeHandle(ref, () => innerRef.current);
230
+ const { actionPending } = useRequestedActionStatus(innerRef);
231
+ const innerLoading = Boolean(loading || actionPending);
251
232
 
252
- return (
253
- <ShortcutProvider
254
- shortcuts={shortcuts}
255
- elementRef={innerRef}
256
- onActionPrevented={onActionPrevented}
257
- onActionStart={onActionStart}
258
- onActionAbort={onActionAbort}
259
- onActionError={onActionError}
260
- onActionEnd={onActionEnd}
261
- >
262
- <LinkWithShortcuts ref={innerRef} {...rest} />
263
- </ShortcutProvider>
264
- );
265
- });
266
-
267
- const LinkWithShortcuts = forwardRef((props, ref) => {
268
- const { children, readOnly, loading, ...rest } = props;
269
- const innerRef = useRef();
270
- useImperativeHandle(ref, () => innerRef.current);
271
- const { shortcutAction } = useShortcutContext();
272
-
273
- const { loading: actionLoading } = useActionStatus(shortcutAction);
274
- const innerLoading = Boolean(loading || actionLoading);
233
+ useKeyboardShortcuts(innerRef, shortcuts, {
234
+ onActionPrevented,
235
+ onActionStart,
236
+ onActionAbort,
237
+ onActionError,
238
+ onActionEnd,
239
+ });
275
240
 
276
241
  return (
277
- <>
278
- <LinkBasic
279
- ref={innerRef}
280
- {...rest}
281
- loading={innerLoading}
282
- readOnly={readOnly || actionLoading}
283
- data-readonly-silent={actionLoading && !readOnly ? "" : undefined}
284
- /* When we have keyboard shortcuts the link outline is visible on focus (not solely on focus-visible) */
285
- data-focus-visible=""
286
- >
287
- {children}
288
- </LinkBasic>
289
- </>
242
+ <LinkBasic
243
+ {...rest}
244
+ ref={innerRef}
245
+ loading={innerLoading}
246
+ readOnly={readOnly || actionPending}
247
+ data-readonly-silent={actionPending && !readOnly ? "" : undefined}
248
+ /* When we have keyboard shortcuts the link outline is visible on focus (not solely on focus-visible) */
249
+ data-focus-visible=""
250
+ >
251
+ {children}
252
+ </LinkBasic>
290
253
  );
291
254
  });
@@ -0,0 +1,52 @@
1
+ import { FontSizedSvg } from "../svg/font_sized_svg.jsx";
2
+ import { Link } from "./link.jsx";
3
+
4
+ import.meta.css = /* css */ `
5
+ .link_with_icon {
6
+ white-space: nowrap;
7
+ align-items: center;
8
+ gap: 0.3em;
9
+ min-width: 0;
10
+ display: inline-flex;
11
+ flex-grow: 1;
12
+ }
13
+ `;
14
+
15
+ export const LinkWithIcon = ({
16
+ icon,
17
+ isCurrent,
18
+ children,
19
+ className = "",
20
+ ...rest
21
+ }) => {
22
+ return (
23
+ <Link
24
+ className={["link_with_icon", ...className.split(" ")].join(" ")}
25
+ {...rest}
26
+ >
27
+ <FontSizedSvg>{icon}</FontSizedSvg>
28
+ {isCurrent && (
29
+ <FontSizedSvg>
30
+ <CurrentSvg />
31
+ </FontSizedSvg>
32
+ )}
33
+ {children}
34
+ </Link>
35
+ );
36
+ };
37
+
38
+ const CurrentSvg = () => {
39
+ return (
40
+ <svg
41
+ viewBox="0 0 16 16"
42
+ width="100%"
43
+ height="100%"
44
+ xmlns="http://www.w3.org/2000/svg"
45
+ >
46
+ <path
47
+ d="m 8 0 c -3.3125 0 -6 2.6875 -6 6 c 0.007812 0.710938 0.136719 1.414062 0.386719 2.078125 l -0.015625 -0.003906 c 0.636718 1.988281 3.78125 5.082031 5.625 6.929687 h 0.003906 v -0.003906 c 1.507812 -1.507812 3.878906 -3.925781 5.046875 -5.753906 c 0.261719 -0.414063 0.46875 -0.808594 0.585937 -1.171875 l -0.019531 0.003906 c 0.25 -0.664063 0.382813 -1.367187 0.386719 -2.078125 c 0 -3.3125 -2.683594 -6 -6 -6 z m 0 3.691406 c 1.273438 0 2.308594 1.035156 2.308594 2.308594 s -1.035156 2.308594 -2.308594 2.308594 c -1.273438 -0.003906 -2.304688 -1.035156 -2.304688 -2.308594 c -0.003906 -1.273438 1.03125 -2.304688 2.304688 -2.308594 z m 0 0"
48
+ fill="#2e3436"
49
+ />
50
+ </svg>
51
+ );
52
+ };