phoenix_duskmoon 9.16.14 → 9.16.15
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/CHANGELOG.md +11 -0
- package/assets/js/react_form/component.js +17 -6
- package/assets/js/react_form/index.js +29 -15
- package/assets/js/react_form/values.js +22 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
- Public React Chat hook factory, custom composer and renderer integration, and
|
|
6
6
|
application metadata, runtime and admission state across snapshots and events.
|
|
7
|
+
- Opt-in browser constraint validation for classic React forms with
|
|
8
|
+
`novalidate={false}`; schema forms retain JSON Schema validation.
|
|
9
|
+
- Independent classic React form layout through `form_class`, preserving outer
|
|
10
|
+
wrapper `class`; document the upstream schema-form styling limitation.
|
|
7
11
|
- Native Core components: carousel, countdown, diff, keyboard keys, radial progress,
|
|
8
12
|
floating actions, swap, filter group, hero, indicator, join, mask, sidebar layout,
|
|
9
13
|
stack and megamenu, with Storybook examples.
|
|
@@ -32,6 +36,13 @@
|
|
|
32
36
|
|
|
33
37
|
### Fixed
|
|
34
38
|
|
|
39
|
+
- Forward React form field attributes to their interactive controls and preserve
|
|
40
|
+
application-disabled fields when the LiveView connection recovers.
|
|
41
|
+
- Preserve selected-value arrays for native checkbox groups named with `[]`,
|
|
42
|
+
including nested paths, empty selections and per-checkbox reset state.
|
|
43
|
+
- Read current native controls before React form submission, including
|
|
44
|
+
programmatically changed hidden inputs, and delegate events for replaced or
|
|
45
|
+
newly added controls while preserving React-owned values.
|
|
35
46
|
- Resolve production chunk imports by their importing module, preventing identical
|
|
36
47
|
relative paths in unrelated packages from becoming missing browser assets.
|
|
37
48
|
- Preserve lazy custom-element registration and resolve preload URLs relative to
|
|
@@ -10,30 +10,41 @@ const h = React.createElement;
|
|
|
10
10
|
export function ReactField({ config, value, errors, disabled, onChange }) {
|
|
11
11
|
const errorId = useId();
|
|
12
12
|
const label = config.label || (Array.isArray(config.name) ? config.name.join(".") : config.name);
|
|
13
|
-
const common = { disabled, "aria-label": label, "aria-invalid": errors?.length ? "true" : undefined, "aria-describedby": errors?.length ? errorId : undefined };
|
|
13
|
+
const common = { disabled: Boolean(disabled || config.disabled), "aria-label": label, "aria-invalid": errors?.length ? "true" : undefined, "aria-describedby": errors?.length ? errorId : undefined };
|
|
14
|
+
const textAttributes = { placeholder: config.placeholder ?? undefined, autoComplete: config.autocomplete ?? undefined, required: config.required, readOnly: config.readonly };
|
|
14
15
|
let control;
|
|
15
16
|
|
|
16
17
|
switch (config.type) {
|
|
17
18
|
case "checkbox":
|
|
18
|
-
control = h(Checkbox, { ...common, checked: Boolean(value), onChange: (event) => onChange(event.target.checked) });
|
|
19
|
+
control = h(Checkbox, { ...common, required: config.required, checked: Boolean(value), onChange: (event) => onChange(event.target.checked) });
|
|
19
20
|
break;
|
|
20
21
|
case "number":
|
|
21
|
-
|
|
22
|
+
// TODO(upstream): duskmoon-dev/duskmoon-react#82
|
|
23
|
+
// WORKAROUND(upstream): duskmoon-dev/duskmoon-react#82 — readOnly does not guard upstream stepping.
|
|
24
|
+
control = h(InputNumber, { ...common, ...textAttributes, min: config.min ?? undefined, max: config.max ?? undefined, step: config.step ?? undefined,
|
|
25
|
+
controls: config.readonly ? false : undefined, keyboard: config.readonly ? false : undefined, value: value ?? null, onChange });
|
|
22
26
|
break;
|
|
23
27
|
case "select":
|
|
24
28
|
case "multiselect":
|
|
25
|
-
|
|
29
|
+
// TODO(upstream): duskmoon-dev/duskmoon-react#83
|
|
30
|
+
// WORKAROUND(upstream): duskmoon-dev/duskmoon-react#83 — only the mounted trigger can receive required ARIA in 0.5.0.
|
|
31
|
+
control = h(Select, { ...common, placeholder: config.placeholder ?? undefined, ref: (container) => {
|
|
32
|
+
const trigger = container?.querySelector('button[aria-haspopup="listbox"]');
|
|
33
|
+
if (config.required) trigger?.setAttribute("aria-required", "true");
|
|
34
|
+
else trigger?.removeAttribute("aria-required");
|
|
35
|
+
}, value: value ?? undefined, mode: config.type === "multiselect" ? "multiple" : undefined,
|
|
26
36
|
options: config.options || [], showSearch: true, allowClear: true, onChange: (next) => onChange(next ?? null) });
|
|
27
37
|
break;
|
|
28
38
|
case "textarea":
|
|
29
|
-
control = h("textarea", { ...common, className: "textarea", value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
39
|
+
control = h("textarea", { ...common, ...textAttributes, rows: config.rows ?? undefined, className: "textarea", value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
30
40
|
break;
|
|
31
41
|
default:
|
|
32
|
-
control = h(Input, { ...common, type: config.type, value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
42
|
+
control = h(Input, { ...common, ...textAttributes, type: config.type, value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
return h(Form.Item, {
|
|
36
46
|
label,
|
|
47
|
+
required: config.required,
|
|
37
48
|
className: errors?.length ? "form-item-error" : undefined,
|
|
38
49
|
help: errors?.length ? h(Form.ErrorList, { id: errorId, errors, role: "alert" }) : undefined,
|
|
39
50
|
}, control);
|
|
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client";
|
|
|
3
3
|
import { ReactField } from "./component.js";
|
|
4
4
|
import { compileForm } from "@duskmoon-dev/components/json-schema-form";
|
|
5
5
|
import { ReactSchemaForm, changedPath, schemaErrors } from "./schema.js";
|
|
6
|
-
import { collectValues, fieldKey, getAt, pathOf, setAt } from "./values.js";
|
|
6
|
+
import { collectValues, fieldKey, getAt, nativeValue, pathOf, setAt, setNativeValue } from "./values.js";
|
|
7
7
|
function parseJSON(value, fallback = {}) { try { return JSON.parse(value); } catch { return fallback; } }
|
|
8
8
|
function flattenErrors(errors, prefix = [], result = {}) {
|
|
9
9
|
Object.entries(errors || {}).forEach(([key, value]) => {
|
|
@@ -26,10 +26,10 @@ export const DuskmoonReactForm = {
|
|
|
26
26
|
},
|
|
27
27
|
mountForm() {
|
|
28
28
|
this.form = this.el.querySelector("[data-dm-react-form]");
|
|
29
|
-
|
|
29
|
+
this.fields = [...this.el.querySelectorAll("[data-dm-react-field]")];
|
|
30
|
+
const nativeValues = collectValues(this.nativeControls());
|
|
30
31
|
this.values = { ...nativeValues, ...parseJSON(this.el.dataset.initialValues) };
|
|
31
32
|
this.initialValues = structuredClone(this.values);
|
|
32
|
-
this.fields = [...this.el.querySelectorAll("[data-dm-react-field]")];
|
|
33
33
|
const fieldKeys = this.fields.map((element) => fieldKey(parseJSON(element.dataset.dmReactField).name));
|
|
34
34
|
if (new Set(fieldKeys).size !== fieldKeys.length) throw new Error("React form field names must be unique");
|
|
35
35
|
this.mountFields(); this.bindNative(); this.bindForm();
|
|
@@ -42,6 +42,7 @@ export const DuskmoonReactForm = {
|
|
|
42
42
|
this.renderSchema();
|
|
43
43
|
},
|
|
44
44
|
renderSchema() {
|
|
45
|
+
// TODO(upstream): duskmoon-dev/duskmoon-react#85 - JsonSchemaForm has no native form class prop.
|
|
45
46
|
this.schemaRoot.render(React.createElement(ReactSchemaForm, {
|
|
46
47
|
key: this.schemaRevision, compiled: this.compiledSchema, value: this.values,
|
|
47
48
|
defaultValue: this.initialValues, errors: this.errors, disabled: this.disconnectedState,
|
|
@@ -68,18 +69,23 @@ export const DuskmoonReactForm = {
|
|
|
68
69
|
onChange: (value) => this.change(config.name, value),
|
|
69
70
|
}));
|
|
70
71
|
},
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
nativeControls() {
|
|
73
|
+
const managedPaths = this.fields.map((element) => pathOf(parseJSON(element.dataset.dmReactField).name).map(String));
|
|
74
|
+
return [...this.form.querySelectorAll("input[name], select[name], textarea[name]")].filter((element) => {
|
|
75
|
+
if (element.closest("[data-dm-react-field]")) return false;
|
|
76
|
+
const path = pathOf(element.name).map(String);
|
|
77
|
+
return !managedPaths.some((managed) => managed.slice(0, Math.min(path.length, managed.length)).every((key, index) => key === path[index]));
|
|
76
78
|
});
|
|
77
79
|
},
|
|
80
|
+
bindNative() {
|
|
81
|
+
this.nativeHandler = (event) => {
|
|
82
|
+
const controls = this.nativeControls(); const element = event.target;
|
|
83
|
+
if (controls.includes(element)) this.change(element.name, nativeValue(element, controls));
|
|
84
|
+
};
|
|
85
|
+
this.form.addEventListener("input", this.nativeHandler); this.form.addEventListener("change", this.nativeHandler);
|
|
86
|
+
},
|
|
78
87
|
nativeValue(element) {
|
|
79
|
-
|
|
80
|
-
if (element.type === "number") return element.value === "" ? null : Number(element.value);
|
|
81
|
-
if (element.multiple) return [...element.selectedOptions].map((option) => option.value);
|
|
82
|
-
return element.value;
|
|
88
|
+
return nativeValue(element, this.nativeControls());
|
|
83
89
|
},
|
|
84
90
|
bindForm() {
|
|
85
91
|
this.submitHandler = (event) => { event.preventDefault(); this.submit(); };
|
|
@@ -94,7 +100,15 @@ export const DuskmoonReactForm = {
|
|
|
94
100
|
const event = this.el.getAttribute("phx-change"); if (!event || this.disconnectedState) return;
|
|
95
101
|
clearTimeout(this.changeTimer); this.changeTimer = setTimeout(() => this.send(event, name), Number(this.el.getAttribute("phx-debounce") || 0));
|
|
96
102
|
},
|
|
97
|
-
submit() {
|
|
103
|
+
submit() {
|
|
104
|
+
clearTimeout(this.changeTimer); const event = this.el.getAttribute("phx-submit"); if (!event || this.disconnectedState) return;
|
|
105
|
+
if (this.form) {
|
|
106
|
+
if (this.form.noValidate === false && !this.form.reportValidity()) return;
|
|
107
|
+
const controls = this.nativeControls();
|
|
108
|
+
controls.forEach((element) => { this.values = setAt(this.values, element.name, nativeValue(element, controls)); });
|
|
109
|
+
}
|
|
110
|
+
this.send(event);
|
|
111
|
+
},
|
|
98
112
|
send(event, changed) {
|
|
99
113
|
const revision = ++this.revision; const values = this.values;
|
|
100
114
|
const payload = { id: this.el.id, values, revision, ...(changed ? { changed: pathOf(changed) } : {}) };
|
|
@@ -109,9 +123,9 @@ export const DuskmoonReactForm = {
|
|
|
109
123
|
clearTimeout(this.changeTimer);
|
|
110
124
|
this.values = structuredClone(values); this.errors = {}; this.revision++;
|
|
111
125
|
if (this.schemaRoot) { this.schemaRevision++; this.renderAll(); return; }
|
|
112
|
-
this.
|
|
126
|
+
this.nativeControls().forEach((element) => setNativeValue(element, getAt(this.values, element.name))); this.renderAll();
|
|
113
127
|
},
|
|
114
128
|
disconnected() { clearTimeout(this.changeTimer); this.disconnectedState = true; this.renderAll(); },
|
|
115
129
|
reconnected() { this.disconnectedState = false; this.renderAll(); },
|
|
116
|
-
destroyed() { this.destroyedState = true; clearTimeout(this.changeTimer); this.schemaRoot?.unmount(); this.form?.removeEventListener("submit", this.submitHandler); this.form?.removeEventListener("reset", this.resetHandler); this.
|
|
130
|
+
destroyed() { this.destroyedState = true; clearTimeout(this.changeTimer); this.schemaRoot?.unmount(); this.form?.removeEventListener("submit", this.submitHandler); this.form?.removeEventListener("reset", this.resetHandler); this.form?.removeEventListener("input", this.nativeHandler); this.form?.removeEventListener("change", this.nativeHandler); this.roots.forEach(({ root }) => root.unmount()); },
|
|
117
131
|
};
|
|
@@ -17,13 +17,27 @@ export function setAt(values, name, value) {
|
|
|
17
17
|
return result;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function isCheckboxGroup(element) {
|
|
21
|
+
return element.type === "checkbox" && element.name.endsWith("[]");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function nativeValue(element, elements = [element]) {
|
|
25
|
+
if (isCheckboxGroup(element)) {
|
|
26
|
+
return elements.filter((control) => control.type === "checkbox" && control.name === element.name && control.checked)
|
|
27
|
+
.map((control) => control.value);
|
|
28
|
+
}
|
|
29
|
+
if (element.type === "checkbox") return Boolean(element.checked);
|
|
30
|
+
if (element.type === "number") return element.value === "" ? null : Number(element.value);
|
|
31
|
+
if (element.multiple) return [...element.selectedOptions].map((option) => option.value);
|
|
32
|
+
return element.value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setNativeValue(element, value) {
|
|
36
|
+
if (element.type === "checkbox") {
|
|
37
|
+
element.checked = isCheckboxGroup(element) ? Array.isArray(value) && value.includes(element.value) : Boolean(value);
|
|
38
|
+
} else element.value = value ?? "";
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
export function collectValues(elements) {
|
|
21
|
-
return elements.reduce((values, element) => {
|
|
22
|
-
let value;
|
|
23
|
-
if (element.type === "checkbox") value = Boolean(element.checked);
|
|
24
|
-
else if (element.type === "number") value = element.value === "" ? null : Number(element.value);
|
|
25
|
-
else if (element.multiple) value = [...element.selectedOptions].map((option) => option.value);
|
|
26
|
-
else value = element.value;
|
|
27
|
-
return setAt(values, element.name, value);
|
|
28
|
-
}, {});
|
|
42
|
+
return elements.reduce((values, element) => setAt(values, element.name, nativeValue(element, elements)), {});
|
|
29
43
|
}
|