@jsenv/navi 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/index.js +51 -0
  2. package/package.json +38 -0
  3. package/src/action_private_properties.js +11 -0
  4. package/src/action_proxy_test.html +353 -0
  5. package/src/action_run_states.js +5 -0
  6. package/src/actions.js +1377 -0
  7. package/src/browser_integration/browser_integration.js +191 -0
  8. package/src/browser_integration/document_back_and_forward.js +17 -0
  9. package/src/browser_integration/document_loading_signal.js +100 -0
  10. package/src/browser_integration/document_state_signal.js +9 -0
  11. package/src/browser_integration/document_url_signal.js +9 -0
  12. package/src/browser_integration/use_is_visited.js +19 -0
  13. package/src/browser_integration/via_history.js +199 -0
  14. package/src/browser_integration/via_navigation.js +168 -0
  15. package/src/components/action_execution/form_context.js +8 -0
  16. package/src/components/action_execution/render_actionable_component.jsx +27 -0
  17. package/src/components/action_execution/use_action.js +330 -0
  18. package/src/components/action_execution/use_execute_action.js +161 -0
  19. package/src/components/action_renderer.jsx +136 -0
  20. package/src/components/collect_form_element_values.js +79 -0
  21. package/src/components/demos/0_button_demo.html +155 -0
  22. package/src/components/demos/1_checkbox_demo.html +257 -0
  23. package/src/components/demos/2_input_textual_demo.html +354 -0
  24. package/src/components/demos/3_radio_demo.html +222 -0
  25. package/src/components/demos/4_select_demo.html +104 -0
  26. package/src/components/demos/5_list_scrollable_demo.html +153 -0
  27. package/src/components/demos/action/0_button_demo.html +204 -0
  28. package/src/components/demos/action/10_shortcuts_demo.html +189 -0
  29. package/src/components/demos/action/11_nested_shortcuts_demo.html +401 -0
  30. package/src/components/demos/action/1_input_text_demo.html +461 -0
  31. package/src/components/demos/action/2_form_multiple.html +303 -0
  32. package/src/components/demos/action/3_details_demo.html +172 -0
  33. package/src/components/demos/action/4_input_checkbox_demo.html +611 -0
  34. package/src/components/demos/action/6_checkbox_list_demo.html +109 -0
  35. package/src/components/demos/action/7_radio_list_demo.html +217 -0
  36. package/src/components/demos/action/8_editable_text_demo.html +442 -0
  37. package/src/components/demos/action/9_link_demo.html +172 -0
  38. package/src/components/demos/demo.md +0 -0
  39. package/src/components/demos/route/basic/basic.html +14 -0
  40. package/src/components/demos/route/basic/basic_route_demo.jsx +224 -0
  41. package/src/components/demos/route/multi/multi.html +14 -0
  42. package/src/components/demos/route/multi/multi_route_demo.jsx +277 -0
  43. package/src/components/details/details.jsx +248 -0
  44. package/src/components/details/summary_marker.jsx +141 -0
  45. package/src/components/editable_text/editable_text.jsx +96 -0
  46. package/src/components/error_boundary_context.js +9 -0
  47. package/src/components/form.jsx +144 -0
  48. package/src/components/input/button.jsx +333 -0
  49. package/src/components/input/checkbox_list.jsx +294 -0
  50. package/src/components/input/field.jsx +61 -0
  51. package/src/components/input/field_css.js +118 -0
  52. package/src/components/input/input.jsx +15 -0
  53. package/src/components/input/input_checkbox.jsx +370 -0
  54. package/src/components/input/input_radio.jsx +299 -0
  55. package/src/components/input/input_textual.jsx +338 -0
  56. package/src/components/input/radio_list.jsx +283 -0
  57. package/src/components/input/select.jsx +273 -0
  58. package/src/components/input/use_form_event.js +20 -0
  59. package/src/components/input/use_on_change.js +12 -0
  60. package/src/components/link/link.jsx +291 -0
  61. package/src/components/loader/loader_background.jsx +324 -0
  62. package/src/components/loader/loading_spinner.jsx +68 -0
  63. package/src/components/loader/network_speed.js +83 -0
  64. package/src/components/loader/rectangle_loading.jsx +225 -0
  65. package/src/components/route.jsx +15 -0
  66. package/src/components/selection/selection.js +5 -0
  67. package/src/components/selection/selection_context.jsx +262 -0
  68. package/src/components/shortcut/os.js +9 -0
  69. package/src/components/shortcut/shortcut_context.jsx +390 -0
  70. package/src/components/use_action_events.js +37 -0
  71. package/src/components/use_auto_focus.js +43 -0
  72. package/src/components/use_debounce_true.js +31 -0
  73. package/src/components/use_focus_group.js +19 -0
  74. package/src/components/use_initial_value.js +104 -0
  75. package/src/components/use_is_visited.js +19 -0
  76. package/src/components/use_ref_array.js +38 -0
  77. package/src/components/use_signal_sync.js +50 -0
  78. package/src/components/use_state_array.js +40 -0
  79. package/src/docs/actions.md +228 -0
  80. package/src/docs/demos/resource/action_status.jsx +42 -0
  81. package/src/docs/demos/resource/demo.md +1 -0
  82. package/src/docs/demos/resource/resource_demo_0.html +84 -0
  83. package/src/docs/demos/resource/resource_demo_10_post_gc.html +364 -0
  84. package/src/docs/demos/resource/resource_demo_11_describe_many.html +362 -0
  85. package/src/docs/demos/resource/resource_demo_2.html +173 -0
  86. package/src/docs/demos/resource/resource_demo_3_filtered_users.html +415 -0
  87. package/src/docs/demos/resource/resource_demo_4_details.html +284 -0
  88. package/src/docs/demos/resource/resource_demo_5_renderer_lazy.html +115 -0
  89. package/src/docs/demos/resource/resource_demo_6_gc.html +217 -0
  90. package/src/docs/demos/resource/resource_demo_7_child_gc.html +240 -0
  91. package/src/docs/demos/resource/resource_demo_8_proxy_gc.html +319 -0
  92. package/src/docs/demos/resource/resource_demo_9_describe_one.html +472 -0
  93. package/src/docs/demos/resource/tata.jsx +3 -0
  94. package/src/docs/demos/resource/toto.jsx +3 -0
  95. package/src/docs/demos/user_nav/user_nav.html +12 -0
  96. package/src/docs/demos/user_nav/user_nav.jsx +330 -0
  97. package/src/docs/resource_dependencies.md +103 -0
  98. package/src/docs/resource_with_params.md +80 -0
  99. package/src/notes.md +13 -0
  100. package/src/route/route.js +518 -0
  101. package/src/route/route.test.html +228 -0
  102. package/src/store/array_signal_store.js +537 -0
  103. package/src/store/local_storage_signal.js +17 -0
  104. package/src/store/resource_graph.js +1303 -0
  105. package/src/store/tests/resource_graph_autoreload_demo.html +12 -0
  106. package/src/store/tests/resource_graph_autoreload_demo.jsx +964 -0
  107. package/src/store/tests/resource_graph_dependencies.test.js +95 -0
  108. package/src/store/value_in_local_storage.js +187 -0
  109. package/src/symbol_object_signal.js +1 -0
  110. package/src/use_action_data.js +10 -0
  111. package/src/use_action_status.js +47 -0
  112. package/src/utils/add_many_event_listeners.js +15 -0
  113. package/src/utils/array_add_remove.js +61 -0
  114. package/src/utils/array_signal.js +15 -0
  115. package/src/utils/compare_two_js_values.js +172 -0
  116. package/src/utils/execute_with_cleanup.js +21 -0
  117. package/src/utils/get_caller_info.js +85 -0
  118. package/src/utils/iterable_weak_set.js +62 -0
  119. package/src/utils/js_value_weak_map.js +162 -0
  120. package/src/utils/js_value_weak_map_demo.html +690 -0
  121. package/src/utils/merge_two_js_values.js +53 -0
  122. package/src/utils/stringify_for_display.js +150 -0
  123. package/src/utils/weak_effect.js +48 -0
