phoenix_duskmoon 9.16.14 → 9.16.16

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,9 +1,20 @@
1
1
  # Unreleased
2
2
 
3
+ ### Breaking changes
4
+
5
+ - Remove the six `dm_git_*` repository components, their automatic imports,
6
+ Storybook demos and delegated Git clipboard runtime. Git presentation is now
7
+ application-owned. There is no replacement published package; see
8
+ [the migration guide](guides/migrating-git-components.md) before upgrading.
9
+
3
10
  ### Added
4
11
 
5
12
  - Public React Chat hook factory, custom composer and renderer integration, and
6
13
  application metadata, runtime and admission state across snapshots and events.
14
+ - Opt-in browser constraint validation for classic React forms with
15
+ `novalidate={false}`; schema forms retain JSON Schema validation.
16
+ - Independent classic React form layout through `form_class`, preserving outer
17
+ wrapper `class`; document the upstream schema-form styling limitation.
7
18
  - Native Core components: carousel, countdown, diff, keyboard keys, radial progress,
8
19
  floating actions, swap, filter group, hero, indicator, join, mask, sidebar layout,
9
20
  stack and megamenu, with Storybook examples.
@@ -32,6 +43,17 @@
32
43
 
33
44
  ### Fixed
34
45
 
46
+ - Hide the simple appbar's mobile toggle and navigation when no menu entries exist,
47
+ preserving its logo and user-profile slots.
48
+ - Generate React Chat submission IDs on HTTP LAN origins with a cryptographic
49
+ UUIDv4 fallback when the native `crypto.randomUUID()` API is unavailable.
50
+ - Forward React form field attributes to their interactive controls and preserve
51
+ application-disabled fields when the LiveView connection recovers.
52
+ - Preserve selected-value arrays for native checkbox groups named with `[]`,
53
+ including nested paths, empty selections and per-checkbox reset state.
54
+ - Read current native controls before React form submission, including
55
+ programmatically changed hidden inputs, and delegate events for replaced or
56
+ newly added controls while preserving React-owned values.
35
57
  - Resolve production chunk imports by their importing module, preventing identical
36
58
  relative paths in unrelated packages from becoming missing browser assets.
37
59
  - Preserve lazy custom-element registration and resolve preload URLs relative to
package/README.md CHANGED
@@ -14,6 +14,11 @@ Requires Elixir `>= 1.18` and `tailwindcss >= 4.0`.
14
14
 
