@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,248 @@
|
|
|
1
|
+
import { elementIsFocusable, findAfter } from "@jsenv/dom";
|
|
2
|
+
import { requestAction } from "@jsenv/validation";
|
|
3
|
+
import { forwardRef } from "preact/compat";
|
|
4
|
+
import { useEffect, useImperativeHandle, useRef, useState } from "preact/hooks";
|
|
5
|
+
import { useNavState } from "../../browser_integration/browser_integration.js";
|
|
6
|
+
import { useActionStatus } from "../../use_action_status.js";
|
|
7
|
+
import { renderActionableComponent } from "../action_execution/render_actionable_component.jsx";
|
|
8
|
+
import { useAction } from "../action_execution/use_action.js";
|
|
9
|
+
import { useExecuteAction } from "../action_execution/use_execute_action.js";
|
|
10
|
+
import { ActionRenderer } from "../action_renderer.jsx";
|
|
11
|
+
import { useKeyboardShortcuts } from "../shortcut/shortcut_context.jsx";
|
|
12
|
+
import { useActionEvents } from "../use_action_events.js";
|
|
13
|
+
import { useFocusGroup } from "../use_focus_group.js";
|
|
14
|
+
import { SummaryMarker } from "./summary_marker.jsx";
|
|
15
|
+
|
|
16
|
+
import.meta.css = /* css */ `
|
|
17
|
+
.navi_details {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
position: relative;
|
|
21
|
+
z-index: 1;
|
|
22
|
+
flex-shrink: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.navi_details > summary {
|
|
26
|
+
flex-shrink: 0;
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
user-select: none;
|
|
31
|
+
}
|
|
32
|
+
.summary_body {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: row;
|
|
35
|
+
align-items: center;
|
|
36
|
+
width: 100%;
|
|
37
|
+
gap: 0.2em;
|
|
38
|
+
}
|
|
39
|
+
.summary_label {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex: 1;
|
|
42
|
+
gap: 0.2em;
|
|
43
|
+
align-items: center;
|
|
44
|
+
padding-right: 10px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.navi_details > summary:focus {
|
|
48
|
+
z-index: 1;
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
export const Details = forwardRef((props, ref) => {
|
|
53
|
+
return renderActionableComponent(props, ref, {
|
|
54
|
+
Basic: DetailsBasic,
|
|
55
|
+
WithAction: DetailsWithAction,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const DetailsBasic = forwardRef((props, ref) => {
|
|
60
|
+
const {
|
|
61
|
+
id,
|
|
62
|
+
label = "Summary",
|
|
63
|
+
children,
|
|
64
|
+
open,
|
|
65
|
+
loading,
|
|
66
|
+
className,
|
|
67
|
+
focusGroup,
|
|
68
|
+
focusGroupDirection,
|
|
69
|
+
arrowKeyShortcuts = true,
|
|
70
|
+
openKeyShortcut = "ArrowRight",
|
|
71
|
+
closeKeyShortcut = "ArrowLeft",
|
|
72
|
+
onToggle,
|
|
73
|
+
...rest
|
|
74
|
+
} = props;
|
|
75
|
+
const innerRef = useRef();
|
|
76
|
+
useImperativeHandle(ref, () => innerRef.current);
|
|
77
|
+
|
|
78
|
+
const [navState, setNavState] = useNavState(id);
|
|
79
|
+
const [innerOpen, innerOpenSetter] = useState(open || navState);
|
|
80
|
+
useFocusGroup(innerRef, {
|
|
81
|
+
enabled: focusGroup,
|
|
82
|
+
name: typeof focusGroup === "string" ? focusGroup : undefined,
|
|
83
|
+
direction: focusGroupDirection,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Browser will dispatch "toggle" event even if we set open={true}
|
|
88
|
+
* When rendering the component for the first time
|
|
89
|
+
* We have to ensure the initial "toggle" event is ignored.
|
|
90
|
+
*
|
|
91
|
+
* If we don't do that code will think the details has changed and run logic accordingly
|
|
92
|
+
* For example it will try to navigate to the current url while we are already there
|
|
93
|
+
*
|
|
94
|
+
* See:
|
|
95
|
+
* - https://techblog.thescore.com/2024/10/08/why-we-decided-to-change-how-the-details-element-works/
|
|
96
|
+
* - https://github.com/whatwg/html/issues/4500
|
|
97
|
+
* - https://stackoverflow.com/questions/58942600/react-html-details-toggles-uncontrollably-when-starts-open
|
|
98
|
+
*
|
|
99
|
+
*/
|
|
100
|
+
const mountedRef = useRef(false);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
mountedRef.current = true;
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const summaryRef = useRef(null);
|
|
106
|
+
|
|
107
|
+
useKeyboardShortcuts(
|
|
108
|
+
innerRef,
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
key: openKeyShortcut,
|
|
112
|
+
enabled: arrowKeyShortcuts,
|
|
113
|
+
when: (e) =>
|
|
114
|
+
document.activeElement === summaryRef.current &&
|
|
115
|
+
// avoid handling openKeyShortcut twice when keydown occurs inside nested details
|
|
116
|
+
!e.defaultPrevented,
|
|
117
|
+
action: (e) => {
|
|
118
|
+
const details = innerRef.current;
|
|
119
|
+
if (!details.open) {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
details.open = true;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const summary = summaryRef.current;
|
|
125
|
+
const firstFocusableElementInDetails = findAfter(
|
|
126
|
+
summary,
|
|
127
|
+
elementIsFocusable,
|
|
128
|
+
{ root: details },
|
|
129
|
+
);
|
|
130
|
+
if (!firstFocusableElementInDetails) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
firstFocusableElementInDetails.focus();
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: closeKeyShortcut,
|
|
139
|
+
enabled: arrowKeyShortcuts,
|
|
140
|
+
when: () => {
|
|
141
|
+
const details = innerRef.current;
|
|
142
|
+
return details.open;
|
|
143
|
+
},
|
|
144
|
+
action: (e) => {
|
|
145
|
+
const details = innerRef.current;
|
|
146
|
+
const summary = summaryRef.current;
|
|
147
|
+
if (document.activeElement === summary) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
summary.focus();
|
|
150
|
+
details.open = false;
|
|
151
|
+
} else {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
summary.focus();
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
(shortcut, e) => {
|
|
159
|
+
shortcut.action(e);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<details
|
|
165
|
+
{...rest}
|
|
166
|
+
id={id}
|
|
167
|
+
className={[
|
|
168
|
+
"navi_details",
|
|
169
|
+
...(className ? className.split(" ") : []),
|
|
170
|
+
].join(" ")}
|
|
171
|
+
ref={innerRef}
|
|
172
|
+
onToggle={(e) => {
|
|
173
|
+
const isOpen = e.newState === "open";
|
|
174
|
+
if (mountedRef.current) {
|
|
175
|
+
if (isOpen) {
|
|
176
|
+
innerOpenSetter(true);
|
|
177
|
+
setNavState(true);
|
|
178
|
+
} else {
|
|
179
|
+
innerOpenSetter(false);
|
|
180
|
+
setNavState(undefined);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
onToggle?.(e);
|
|
184
|
+
}}
|
|
185
|
+
open={innerOpen}
|
|
186
|
+
>
|
|
187
|
+
<summary ref={summaryRef}>
|
|
188
|
+
<div className="summary_body">
|
|
189
|
+
<SummaryMarker open={innerOpen} loading={loading} />
|
|
190
|
+
<div className="summary_label">{label}</div>
|
|
191
|
+
</div>
|
|
192
|
+
</summary>
|
|
193
|
+
{children}
|
|
194
|
+
</details>
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const DetailsWithAction = forwardRef((props, ref) => {
|
|
199
|
+
const {
|
|
200
|
+
action,
|
|
201
|
+
loading,
|
|
202
|
+
onToggle,
|
|
203
|
+
onActionPrevented,
|
|
204
|
+
onActionStart,
|
|
205
|
+
onActionError,
|
|
206
|
+
onActionEnd,
|
|
207
|
+
children,
|
|
208
|
+
...rest
|
|
209
|
+
} = props;
|
|
210
|
+
const innerRef = useRef();
|
|
211
|
+
useImperativeHandle(ref, () => innerRef.current);
|
|
212
|
+
|
|
213
|
+
const effectiveAction = useAction(action);
|
|
214
|
+
const { loading: actionLoading } = useActionStatus(effectiveAction);
|
|
215
|
+
const executeAction = useExecuteAction(innerRef, {
|
|
216
|
+
// the error will be displayed by actionRenderer inside <details>
|
|
217
|
+
errorEffect: "none",
|
|
218
|
+
});
|
|
219
|
+
useActionEvents(innerRef, {
|
|
220
|
+
onPrevented: onActionPrevented,
|
|
221
|
+
onAction: executeAction,
|
|
222
|
+
onStart: onActionStart,
|
|
223
|
+
onError: onActionError,
|
|
224
|
+
onEnd: onActionEnd,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<DetailsBasic
|
|
229
|
+
{...rest}
|
|
230
|
+
ref={innerRef}
|
|
231
|
+
onToggle={(toggleEvent) => {
|
|
232
|
+
const isOpen = toggleEvent.newState === "open";
|
|
233
|
+
if (isOpen) {
|
|
234
|
+
requestAction(effectiveAction, {
|
|
235
|
+
event: toggleEvent,
|
|
236
|
+
method: "run",
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
effectiveAction.abort();
|
|
240
|
+
}
|
|
241
|
+
onToggle?.(toggleEvent);
|
|
242
|
+
}}
|
|
243
|
+
loading={loading || actionLoading}
|
|
244
|
+
>
|
|
245
|
+
<ActionRenderer action={effectiveAction}>{children}</ActionRenderer>
|
|
246
|
+
</DetailsBasic>
|
|
247
|
+
);
|
|
248
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from "preact/hooks";
|
|
2
|
+
import { useDebounceTrue } from "../use_debounce_true.js";
|
|
3
|
+
|
|
4
|
+
const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
|
|
5
|
+
const downArrowPath = "M480-280L160-600l80-80 240 240 240-240 80 80-320 320z";
|
|
6
|
+
|
|
7
|
+
import.meta.css = /* css */ `
|
|
8
|
+
.summary_marker {
|
|
9
|
+
width: 1em;
|
|
10
|
+
height: 1em;
|
|
11
|
+
line-height: 1em;
|
|
12
|
+
}
|
|
13
|
+
.summary_marker_svg .arrow {
|
|
14
|
+
animation-duration: 0.3s;
|
|
15
|
+
animation-fill-mode: forwards;
|
|
16
|
+
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
17
|
+
}
|
|
18
|
+
.summary_marker_svg .arrow[data-animation-target="down"] {
|
|
19
|
+
animation-name: morph-to-down;
|
|
20
|
+
}
|
|
21
|
+
@keyframes morph-to-down {
|
|
22
|
+
from {
|
|
23
|
+
d: path("${rightArrowPath}");
|
|
24
|
+
}
|
|
25
|
+
to {
|
|
26
|
+
d: path("${downArrowPath}");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
.summary_marker_svg .arrow[data-animation-target="right"] {
|
|
30
|
+
animation-name: morph-to-right;
|
|
31
|
+
}
|
|
32
|
+
@keyframes morph-to-right {
|
|
33
|
+
from {
|
|
34
|
+
d: path("${downArrowPath}");
|
|
35
|
+
}
|
|
36
|
+
to {
|
|
37
|
+
d: path("${rightArrowPath}");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.summary_marker_svg .foreground_circle {
|
|
42
|
+
stroke-dasharray: 503 1507; /* ~25% of circle perimeter */
|
|
43
|
+
stroke-dashoffset: 0;
|
|
44
|
+
animation: progress-around-circle 1.5s linear infinite;
|
|
45
|
+
}
|
|
46
|
+
@keyframes progress-around-circle {
|
|
47
|
+
0% {
|
|
48
|
+
stroke-dashoffset: 0;
|
|
49
|
+
}
|
|
50
|
+
100% {
|
|
51
|
+
stroke-dashoffset: -2010;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* fading and scaling */
|
|
56
|
+
.summary_marker_svg .arrow {
|
|
57
|
+
transition: opacity 0.3s ease-in-out;
|
|
58
|
+
opacity: 1;
|
|
59
|
+
}
|
|
60
|
+
.summary_marker_svg .loading_container {
|
|
61
|
+
transition: transform 0.3s linear;
|
|
62
|
+
transform: scale(0.3);
|
|
63
|
+
}
|
|
64
|
+
.summary_marker_svg .background_circle,
|
|
65
|
+
.summary_marker_svg .foreground_circle {
|
|
66
|
+
transition: opacity 0.3s ease-in-out;
|
|
67
|
+
opacity: 0;
|
|
68
|
+
}
|
|
69
|
+
.summary_marker_svg[data-loading] .arrow {
|
|
70
|
+
opacity: 0;
|
|
71
|
+
}
|
|
72
|
+
.summary_marker_svg[data-loading] .loading_container {
|
|
73
|
+
transform: scale(1);
|
|
74
|
+
}
|
|
75
|
+
.summary_marker_svg[data-loading] .background_circle {
|
|
76
|
+
opacity: 0.2;
|
|
77
|
+
}
|
|
78
|
+
.summary_marker_svg[data-loading] .foreground_circle {
|
|
79
|
+
opacity: 1;
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
export const SummaryMarker = ({ open, loading }) => {
|
|
84
|
+
const showLoading = useDebounceTrue(loading, 300);
|
|
85
|
+
const mountedRef = useRef(false);
|
|
86
|
+
const prevOpenRef = useRef(open);
|
|
87
|
+
|
|
88
|
+
useLayoutEffect(() => {
|
|
89
|
+
mountedRef.current = true;
|
|
90
|
+
return () => {
|
|
91
|
+
mountedRef.current = false;
|
|
92
|
+
};
|
|
93
|
+
}, []);
|
|
94
|
+
const shouldAnimate = mountedRef.current && prevOpenRef.current !== open;
|
|
95
|
+
prevOpenRef.current = open;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<span className="summary_marker">
|
|
99
|
+
<svg
|
|
100
|
+
className="summary_marker_svg"
|
|
101
|
+
viewBox="0 -960 960 960"
|
|
102
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
103
|
+
data-loading={open ? showLoading || undefined : undefined}
|
|
104
|
+
>
|
|
105
|
+
<g className="loading_container" transform-origin="480px -480px">
|
|
106
|
+
<circle
|
|
107
|
+
className="background_circle"
|
|
108
|
+
cx="480"
|
|
109
|
+
cy="-480"
|
|
110
|
+
r="320"
|
|
111
|
+
stroke="currentColor"
|
|
112
|
+
fill="none"
|
|
113
|
+
strokeWidth="60"
|
|
114
|
+
opacity="0.2"
|
|
115
|
+
/>
|
|
116
|
+
<circle
|
|
117
|
+
className="foreground_circle"
|
|
118
|
+
cx="480"
|
|
119
|
+
cy="-480"
|
|
120
|
+
r="320"
|
|
121
|
+
stroke="currentColor"
|
|
122
|
+
fill="none"
|
|
123
|
+
strokeWidth="60"
|
|
124
|
+
strokeLinecap="round"
|
|
125
|
+
strokeDasharray="503 1507"
|
|
126
|
+
/>
|
|
127
|
+
</g>
|
|
128
|
+
<g className="arrow_container" transform-origin="480px -480px">
|
|
129
|
+
<path
|
|
130
|
+
className="arrow"
|
|
131
|
+
fill="currentColor"
|
|
132
|
+
data-animation-target={
|
|
133
|
+
shouldAnimate ? (open ? "down" : "right") : undefined
|
|
134
|
+
}
|
|
135
|
+
d={open ? downArrowPath : rightArrowPath}
|
|
136
|
+
/>
|
|
137
|
+
</g>
|
|
138
|
+
</svg>
|
|
139
|
+
</span>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { forwardRef } from "preact/compat";
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "preact/hooks";
|
|
8
|
+
import { Input } from "../input/input.jsx";
|
|
9
|
+
|
|
10
|
+
export const useEditableController = () => {
|
|
11
|
+
const [editable, editableSetter] = useState(null);
|
|
12
|
+
const startEditing = useCallback(({ focusVisible } = {}) => {
|
|
13
|
+
editableSetter({
|
|
14
|
+
focusVisible,
|
|
15
|
+
});
|
|
16
|
+
}, []);
|
|
17
|
+
const stopEditing = useCallback(() => {
|
|
18
|
+
editableSetter(null);
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
const prevEditableRef = useRef(editable);
|
|
22
|
+
const editableJustEnded = prevEditableRef.current && !editable;
|
|
23
|
+
prevEditableRef.current = editable;
|
|
24
|
+
|
|
25
|
+
return { editable, startEditing, stopEditing, editableJustEnded };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const EditableText = forwardRef((props, ref) => {
|
|
29
|
+
let { children, action, editable, value, valueSignal, onEditEnd, ...rest } =
|
|
30
|
+
props;
|
|
31
|
+
if (import.meta.DEV && !action) {
|
|
32
|
+
console.warn(`EditableText requires an action prop`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const innerRef = useRef();
|
|
36
|
+
useImperativeHandle(ref, () => innerRef.current);
|
|
37
|
+
|
|
38
|
+
if (valueSignal) {
|
|
39
|
+
value = valueSignal.value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const editablePreviousRef = useRef(editable);
|
|
43
|
+
const valueWhenEditStartRef = useRef(editable ? value : undefined);
|
|
44
|
+
if (editablePreviousRef.current !== editable) {
|
|
45
|
+
if (editable) {
|
|
46
|
+
valueWhenEditStartRef.current = value;
|
|
47
|
+
}
|
|
48
|
+
editablePreviousRef.current = editable;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<div style={{ display: editable ? "none" : "inline-flex", flexGrow: 1 }}>
|
|
54
|
+
{children || <span>{value}</span>}
|
|
55
|
+
</div>
|
|
56
|
+
{editable && (
|
|
57
|
+
<Input
|
|
58
|
+
autoFocus
|
|
59
|
+
autoFocusVisible
|
|
60
|
+
autoSelect
|
|
61
|
+
required
|
|
62
|
+
cancelOnEscape
|
|
63
|
+
cancelOnBlurInvalid
|
|
64
|
+
onCancel={(e) => {
|
|
65
|
+
if (valueSignal) {
|
|
66
|
+
valueSignal.value = valueWhenEditStartRef.current;
|
|
67
|
+
}
|
|
68
|
+
onEditEnd({
|
|
69
|
+
cancelled: true,
|
|
70
|
+
event: e,
|
|
71
|
+
});
|
|
72
|
+
}}
|
|
73
|
+
onBlur={(e) => {
|
|
74
|
+
if (e.target.value === valueWhenEditStartRef.current) {
|
|
75
|
+
onEditEnd({
|
|
76
|
+
cancelled: true,
|
|
77
|
+
event: e,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}}
|
|
81
|
+
action={action}
|
|
82
|
+
onActionEnd={(e) => {
|
|
83
|
+
onEditEnd({
|
|
84
|
+
success: true,
|
|
85
|
+
event: e,
|
|
86
|
+
});
|
|
87
|
+
}}
|
|
88
|
+
ref={innerRef}
|
|
89
|
+
value={value}
|
|
90
|
+
valueSignal={valueSignal}
|
|
91
|
+
{...rest}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createContext } from "preact";
|
|
2
|
+
import { useContext } from "preact/hooks";
|
|
3
|
+
|
|
4
|
+
export const ErrorBoundaryContext = createContext(null);
|
|
5
|
+
|
|
6
|
+
export const useResetErrorBoundary = () => {
|
|
7
|
+
const resetErrorBoundary = useContext(ErrorBoundaryContext);
|
|
8
|
+
return resetErrorBoundary;
|
|
9
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Here we want the same behaviour as web standards:
|
|
4
|
+
*
|
|
5
|
+
* 1. When submitting the form URL does not change
|
|
6
|
+
* 2. When form submission id done user is redirected (by default the current one)
|
|
7
|
+
* (we can configure this using target)
|
|
8
|
+
* So for example user might be reidrect to a page with the resource he just created
|
|
9
|
+
* I could create an example where we would put a link on the page to let user see what he created
|
|
10
|
+
* but by default user stays on the form allowing to create multiple resources at once
|
|
11
|
+
* And an other where he is redirected to the resource he created
|
|
12
|
+
* 3. If form submission fails ideally we should display this somewhere on the UI
|
|
13
|
+
* right now it's just logged to the console I need to see how we can achieve this
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { requestAction, useConstraints } from "@jsenv/validation";
|
|
17
|
+
import { forwardRef } from "preact/compat";
|
|
18
|
+
import { useImperativeHandle, useRef, useState } from "preact/hooks";
|
|
19
|
+
import { FormContext } from "./action_execution/form_context.js";
|
|
20
|
+
import { renderActionableComponent } from "./action_execution/render_actionable_component.jsx";
|
|
21
|
+
import { useFormActionBoundToFormParams } from "./action_execution/use_action.js";
|
|
22
|
+
import { useExecuteAction } from "./action_execution/use_execute_action.js";
|
|
23
|
+
import { collectFormElementValues } from "./collect_form_element_values.js";
|
|
24
|
+
import { useActionEvents } from "./use_action_events.js";
|
|
25
|
+
|
|
26
|
+
export const Form = forwardRef((props, ref) => {
|
|
27
|
+
return renderActionableComponent(props, ref, {
|
|
28
|
+
Basic: FormBasic,
|
|
29
|
+
WithAction: FormWithAction,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const FormBasic = forwardRef((props, ref) => {
|
|
34
|
+
return <form ref={ref} {...props} />;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const FormWithAction = forwardRef((props, ref) => {
|
|
38
|
+
let {
|
|
39
|
+
action,
|
|
40
|
+
method,
|
|
41
|
+
readOnly = false,
|
|
42
|
+
allowConcurrentActions: formAllowConcurrentActions = false,
|
|
43
|
+
actionErrorEffect = "show_validation_message", // "show_validation_message" or "throw"
|
|
44
|
+
onActionPrevented,
|
|
45
|
+
onActionStart,
|
|
46
|
+
onActionAbort,
|
|
47
|
+
onActionError,
|
|
48
|
+
onActionEnd,
|
|
49
|
+
children,
|
|
50
|
+
...rest
|
|
51
|
+
} = props;
|
|
52
|
+
|
|
53
|
+
const innerRef = useRef();
|
|
54
|
+
useImperativeHandle(ref, () => innerRef.current);
|
|
55
|
+
// instantiation validation to:
|
|
56
|
+
// - receive "requestsubmit" custom event ensure submit is prevented
|
|
57
|
+
// (and also execute action without validation if form.submit() is ever called)
|
|
58
|
+
useConstraints(innerRef, []);
|
|
59
|
+
|
|
60
|
+
const [boundAction, formParamsSignal, setFormParams] =
|
|
61
|
+
useFormActionBoundToFormParams(action);
|
|
62
|
+
const [formActionRequester, setFormActionRequester] = useState(null);
|
|
63
|
+
const [formIsBusy, setFormIsBusy] = useState(false);
|
|
64
|
+
const [formActionError, setFormActionError] = useState(null);
|
|
65
|
+
const [formActionAborted, setFormActionAborted] = useState(false);
|
|
66
|
+
const executeAction = useExecuteAction(innerRef, {
|
|
67
|
+
errorEffect: actionErrorEffect,
|
|
68
|
+
});
|
|
69
|
+
const formIsReadOnly =
|
|
70
|
+
readOnly || (formIsBusy && !formAllowConcurrentActions);
|
|
71
|
+
|
|
72
|
+
useActionEvents(innerRef, {
|
|
73
|
+
onPrevented: onActionPrevented,
|
|
74
|
+
onAction: (actionEvent) => {
|
|
75
|
+
const form = innerRef.current;
|
|
76
|
+
const formElementValues = collectFormElementValues(form);
|
|
77
|
+
setFormParams(formElementValues);
|
|
78
|
+
|
|
79
|
+
setFormActionRequester(actionEvent.detail.requester);
|
|
80
|
+
executeAction(actionEvent);
|
|
81
|
+
},
|
|
82
|
+
onStart: (e) => {
|
|
83
|
+
setFormIsBusy(true);
|
|
84
|
+
setFormActionError(null);
|
|
85
|
+
setFormActionAborted(false);
|
|
86
|
+
onActionStart?.(e);
|
|
87
|
+
},
|
|
88
|
+
onAbort: (e) => {
|
|
89
|
+
setFormIsBusy(false);
|
|
90
|
+
setFormActionAborted(true);
|
|
91
|
+
onActionAbort?.(e);
|
|
92
|
+
},
|
|
93
|
+
onError: (e) => {
|
|
94
|
+
setFormIsBusy(false);
|
|
95
|
+
setFormActionError(e);
|
|
96
|
+
onActionError?.(e);
|
|
97
|
+
},
|
|
98
|
+
onEnd: (e) => {
|
|
99
|
+
setFormIsBusy(false);
|
|
100
|
+
onActionEnd?.(e);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<form
|
|
106
|
+
{...rest}
|
|
107
|
+
data-action={boundAction.name}
|
|
108
|
+
data-method={action.meta?.httpVerb || method || "GET"}
|
|
109
|
+
ref={innerRef}
|
|
110
|
+
// eslint-disable-next-line react/no-unknown-property
|
|
111
|
+
onrequestsubmit={(e) => {
|
|
112
|
+
// prevent "submit" event that would be dispatched by the browser after form.requestSubmit()
|
|
113
|
+
// (not super important because our <form> listen the "action" and do does preventDefault on "submit")
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
requestAction(boundAction, { event: e });
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<FormContext.Provider
|
|
119
|
+
value={{
|
|
120
|
+
formAllowConcurrentActions,
|
|
121
|
+
formAction: boundAction,
|
|
122
|
+
formParamsSignal,
|
|
123
|
+
formActionRequester,
|
|
124
|
+
formIsReadOnly,
|
|
125
|
+
formIsBusy,
|
|
126
|
+
formActionAborted,
|
|
127
|
+
formActionError,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{children}
|
|
131
|
+
</FormContext.Provider>
|
|
132
|
+
</form>
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// const dispatchCustomEventOnFormAndFormElements = (type, options) => {
|
|
137
|
+
// const form = innerRef.current;
|
|
138
|
+
// const customEvent = new CustomEvent(type, options);
|
|
139
|
+
// // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements
|
|
140
|
+
// for (const element of form.elements) {
|
|
141
|
+
// element.dispatchEvent(customEvent);
|
|
142
|
+
// }
|
|
143
|
+
// form.dispatchEvent(customEvent);
|
|
144
|
+
// };
|