@@ -0,0 +1,83 @@
1
+ import { signal } from "@preact/signals";
2
+
3
+ export const useNetworkSpeed = () => {
4
+ return networkSpeedSignal.value;
5
+ };
6
+
7
+ const connection =
8
+ window.navigator.connection ||
9
+ window.navigator.mozConnection ||
10
+ window.navigator.webkitConnection;
11
+
12
+ const getNetworkSpeed = () => {
13
+ // ✅ Network Information API (support moderne)
14
+ if (!connection) {
15
+ return "3g";
16
+ }
17
+ if (connection) {
18
+ const effectiveType = connection.effectiveType;
19
+ if (effectiveType) {
20
+ return effectiveType; // "slow-2g", "2g", "3g", "4g", "5g"
21
+ }
22
+ const downlink = connection.downlink;
23
+ if (downlink) {
24
+ // downlink is in Mbps
25
+ if (downlink < 1) return "slow-2g"; // < 1 Mbps
26
+ if (downlink < 2.5) return "2g"; // 1-2.5 Mbps
27
+ if (downlink < 10) return "3g"; // 2.5-10 Mbps
28
+ return "4g"; // > 10 Mbps
29
+ }
30
+ }
31
+ return "3g";
32
+ };
33
+
34
+ const updateNetworkSpeed = () => {
35
+ networkSpeedSignal.value = getNetworkSpeed();
36
+ };
37
+
38
+ export const networkSpeedSignal = signal(getNetworkSpeed());
39
+
40
+ const setupNetworkMonitoring = () => {
41
+ const cleanupFunctions = [];
42
+
43
+ // ✅ 1. Écouter les changements natifs
44
+
45
+ if (connection) {
46
+ connection.addEventListener("change", updateNetworkSpeed);
47
+ cleanupFunctions.push(() => {
48
+ connection.removeEventListener("change", updateNetworkSpeed);
49
+ });
50
+ }
51
+
52
+ // ✅ 2. Polling de backup (toutes les 60 secondes)
53
+ const pollInterval = setInterval(updateNetworkSpeed, 60000);
54
+ cleanupFunctions.push(() => clearInterval(pollInterval));
55
+
56
+ // ✅ 3. Vérifier lors de la reprise d'activité
57
+ const handleVisibilityChange = () => {
58
+ if (!document.hidden) {
59
+ updateNetworkSpeed();
60
+ }
61
+ };
62
+
63
+ document.addEventListener("visibilitychange", handleVisibilityChange);
64
+ cleanupFunctions.push(() => {
65
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
66
+ });
67
+
68
+ // ✅ 4. Vérifier lors de la reprise de connexion
69
+ const handleOnline = () => {
70
+ updateNetworkSpeed();
71
+ };
72
+
73
+ window.addEventListener("online", handleOnline);
74
+ cleanupFunctions.push(() => {
75
+ window.removeEventListener("online", handleOnline);
76
+ });
77
+
78
+ // Cleanup global
79
+ return () => {
80
+ cleanupFunctions.forEach((cleanup) => cleanup());
81
+ };
82
+ };
83
+ setupNetworkMonitoring();
@@ -0,0 +1,225 @@
1
+ /**
2
+ * RectangleLoading Component
3
+ *
4
+ * A responsive loading indicator that dynamically adjusts to fit its container.
5
+ * Displays an animated outline with a traveling dot that follows the container's shape.
6
+ *
7
+ * Features:
8
+ * - Adapts to any container dimensions using ResizeObserver
9
+ * - Scales stroke width, margins and corner radius proportionally
10
+ * - Animates using native SVG animations for smooth performance
11
+ * - High-quality SVG rendering with proper path calculations
12
+ *
13
+ * @param {Object} props - Component props
14
+ * @param {string} [props.color="#383a36"] - Color of the loading indicator
15
+ * @param {number} [props.radius=0] - Corner radius of the rectangle (px)
16
+ */
17
+
18
+ import { useLayoutEffect, useRef, useState } from "preact/hooks";
19
+ import { useNetworkSpeed } from "./network_speed.js";
20
+
21
+ export const RectangleLoading = ({
22
+ color = "currentColor",
23
+ radius = 0,
24
+ size = 2,
25
+ }) => {
26
+ const containerRef = useRef(null);
27
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
28
+
29
+ useLayoutEffect(() => {
30
+ const container = containerRef.current;
31
+ let animationFrameId = null;
32
+
33
+ // Create a resize observer to detect changes in the container's dimensions
34
+ const resizeObserver = new ResizeObserver((entries) => {
35
+ // Use requestAnimationFrame to debounce updates
36
+ if (animationFrameId) {
37
+ cancelAnimationFrame(animationFrameId);
38
+ }
39
+
40
+ animationFrameId = requestAnimationFrame(() => {
41
+ for (const entry of entries) {
42
+ const { width, height } = entry.contentRect;
43
+ setDimensions({ width, height });
44
+ }
45
+ });
46
+ });
47
+
48
+ resizeObserver.observe(container);
49
+
50
+ // Initial measurement
51
+ setDimensions({
52
+ width: container.offsetWidth,
53
+ height: container.offsetHeight,
54
+ });
55
+
56
+ return () => {
57
+ if (animationFrameId) {
58
+ cancelAnimationFrame(animationFrameId);
59
+ }
60
+ resizeObserver.disconnect();
61
+ };
62
+ }, []);
63
+
64
+ return (
65
+ <div name="rectangle_loading" ref={containerRef}>
66
+ {dimensions.width > 0 && dimensions.height > 0 && (
67
+ <RectangleLoadingSvg
68
+ radius={radius}
69
+ color={color}
70
+ width={dimensions.width}
71
+ height={dimensions.height}
72
+ strokeWidth={size}
73
+ />
74
+ )}
75
+ </div>
76
+ );
77
+ };
78
+
79
+ const RectangleLoadingSvg = ({
80
+ width,
81
+ height,
82
+ color,
83
+ radius,
84
+ trailColor = "transparent",
85
+ strokeWidth,
86
+ }) => {
87
+ const margin = Math.max(2, Math.min(width, height) * 0.03);
88
+
89
+ // Calculate the drawable area
90
+ const drawableWidth = width - margin * 2;
91
+ const drawableHeight = height - margin * 2;
92
+
93
+ // ✅ Check if this should be a circle
94
+ const maxPossibleRadius = Math.min(drawableWidth, drawableHeight) / 2;
95
+ const actualRadius = Math.min(
96
+ radius || Math.min(drawableWidth, drawableHeight) * 0.05,
97
+ maxPossibleRadius, // ✅ Limité au radius maximum possible
98
+ );
99
+
100
+ // ✅ Determine if we're dealing with a circle
101
+ const isCircle = actualRadius >= maxPossibleRadius * 0.95; // 95% = virtually a circle
102
+
103
+ let pathLength;
104
+ let rectPath;
105
+
106
+ if (isCircle) {
107
+ // ✅ Circle: perimeter = 2πr
108
+ pathLength = 2 * Math.PI * actualRadius;
109
+
110
+ // ✅ Circle path centered in the drawable area
111
+ const centerX = margin + drawableWidth / 2;
112
+ const centerY = margin + drawableHeight / 2;
113
+
114
+ rectPath = `
115
+ M ${centerX + actualRadius},${centerY}
116
+ A ${actualRadius},${actualRadius} 0 1 1 ${centerX - actualRadius},${centerY}
117
+ A ${actualRadius},${actualRadius} 0 1 1 ${centerX + actualRadius},${centerY}
118
+ `;
119
+ } else {
120
+ // ✅ Rectangle: calculate perimeter properly
121
+ const straightEdges =
122
+ 2 * (drawableWidth - 2 * actualRadius) +
123
+ 2 * (drawableHeight - 2 * actualRadius);
124
+ const cornerArcs = actualRadius > 0 ? 2 * Math.PI * actualRadius : 0;
125
+ pathLength = straightEdges + cornerArcs;
126
+
127
+ rectPath = `
128
+ M ${margin + actualRadius},${margin}
129
+ L ${margin + drawableWidth - actualRadius},${margin}
130
+ A ${actualRadius},${actualRadius} 0 0 1 ${margin + drawableWidth},${margin + actualRadius}
131
+ L ${margin + drawableWidth},${margin + drawableHeight - actualRadius}
132
+ A ${actualRadius},${actualRadius} 0 0 1 ${margin + drawableWidth - actualRadius},${margin + drawableHeight}
133
+ L ${margin + actualRadius},${margin + drawableHeight}
134
+ A ${actualRadius},${actualRadius} 0 0 1 ${margin},${margin + drawableHeight - actualRadius}
135
+ L ${margin},${margin + actualRadius}
136
+ A ${actualRadius},${actualRadius} 0 0 1 ${margin + actualRadius},${margin}
137
+ `;
138
+ }
139
+
140
+ // Fixed segment size in pixels
141
+ const maxSegmentSize = 40;
142
+ const segmentLength = Math.min(maxSegmentSize, pathLength * 0.25);
143
+ const gapLength = pathLength - segmentLength;
144
+
145
+ // Vitesse constante en pixels par seconde
146
+ const networkSpeed = useNetworkSpeed();
147
+ const pixelsPerSecond =
148
+ {
149
+ "slow-2g": 40,
150
+ "2g": 60,
151
+ "3g": 80,
152
+ "4g": 120,
153
+ }[networkSpeed] || 80;
154
+ const animationDuration = Math.max(1.5, pathLength / pixelsPerSecond);
155
+
156
+ // ✅ Calculate correct offset based on actual segment size
157
+ const segmentRatio = segmentLength / pathLength;
158
+ const circleOffset = -animationDuration * segmentRatio;
159
+
160
+ return (
161
+ <svg
162
+ width="100%"
163
+ height="100%"
164
+ viewBox={`0 0 ${width} ${height}`}
165
+ preserveAspectRatio="none"
166
+ style="overflow: visible"
167
+ xmlns="http://www.w3.org/2000/svg"
168
+ shape-rendering="geometricPrecision"
169
+ >
170
+ {/* Base outline - circle ou rectangle */}
171
+ {isCircle ? (
172
+ <circle
173
+ cx={margin + drawableWidth / 2}
174
+ cy={margin + drawableHeight / 2}
175
+ r={actualRadius}
176
+ fill="none"
177
+ stroke={trailColor}
178
+ strokeWidth={strokeWidth}
179
+ />
180
+ ) : (
181
+ <rect
182
+ x={margin}
183
+ y={margin}
184
+ width={drawableWidth}
185
+ height={drawableHeight}
186
+ fill="none"
187
+ stroke={trailColor}
188
+ strokeWidth={strokeWidth}
189
+ rx={actualRadius}
190
+ />
191
+ )}
192
+
193
+ {/* Progress segment that grows and moves */}
194
+ <path
195
+ d={rectPath}
196
+ fill="none"
197
+ stroke={color}
198
+ strokeWidth={strokeWidth}
199
+ strokeLinecap="round"
200
+ strokeDasharray={`${segmentLength} ${gapLength}`}
201
+ pathLength={pathLength}
202
+ >
203
+ <animate
204
+ attributeName="stroke-dashoffset"
205
+ from={pathLength}
206
+ to="0"
207
+ dur={`${animationDuration}s`}
208
+ repeatCount="indefinite"
209
+ begin="0s"
210
+ />
211
+ </path>
212
+
213
+ {/* Leading dot that follows the path */}
214
+ <circle r={strokeWidth} fill={color}>
215
+ <animateMotion
216
+ path={rectPath}
217
+ dur={`${animationDuration}s`}
218
+ repeatCount="indefinite"
219
+ rotate="auto"
220
+ begin={`${circleOffset}s`}
221
+ />
222
+ </circle>
223
+ </svg>
224
+ );
225
+ };
@@ -0,0 +1,15 @@
1
+ import { useRouteStatus } from "../route/route.js";
2
+ import { ActionRenderer } from "./action_renderer.jsx";
3
+
4
+ export const Route = ({ route, children }) => {
5
+ if (!route.action) {
6
+ throw new Error(
7
+ "Route component requires a route with an action to render.",
8
+ );
9
+ }
10
+ const { active } = useRouteStatus(route);
11
+
12
+ return active ? (
13
+ <ActionRenderer action={route.action}>{children}</ActionRenderer>
14
+ ) : null;
15
+ };
@@ -0,0 +1,5 @@
1
+ export {
2
+ SelectionProvider,
3
+ useRegisterSelectionValue,
4
+ useSelectionContext,
5
+ } from "./selection_context.jsx";
@@ -0,0 +1,262 @@
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
+ };
@@ -0,0 +1,9 @@
1
+ const detectMac = () => {
2
+ // Modern way using User-Agent Client Hints API
3
+ if (window.navigator.userAgentData) {
4
+ return window.navigator.userAgentData.platform === "macOS";
5
+ }
6
+ // Fallback to userAgent string parsing
7
+ return /Mac|iPhone|iPad|iPod/.test(window.navigator.userAgent);
8
+ };
9
+ export const isMac = detectMac();