15
15
  See the [docs](https://hexdocs.pm/phoenix_duskmoon/) for more information.
16
16
 
17
+ The unreleased removal of Git repository components is a breaking API change.
18
+ Applications using `dm_git_*` helpers or the Git clipboard runtime must migrate
19
+ to application-owned components before upgrading; see the
20
+ [Git component migration guide](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/blob/main/apps/phoenix_duskmoon/guides/migrating-git-components.md).
21
+
17
22
 
18
23
  ## Install
19
24
 
@@ -11,13 +11,9 @@ const DM_EVENTS = [
11
11
  "dm-submit", "dm-select", "dm-close", "dm-open", "dm-toggle",
12
12
  ];
13
13
 
14
- const COPY_SELECTOR = "[data-copy-value]";
15
- const COPY_FEEDBACK_DURATION = 2000;
16
- const COPY_LISTENER = Symbol.for("phoenix-duskmoon.copy-listener");
17
14
  const DIALOG_SELECTOR = 'dialog[data-dm-confirm-dialog="true"]';
18
15
  const DIALOG_CONFIRM_SELECTOR = '[data-dm-confirm-action="true"]';
19
16
  const DIALOG_BEHAVIOR_LISTENERS = Symbol.for("phoenix-duskmoon.dialog-behavior-listeners");
20
- const copyFeedback = new WeakMap();
21
17
 
22
18
  /**
23
19
  * WebComponentHook - Universal LiveView ↔ Custom Element bridge
@@ -262,106 +258,6 @@ export const FormElementHook = {
262
258
  },
263
259
  };
264
260
 
265
- /** Copy text with the Clipboard API, falling back only when it is unavailable. */
266
- export async function copyTextToClipboard(value, environment = globalThis) {
267
- const clipboard = environment.navigator?.clipboard;
268
-
269
- if (typeof clipboard?.writeText === "function") {
270
- await clipboard.writeText(value);
271
- return;
272
- }
273
-
274
- const document = environment.document;
275
-
276
- if (!document?.body || typeof document.execCommand !== "function") {
277
- throw new Error("Clipboard API is unavailable");
278
- }
279
-
280
- const textArea = document.createElement("textarea");
281
- const activeElement = document.activeElement;
282
- textArea.value = value;
283
- textArea.setAttribute("readonly", "");
284
- textArea.style.position = "fixed";
285
- textArea.style.opacity = "0";
286
- document.body.appendChild(textArea);
287
-
288
- try {
289
- textArea.focus();
290
- textArea.select();
291
-
292
- if (!document.execCommand("copy")) {
293
- throw new Error("Clipboard copy command failed");
294
- }
295
- } finally {
296
- document.body.removeChild(textArea);
297
- activeElement?.focus?.();
298
- }
299
- }
300
-
301
- function copyStatus(button) {
302
- const sibling = button.nextElementSibling;
303
-
304
- if (sibling?.matches?.("[data-copy-status]")) {
305
- return sibling;
306
- }
307
-
308
- return button.parentElement?.querySelector?.("[data-copy-status]") ?? null;
309
- }
310
-
311
- function showCopyFeedback(button, message, state, options) {
312
- const label = button.querySelector?.("[data-copy-label]");
313
- const status = copyStatus(button);
314
- const previous = copyFeedback.get(button);
315
- const cancelReset = options.cancelReset ?? globalThis.clearTimeout;
316
- const scheduleReset = options.scheduleReset ?? globalThis.setTimeout;
317
-
318
- if (previous?.timer !== undefined) {
319
- cancelReset(previous.timer);
320
- }
321
-
322
- const originalLabel = previous?.originalLabel ?? label?.textContent ?? "Copy";
323
-
324
- if (label) label.textContent = message;
325
- if (status) status.textContent = message;
326
- button.dataset.copyState = state;
327
-
328
- const timer = scheduleReset(() => {
329
- if (label) label.textContent = originalLabel;
330
- if (status) status.textContent = "";
331
- delete button.dataset.copyState;
332
- copyFeedback.delete(button);
333
- }, COPY_FEEDBACK_DURATION);
334
-
335
- copyFeedback.set(button, { originalLabel, timer });
336
- }
337
-
338
- /** Handle clicks from any descendant of a generated copy control. */
339
- export async function handleClipboardClick(event, options = {}) {
340
- const button = event.target?.closest?.(COPY_SELECTOR);
341
-
342
- if (!button || button.disabled || button.getAttribute?.("aria-disabled") === "true") {
343
- return;
344
- }
345
-
346
- const copy = options.copy ?? copyTextToClipboard;
347
-
348
- try {
349
- await copy(button.dataset.copyValue);
350
- showCopyFeedback(button, button.dataset.copySuccess ?? "Copied", "success", options);
351
- } catch (_error) {
352
- showCopyFeedback(button, button.dataset.copyFailure ?? "Copy failed", "error", options);
353
- }
354
- }
355
-
356
- /** Install one delegated listener so LiveView-patched copy controls also work. */
357
- export function installClipboardBehavior(root = globalThis.document, options = {}) {
358
- if (!root?.addEventListener || root[COPY_LISTENER]) return;
359
-
360
- const listener = (event) => handleClipboardClick(event, options);
361
- root.addEventListener("click", listener);
362
- root[COPY_LISTENER] = listener;
363
- }
364
-
365
261
  /** Close the current confirm dialog before submit/reset/custom command default actions. */
366
262
  export function handleDialogConfirmClick(event) {
367
263
  const path = event.composedPath?.() ?? [];
@@ -379,7 +275,6 @@ export function installDialogBehavior(root = globalThis.document) {
379
275
  root[DIALOG_BEHAVIOR_LISTENERS] = handleDialogConfirmClick;
380
276
  }
381
277
 
382
- installClipboardBehavior();
383
278
  installDialogBehavior();
384
279
 
385
280
  // Export to window for non-module usage
@@ -131,6 +131,21 @@ const Message = memo(function Message(props) {
131
131
  );
132
132
  });
133
133
 
134
+ function requestId() {
135
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
136
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
137
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
138
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
139
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
140
+ return [
141
+ hex.slice(0, 4).join(""),
142
+ hex.slice(4, 6).join(""),
143
+ hex.slice(6, 8).join(""),
144
+ hex.slice(8, 10).join(""),
145
+ hex.slice(10).join(""),
146
+ ].join("-");
147
+ }
148
+
134
149
  // A composer owns its draft/attachments. This helper only owns admission and UI requests.
135
150
  export function useChatSubmission({ bridge, canSubmit }) {
136
151
  const [busy, setBusy] = useState(false);
@@ -151,7 +166,7 @@ export function useChatSubmission({ bridge, canSubmit }) {
151
166
  setNotice("");
152
167
  try {
153
168
  return await bridge.send("send", {
154
- request_id: crypto.randomUUID(),
169
+ request_id: requestId(),
155
170
  ...payload,
156
171
  });
157
172
  } catch (error) {
@@ -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
- control = h(InputNumber, { ...common, value: value ?? null, onChange });
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
- control = h(Select, { ...common, value: value ?? undefined, mode: config.type === "multiselect" ? "multiple" : undefined,
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
- const nativeValues = collectValues([...this.form.querySelectorAll("[name]")]);
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
- 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;
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
- 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;
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() { clearTimeout(this.changeTimer); const event = this.el.getAttribute("phx-submit"); if (event && !this.disconnectedState) this.send(event); },
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.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();
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.native?.forEach((element) => { element.removeEventListener("input", element.__dmReactHandler); element.removeEventListener("change", element.__dmReactHandler); }); this.roots.forEach(({ root }) => root.unmount()); },
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_duskmoon",
3
- "version": "9.16.14",
3
+ "version": "9.16.16",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./priv/static/assets/js/phoenix_duskmoon.js",