@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,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,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();
|