phoenix_duskmoon 9.13.1 → 9.16.0

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 CHANGED
@@ -1,3 +1,21 @@
1
+ # Unreleased
2
+
3
+ ### Added
4
+
5
+ - Native Core components: carousel, countdown, diff, keyboard keys, radial progress,
6
+ floating actions, swap, filter group, hero, indicator, join, mask, sidebar layout,
7
+ stack and megamenu, with Storybook examples.
8
+ - Chat scroll container and timeline setting; chip interaction attributes and events.
9
+
10
+ ### Changed
11
+
12
+ - Pin published DuskMoon dependencies to current exact versions: Core/CSS Art
13
+ 1.19.9, Elements/Art Elements 1.7.6 and current individual element versions.
14
+ - Use Core spinner/stat styles and remove resolved collapse, highlighting and
15
+ duplicate form-submission workarounds. Preserve existing navigation and overlay APIs.
16
+ - Load Storybook JavaScript as an ES module and resolve workspace assets independently
17
+ of the umbrella reloader's working directory, including hoisted production dependencies.
18
+
1
19
  # [9.0.0-rc.1](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v9.0.0-rc.0...v9.0.0-rc.1) (2026-02-28)
2
20
 
3
21
 
package/README.md CHANGED
@@ -142,15 +142,44 @@ Components that require hooks:
142
142
 
143
143
  ## Available Components
144
144
 
145
- - **Action**: buttons, dropdowns, links, menus, toggles
146
- - **Data Display**: accordion, avatar, badge, card, chip, collapse, datetime, flash, git repository, list, markdown, markdown body, pagination, popover, progress, skeleton, stat, table, timeline, tooltip
147
- - **Data Entry**: autocomplete, cascader, checkbox, compact input, file upload, form, input, multi-select, OTP input, PIN input, radio, rating, segment control, select, slider, switch, textarea, time input, tree select
145
+ - **Action**: buttons, dropdowns, floating actions, links, menus, swaps, toggles
146
+ - **Data Display**: accordion, avatar, badge, card, carousel, chat, chat scroll, chip, collapse, countdown, datetime, diff, flash, git repository, list, markdown, markdown body, pagination, popover, progress, radial progress, skeleton, stat, table, timeline, tooltip
147
+ - **Data Entry**: autocomplete, cascader, checkbox, compact input, file upload, filter group, form, input, multi-select, OTP input, PIN input, radio, rating, segment control, select, slider, switch, textarea, time input, tree select
148
148
  - **Feedback**: dialog, loading, snackbar, toast
149
- - **Navigation**: actionbar, appbar, bottom nav, breadcrumb, left menu, navbar, nested menu, page footer, page header, stepper, steps, tabs
150
- - **Layout**: bottom sheet, divider, drawer, theme switcher
149
+ - **Navigation**: actionbar, appbar, bottom nav, breadcrumb, left menu, megamenu, navbar, page footer, page header, stepper, tabs
150
+ - **Layout**: bottom sheet, divider, drawer, sidebar layout, theme switcher
151
151
  - **CSS Art**: button noise, eclipse, plasma ball, signature, snow, spotlight search
152
152
 
153
153
 
154
+ ## Upstream design alignment
155
+
156
+ The current frontend uses Core/CSS Art 1.19.10 and Elements/Art Elements 1.8.0.
157
+ Individually published element dependencies are pinned to their current versions;
158
+ see the [contract audit](apps/phoenix_duskmoon/guides/upstream-design-alignment.md) for the exact set.
159
+
160
+ The new Core primitives render native HTML and use the packaged CSS. Applications
161
+ own countdown timing, filter state, progress values and layout state. Carousel uses
162
+ native scrolling, swap uses a checkbox, floating actions and megamenu
163
+ use the browser Popover API. No element registration is needed for these primitives.
164
+
165
+ ```heex
166
+ <div class="indicator">
167
+ <span class="indicator-item badge badge-primary" aria-label="3 unread messages">3</span>
168
+ <button type="button" class="btn">Inbox</button>
169
+ </div>
170
+
171
+ <.dm_sidebar_layout>
172
+ <:sidebar><nav aria-label="Workspace">Workspace links</nav></:sidebar>
173
+ <main>Page content</main>
174
+ </.dm_sidebar_layout>
175
+ ```
176
+
177
+ Existing `dm_menu`, `dm_drawer` and `dm_bottom_sheet` retain their Elements methods
178
+ and events. `dm_loading_spinner` now follows Core's reduced-motion behavior, and
179
+ `dm_stat` uses its semantic stat layout. Chat scroll and chip event examples are
180
+ available in Storybook. Browser `File` objects from chat events require the
181
+ application's upload mechanism; they cannot be sent as LiveView JSON payloads.
182
+
154
183
  ## Live Storybook
