phoenix_duskmoon 9.14.0 → 9.16.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/README.md CHANGED
@@ -143,17 +143,17 @@ Components that require hooks:
143
143
  ## Available Components
144
144
 
145
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, keyboard keys, flash, git repository, list, markdown, markdown body, pagination, popover, progress, radial progress, skeleton, stat, table, timeline, tooltip
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
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, megamenu, navbar, nested menu, page footer, page header, stepper, steps, tabs
150
- - **Layout**: bottom sheet, divider, drawer, hero, indicator, join, mask, sidebar layout, stack, 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
154
  ## Upstream design alignment
155
155
 
156
- The current frontend uses Core/CSS Art 1.19.9 and Elements/Art Elements 1.7.6.
156
+ The current frontend uses Core/CSS Art 1.19.10 and Elements/Art Elements 1.8.0.
157
157
  Individually published element dependencies are pinned to their current versions;
158
158
  see the [contract audit](apps/phoenix_duskmoon/guides/upstream-design-alignment.md) for the exact set.
159
159
 
@@ -163,10 +163,10 @@ native scrolling, swap uses a checkbox, floating actions and megamenu
163
163
  use the browser Popover API. No element registration is needed for these primitives.
164
164
 
165
165
  ```heex
166
- <.dm_indicator>
167
- <:indicator><span class="badge badge-primary">3</span></:indicator>
166
+ <div class="indicator">
167
+ <span class="indicator-item badge badge-primary" aria-label="3 unread messages">3</span>
168
168
  <button type="button" class="btn">Inbox</button>
169
- </.dm_indicator>
169
+ </div>
170
170
 
171
171
  <.dm_sidebar_layout>
172
172
  <:sidebar><nav aria-label="Workspace">Workspace links</nav></:sidebar>
@@ -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";
@@ -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
+ };