@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,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
+ // };