155
184
 
156
185
  [Live Storybook](https://duskmoon-storybook.gsmlg.dev)
@@ -29,12 +29,10 @@ el-dm-art-snowball-preloader,
29
29
  el-dm-art-sun,
30
30
  el-dm-art-synthwave-starfield,
31
31
  el-dm-autocomplete,
32
- el-dm-badge,
33
32
  el-dm-bottom-navigation,
34
33
  el-dm-bottom-sheet,
35
34
  el-dm-breadcrumbs,
36
35
  el-dm-button,
37
- el-dm-card,
38
36
  el-dm-cascader,
39
37
  el-dm-chip,
40
38
  el-dm-circle-menu,
@@ -60,7 +58,6 @@ el-dm-progress,
60
58
  el-dm-segment-control,
61
59
  el-dm-select,
62
60
  el-dm-slider,
63
- el-dm-stepper,
64
61
  el-dm-switch,
65
62
  el-dm-table,
66
63
  el-dm-tabs,
@@ -3,6 +3,8 @@
3
3
  @source "../../lib/**/*.ex";
4
4
 
5
5
  @import "tailwindcss";
6
+ @import "@duskmoon-dev/core/base.css";
7
+ @import "@duskmoon-dev/components/styles.css";
6
8
 
7
9
  /* DuskMoon UI design system — syntactic sugar for @duskmoon-dev/core */
8
10
  @plugin "@duskmoon-dev/core/plugin";
@@ -11,13 +13,6 @@
11
13
  @import "@duskmoon-dev/core/components";
12
14
  @import "./element-theme-bridge.css";
13
15
 
14
- @layer components {
15
- /* WORKAROUND(upstream): duskmoon-dev/duskmoonui#43 */
16
- .collapse-card:not(.collapse-open):not(.show) .collapse-content > * {
17
- padding-block-end: 0;
18
- }
19
- }
20
-
21
16
  /* CSS Art — decorative/animated visual effects from @duskmoon-dev/css-art */
22
17
  @import "@duskmoon-dev/css-art/dist/art/atom.css";
23
18
  @import "@duskmoon-dev/css-art/dist/art/cat-stargazer.css";
@@ -0,0 +1,225 @@
1
+ import React, { memo, useEffect, useId, useRef, useState } from "react";
2
+ import { Markdown } from "@duskmoon-dev/components/markdown";
3
+ import { Input } from "@duskmoon-dev/components/input";
4
+ import { Button } from "@duskmoon-dev/components/button";
5
+ import { Chat } from "@duskmoon-dev/components/chat";
6
+ const h = React.createElement;
7
+
8
+ const Message = memo(function Message({ message, bridge, targetId, timeline }) {
9
+ const [notice, setNotice] = useState("");
10
+ return h(
11
+ Chat,
12
+ {
13
+ id: targetId,
14
+ role: "article",
15
+ placement: message.role === "user" ? "end" : "start",
16
+ timeline,
17
+ "data-message-id": message.id,
18
+ },
19
+ h(Chat.Header, null, message.author || message.role),
20
+ h(
21
+ Chat.Bubble,
22
+ { streaming: message.status === "streaming" },
23
+ ...(message.tools || []).map((tool) =>
24
+ h(
25
+ Chat.Tool,
26
+ { key: tool.id, status: tool.status || "running" },
27
+ h(Chat.ToolHeader, null, `${tool.name || tool.id}: ${tool.status || "running"}`),
28
+ h(
29
+ tool.result == null ? Chat.ToolCall : Chat.ToolResult,
30
+ null,
31
+ h("pre", null, JSON.stringify(tool.result ?? tool.input ?? {}, null, 2)),
32
+ ),
33
+ ),
34
+ ),
35
+ h(Markdown, { className: "chat-bubble-content", markdown: message.content || "" }),
36
+ ),
37
+ message.status === "streaming" &&
38
+ h(Chat.Status, { role: "status" }, h(Chat.StatusItem, null, "Generating…")),
39
+ message.error && h("p", { role: "alert" }, message.error),
40
+ h(
41
+ Chat.Footer,
42
+ null,
43
+ h(
44
+ Chat.Actions,
45
+ null,
46
+ h(
47
+ Button,
48
+ {
49
+ type: "button",
50
+ appearance: "ghost",
51
+ onClick: async () => {
52
+ try {
53
+ await navigator.clipboard.writeText(message.content || "");
54
+ setNotice("Copied");
55
+ } catch {
56
+ setNotice("Copy failed");
57
+ }
58
+ },
59
+ },
60
+ "Copy",
61
+ ),
62
+ ["error", "cancelled"].includes(message.status) &&
63
+ h(
64
+ Button,
65
+ {
66
+ type: "button",
67
+ appearance: "ghost",
68
+ onClick: () =>
69
+ bridge
70
+ .send("retry", { message_id: message.id })
71
+ .catch((error) => setNotice(error.message)),
72
+ },
73
+ "Retry",
74
+ ),
75
+ ...(message.actions || []).map((action) =>
76
+ h(
77
+ Button,
78
+ {
79
+ key: action.id,
80
+ type: "button",
81
+ appearance: "ghost",
82
+ onClick: () =>
83
+ bridge
84
+ .send("action", { message_id: message.id, action: action.id })
85
+ .catch((error) => setNotice(error.message)),
86
+ },
87
+ action.label,
88
+ ),
89
+ ),
90
+ ),
91
+ ),
92
+ h("span", { role: "status" }, notice),
93
+ );
94
+ });
95
+
96
+ export function ReactChat({ state, bridge, connected, label }) {
97
+ const transcriptId = useId();
98
+ const [draft, setDraft] = useState("");
99
+ const [busy, setBusy] = useState(false);
100
+ const [notice, setNotice] = useState("");
101
+ const pending = useRef(false);
102
+ const alive = useRef(true);
103
+ const scroll = useRef(null);
104
+ const follow = useRef(true);
105
+ useEffect(
106
+ () => () => {
107
+ alive.current = false;
108
+ },
109
+ [],
110
+ );
111
+ useEffect(() => {
112
+ if (follow.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
113
+ }, [state.messages]);
114
+ const streaming = state.messages.some((message) => message.status === "streaming");
115
+ const replies = state.messages.filter((message) => message.role === "assistant").slice(-24);
116
+ const timelines = new Map(replies.map((message, index) => [message.id, index + 1]));
117
+ const targetId = (message) => `${transcriptId}-message-${encodeURIComponent(message.id)}`;
118
+ async function submit(event) {
119
+ event.preventDefault();
120
+ if (!draft.trim() || pending.current || streaming || !connected || state.needsSync) return;
121
+ const text = draft;
122
+ pending.current = true;
123
+ setBusy(true);
124
+ setNotice("");
125
+ try {
126
+ await bridge.send("send", { text, request_id: crypto.randomUUID() });
127
+ if (alive.current) setDraft((current) => (current === text ? "" : current));
128
+ } catch (error) {
129
+ if (alive.current) setNotice(error.message);
130
+ } finally {
131
+ pending.current = false;
132
+ if (alive.current) setBusy(false);
133
+ }
134
+ }
135
+ return h(
136
+ "section",
137
+ { className: "flex flex-col gap-3 h-full", "aria-label": label },
138
+ h(
139
+ Chat.Scroll,
140
+ {
141
+ ref: scroll,
142
+ className: "flex-1 min-h-0",
143
+ "data-chat-messages": true,
144
+ onScroll: () => {
145
+ const el = scroll.current;
146
+ follow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
147
+ },
148
+ },
149
+ h(
150
+ Chat.ScrollTrack,
151
+ { role: "navigation", "aria-label": "Assistant replies" },
152
+ replies.map((message) =>
153
+ h(Chat.ScrollIndicator, {
154
+ key: message.id,
155
+ targetId: targetId(message),
156
+ timeline: timelines.get(message.id),
157
+ onClick: () => {
158
+ follow.current = false;
159
+ },
160
+ }),
161
+ ),
162
+ ),
163
+ h(
164
+ Chat.ScrollBody,
165
+ { className: "gap-3" },
166
+ state.messages.map((message) =>
167
+ h(Message, {
168
+ key: message.id,
169
+ message,
170
+ bridge,
171
+ targetId: targetId(message),
172
+ timeline: timelines.get(message.id),
173
+ }),
174
+ ),
175
+ ),
176
+ ),
177
+ h(
178
+ Button,
179
+ {
180
+ type: "button",
181
+ appearance: "ghost",
182
+ onClick: () => {
183
+ follow.current = true;
184
+ scroll.current.scrollTop = scroll.current.scrollHeight;
185
+ },
186
+ },
187
+ "Jump to latest",
188
+ ),
189
+ (!connected || state.needsSync) &&
190
+ h(
191
+ "p",
192
+ { role: "status" },
193
+ connected ? "Synchronizing conversation…" : "Disconnected. Draft preserved.",
194
+ ),
195
+ notice && h("p", { role: "alert" }, notice),
196
+ h(
197
+ "form",
198
+ { className: "flex gap-2", onSubmit: submit },
199
+ h(Input, {
200
+ value: draft,
201
+ onChange: (event) => setDraft(event.target.value),
202
+ "aria-label": "Message",
203
+ placeholder: "Write a message",
204
+ }),
205
+ h(
206
+ Button,
207
+ {
208
+ type: "submit",
209
+ disabled: busy || streaming || !connected || state.needsSync || !draft.trim(),
210
+ },
211
+ "Send",
212
+ ),
213
+ streaming &&
214
+ h(
215
+ Button,
216
+ {
217
+ type: "button",
218
+ disabled: !connected,
219
+ onClick: () => bridge.send("stop", {}).catch((error) => setNotice(error.message)),
220
+ },
221
+ "Stop",
222
+ ),
223
+ ),
224
+ );
225
+ }
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { ReactChat } from "./chat.js";
4
+ import { initialState, reduceChat } from "./state.js";
5
+
6
+ export const DuskmoonReactChat = {
7
+ mounted() {
8
+ this.config = JSON.parse(this.el.dataset.chat);
9
+ this.state = initialState(this.config.conversationId, this.config.messages);
10
+ this.root = createRoot(this.el);
11
+ this.connected = true;
12
+ this.requests = new Set();
13
+ this.bridge = { send: (action, payload) => this.sendChat(action, payload) };
14
+ this.eventRef = this.handleEvent(this.config.event, (event) => {
15
+ if (event.id !== this.el.id) return;
16
+ const previous = this.state;
17
+ this.state = reduceChat(this.state, event);
18
+ if (previous === this.state) return;
19
+ if (this.state.needsSync && !previous.needsSync) this.requestSync();
20
+ this.scheduleRender();
21
+ });
22
+ this.renderChat();
23
+ },
24
+ sendChat(action, payload) {
25
+ if (!this.connected || this.dead)
26
+ return Promise.reject(new Error("Chat is disconnected"));
27
+ return new Promise((resolve, reject) => {
28
+ const finish = (error, reply) => {
29
+ if (!this.requests.delete(request)) return;
30
+ clearTimeout(request.timer);
31
+ error ? reject(error) : resolve(reply);
32
+ };
33
+ const request = {
34
+ cancel: () =>
35
+ finish(new Error("Chat disconnected; check status before retrying")),
36
+ };
37
+ this.requests.add(request);
38
+ request.timer = setTimeout(
39
+ () =>
40
+ finish(
41
+ new Error("Chat request timed out; check status before retrying"),
42
+ ),
43
+ 15000,
44
+ );
45
+ const body = {
46
+ ...payload,
47
+ id: this.el.id,
48
+ conversation_id: this.state.conversationId,
49
+ };
50
+ const reply = (response) =>
51
+ finish(
52
+ response?.status === "ok"
53
+ ? null
54
+ : new Error(response?.message || "Chat request rejected"),
55
+ response,
56
+ );
57
+ try {
58
+ const event = this.config.actions[action];
59
+ if (this.config.target)
60
+ this.pushEventTo(this.config.target, event, body, reply);
61
+ else this.pushEvent(event, body, reply);
62
+ } catch (error) {
63
+ finish(error);
64
+ }
65
+ });
66
+ },
67
+ requestSync() {
68
+ this.sendChat("sync", { sequences: this.state.sequences }).catch(() => {});
69
+ },
70
+ scheduleRender() {
71
+ if (!this.frame)
72
+ this.frame = requestAnimationFrame(() => {
73
+ this.frame = null;
74
+ if (!this.dead) this.renderChat();
75
+ });
76
+ },
77
+ renderChat() {
78
+ this.root.render(
79
+ React.createElement(ReactChat, {
80
+ state: this.state,
81
+ bridge: this.bridge,
82
+ connected: this.connected,
83
+ label: this.config.label,
84
+ }),
85
+ );
86
+ },
87
+ disconnected() {
88
+ this.connected = false;
89
+ this.requests.forEach((request) => request.cancel());
90
+ this.renderChat();
91
+ },
92
+ reconnected() {
93
+ this.connected = true;
94
+ this.state = { ...this.state, needsSync: true };
95
+ this.renderChat();
96
+ this.requestSync();
97
+ },
98
+ destroyed() {
99
+ this.dead = true;
100
+ cancelAnimationFrame(this.frame);
101
+ this.removeHandleEvent(this.eventRef);
102
+ this.requests.forEach((request) => request.cancel());
103
+ this.root.unmount();
104
+ },
105
+ };
@@ -0,0 +1,84 @@
1
+ export function initialState(conversationId, messages = []) {
2
+ return {
3
+ conversationId,
4
+ messages,
5
+ sequences: {},
6
+ error: null,
7
+ needsSync: false,
8
+ };
9
+ }
10
+
11
+ export function reduceChat(state, event) {
12
+ if (event?.conversation_id !== state.conversationId) return state;
13
+ if (["chat.snapshot", "chat.reset"].includes(event.type)) {
14
+ return {
15
+ ...initialState(state.conversationId, event.messages || []),
16
+ sequences: event.sequences || {},
17
+ };
18
+ }
19
+ if (
20
+ !event.message_id ||
21
+ !Number.isSafeInteger(event.sequence) ||
22
+ event.sequence < 1
23
+ )
24
+ return state;
25
+ const last = state.sequences[event.message_id] || 0;
26
+ if (event.sequence <= last) return state;
27
+ if (event.sequence !== last + 1) return { ...state, needsSync: true };
28
+ if (state.needsSync) return state;
29
+ const index = state.messages.findIndex(
30
+ (message) => message.id === event.message_id,
31
+ );
32
+ let messages = state.messages;
33
+ if (event.type === "chat.message") {
34
+ if (!event.message || event.message.id !== event.message_id) return state;
35
+ messages =
36
+ index < 0
37
+ ? [...messages, event.message]
38
+ : messages.map((m, i) => (i === index ? event.message : m));
39
+ } else {
40
+ if (index < 0) return { ...state, needsSync: true };
41
+ const message = messages[index];
42
+ let next;
43
+ switch (event.type) {
44
+ case "chat.delta":
45
+ if (message.status !== "streaming" || typeof event.text !== "string")
46
+ return state;
47
+ next = { ...message, content: (message.content || "") + event.text };
48
+ break;
49
+ case "chat.tool":
50
+ if (!event.tool?.id) return state;
51
+ next = {
52
+ ...message,
53
+ tools: [
54
+ ...(message.tools || []).filter(
55
+ (tool) => tool.id !== event.tool.id,
56
+ ),
57
+ event.tool,
58
+ ],
59
+ };
60
+ break;
61
+ case "chat.complete":
62
+ next = {
63
+ ...message,
64
+ status: event.status === "cancelled" ? "cancelled" : "complete",
65
+ };
66
+ break;
67
+ case "chat.error":
68
+ next = {
69
+ ...message,
70
+ status: "error",
71
+ error: event.message || "Generation failed",
72
+ };
73
+ break;
74
+ default:
75
+ return state;
76
+ }
77
+ messages = messages.map((m, i) => (i === index ? next : m));
78
+ }
79
+ return {
80
+ ...state,
81
+ messages,
82
+ sequences: { ...state.sequences, [event.message_id]: event.sequence },
83
+ };
84
+ }
@@ -0,0 +1,40 @@
1
+ import React, { useId } from "react";
2
+ import { Form } from "@duskmoon-dev/components/form";
3
+ import { Input } from "@duskmoon-dev/components/input";
4
+ import { InputNumber } from "@duskmoon-dev/components/input-number";
5
+ import { Checkbox } from "@duskmoon-dev/components/checkbox";
6
+ import { Select } from "@duskmoon-dev/components/select";
7
+
8
+ const h = React.createElement;
9
+
10
+ export function ReactField({ config, value, errors, disabled, onChange }) {
11
+ const errorId = useId();
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 };
14
+ let control;
15
+
16
+ switch (config.type) {
17
+ case "checkbox":
18
+ control = h(Checkbox, { ...common, checked: Boolean(value), onChange: (event) => onChange(event.target.checked) });
19
+ break;
20
+ case "number":
21
+ control = h(InputNumber, { ...common, value: value ?? null, onChange });
22
+ break;
23
+ case "select":
24
+ case "multiselect":
25
+ control = h(Select, { ...common, value: value ?? undefined, mode: config.type === "multiselect" ? "multiple" : undefined,
26
+ options: config.options || [], showSearch: true, allowClear: true, onChange: (next) => onChange(next ?? null) });
27
+ break;
28
+ case "textarea":
29
+ control = h("textarea", { ...common, className: "textarea", value: value ?? "", onChange: (event) => onChange(event.target.value) });
30
+ break;
31
+ default:
32
+ control = h(Input, { ...common, type: config.type, value: value ?? "", onChange: (event) => onChange(event.target.value) });
33
+ }
34
+
35
+ return h(Form.Item, {
36
+ label,
37
+ className: errors?.length ? "form-item-error" : undefined,
38
+ help: errors?.length ? h(Form.ErrorList, { id: errorId, errors, role: "alert" }) : undefined,
39
+ }, control);
40
+ }
@@ -0,0 +1,117 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { ReactField } from "./component.js";
4
+ import { compileForm } from "@duskmoon-dev/components/json-schema-form";
5
+ import { ReactSchemaForm, changedPath, schemaErrors } from "./schema.js";
6
+ import { collectValues, fieldKey, getAt, pathOf, setAt } from "./values.js";
7
+ function parseJSON(value, fallback = {}) { try { return JSON.parse(value); } catch { return fallback; } }
8
+ function flattenErrors(errors, prefix = [], result = {}) {
9
+ Object.entries(errors || {}).forEach(([key, value]) => {
10
+ const path = [...prefix, key];
11
+ if (Array.isArray(value)) result[path.join(".")] = value;
12
+ else if (value && typeof value === "object") flattenErrors(value, path, result);
13
+ });
14
+ return result;
15
+ }
16
+
17
+ export const DuskmoonReactForm = {
18
+ mounted() {
19
+ this.revision = 0; this.roots = new Map(); this.disconnectedState = false;
20
+ this.schemaElement = this.el.querySelector("[data-dm-react-schema]");
21
+ if (this.schemaElement) this.mountSchema();
22
+ else this.mountForm();
23
+ this.handleEvent("dm:form:reset", (payload) => {
24
+ if (payload?.id === this.el.id) this.resetForm(payload.values ?? this.initialValues);
25
+ });
26
+ },
27
+ mountForm() {
28
+ this.form = this.el.querySelector("[data-dm-react-form]");
29
+ const nativeValues = collectValues([...this.form.querySelectorAll("[name]")]);
30
+ this.values = { ...nativeValues, ...parseJSON(this.el.dataset.initialValues) };
31
+ this.initialValues = structuredClone(this.values);
32
+ this.fields = [...this.el.querySelectorAll("[data-dm-react-field]")];
33
+ const fieldKeys = this.fields.map((element) => fieldKey(parseJSON(element.dataset.dmReactField).name));
34
+ if (new Set(fieldKeys).size !== fieldKeys.length) throw new Error("React form field names must be unique");
35
+ this.mountFields(); this.bindNative(); this.bindForm();
36
+ },
37
+ mountSchema() {
38
+ this.compiledSchema = compileForm(JSON.parse(this.el.dataset.schema));
39
+ this.values = parseJSON(this.el.dataset.initialValues, this.compiledSchema.initialValue());
40
+ this.initialValues = structuredClone(this.values);
41
+ this.schemaRevision = 0; this.schemaRoot = createRoot(this.schemaElement);
42
+ this.renderSchema();
43
+ },
44
+ renderSchema() {
45
+ this.schemaRoot.render(React.createElement(ReactSchemaForm, {
46
+ key: this.schemaRevision, compiled: this.compiledSchema, value: this.values,
47
+ defaultValue: this.initialValues, errors: this.errors, disabled: this.disconnectedState,
48
+ onChange: (values) => {
49
+ const path = changedPath(this.values, values);
50
+ this.values = structuredClone(values); this.errors = {}; this.renderAll(); this.scheduleChange(path);
51
+ },
52
+ onErrorsChange: () => { this.errors = {}; this.renderAll(); },
53
+ onSubmit: (values) => { this.values = structuredClone(values); this.submit(); },
54
+ onReset: () => this.resetForm(),
55
+ }));
56
+ },
57
+ mountFields() {
58
+ this.fields.forEach((element) => {
59
+ const config = parseJSON(element.dataset.dmReactField);
60
+ const key = fieldKey(config.name); const root = createRoot(element);
61
+ this.roots.set(key, { config, root }); this.renderField(element);
62
+ });
63
+ },
64
+ renderField(element) {
65
+ const config = parseJSON(element.dataset.dmReactField); const entry = this.roots.get(fieldKey(config.name));
66
+ entry?.root.render(React.createElement(ReactField, {
67
+ config, value: getAt(this.values, config.name), errors: this.errors?.[fieldKey(config.name)], disabled: this.disconnectedState,
68
+ onChange: (value) => this.change(config.name, value),
69
+ }));
70
+ },
71
+ bindNative() {
72
+ this.native = [...this.form.querySelectorAll("[name]")].filter((element) => !element.closest("[data-dm-react-field]"));
73
+ this.native.forEach((element) => {
74
+ const handler = () => this.change(element.name, this.nativeValue(element));
75
+ element.addEventListener("input", handler); element.addEventListener("change", handler); element.__dmReactHandler = handler;
76
+ });
77
+ },
78
+ nativeValue(element) {
79
+ if (element.type === "checkbox") return element.checked;
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;
83
+ },
84
+ bindForm() {
85
+ this.submitHandler = (event) => { event.preventDefault(); this.submit(); };
86
+ this.resetHandler = () => setTimeout(() => this.resetForm(), 0);
87
+ this.form.addEventListener("submit", this.submitHandler); this.form.addEventListener("reset", this.resetHandler);
88
+ },
89
+ change(name, value) {
90
+ this.values = setAt(this.values, name, value); this.errors = { ...this.errors, [fieldKey(name)]: [] }; this.renderAll();
91
+ this.scheduleChange(name);
92
+ },
93
+ scheduleChange(name) {
94
+ const event = this.el.getAttribute("phx-change"); if (!event || this.disconnectedState) return;
95
+ clearTimeout(this.changeTimer); this.changeTimer = setTimeout(() => this.send(event, name), Number(this.el.getAttribute("phx-debounce") || 0));
96
+ },
97
+ submit() { clearTimeout(this.changeTimer); const event = this.el.getAttribute("phx-submit"); if (event && !this.disconnectedState) this.send(event); },
98
+ send(event, changed) {
99
+ const revision = ++this.revision; const values = this.values;
100
+ const payload = { id: this.el.id, values, revision, ...(changed ? { changed: pathOf(changed) } : {}) };
101
+ const target = this.el.getAttribute("phx-target"); const callback = (reply) => {
102
+ if (this.destroyedState || revision !== this.revision || values !== this.values || !reply) return;
103
+ this.errors = this.schemaRoot ? schemaErrors(reply.errors) : flattenErrors(reply.errors); this.renderAll();
104
+ };
105
+ if (target) this.pushEventTo(target, event, payload, callback); else this.pushEvent(event, payload, callback);
106
+ },
107
+ renderAll() { if (this.schemaRoot) this.renderSchema(); else this.fields.forEach((element) => this.renderField(element)); },
108
+ resetForm(values = this.initialValues) {
109
+ clearTimeout(this.changeTimer);
110
+ this.values = structuredClone(values); this.errors = {}; this.revision++;
111
+ if (this.schemaRoot) { this.schemaRevision++; this.renderAll(); return; }
112
+ this.native.forEach((element) => { if (element.type === "checkbox") element.checked = Boolean(getAt(this.values, element.name)); else element.value = getAt(this.values, element.name) ?? ""; }); this.renderAll();
113
+ },
114
+ disconnected() { clearTimeout(this.changeTimer); this.disconnectedState = true; this.renderAll(); },
115
+ 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.native?.forEach((element) => { element.removeEventListener("input", element.__dmReactHandler); element.removeEventListener("change", element.__dmReactHandler); }); this.roots.forEach(({ root }) => root.unmount()); },
117
+ };