Skip to content

Commit 276d3ad

Browse files
committed
dev(core): align form components
1 parent 2750fae commit 276d3ad

243 files changed

Lines changed: 7587 additions & 2422 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ Generates apps with `corex.new` and asserts install paths. Requires `mix archive
8585
| `lib/components/` | Phoenix components (`Corex.*`), moduledoc, `attr` / `slot` |
8686
| `lib/components/<name>/` | Connect, anatomy, translation modules |
8787
| `assets/hooks/` | LiveView hooks (TypeScript, Zag.js) |
88-
| `assets/lib/` | Shared TS helpers (`util`, `respond-to`, `read-props`, …); tests in `assets/lib/*.test.ts` |
88+
| `assets/lib/` | Shared TS helpers (`util`, `respond-to`, `read-props`, …) |
89+
| `assets/test/lib/` | Unit tests for `assets/lib/` helpers |
8990
| `assets/components/` | Zag `Component` subclasses; colocated `*.test.ts` per module (helpers + smoke); all modules in `components-contract.test.ts` and `components-smoke.test.ts` |
9091
| `assets/hooks/` | LiveView hooks; hook-specific logic in `hooks/<name>.ts` + `hooks/<name>.test.ts`; wiring in `hooks-wiring.test.ts` |
9192
| `priv/design/corex/` | Corex Design tokens and component CSS (source of truth in the package) |
@@ -120,12 +121,12 @@ We use [Conventional Commits](https://www.conventionalcommits.org/) style when i
120121

121122
| Path | Expectation |
122123
| ---- | ----------- |
123-
| `assets/lib/*.ts` | Colocated `assets/lib/<name>.test.ts` for every module except `core.ts` (abstract base) |
124+
| `assets/lib/*.ts` | `assets/test/lib/<name>.test.ts` for every module except `core.ts` (abstract base) |
124125
| `assets/components/*.ts` | Tests under `assets/test/component/` (per-module + `components-contract.test.ts` + `components-smoke.test.ts`) |
125126
| `assets/hooks/*.ts` | Tests under `assets/test/hooks/` (per-hook lifecycle + parser tests + `hooks-contract.test.ts`) |
126127
| `assets/test/helpers/` | Shared DOM fixtures (`dom.ts`, `component-fixture.ts`, `component-smoke.ts`, `mock-live-socket.ts`, `expect-hook.ts`) |
127128

128-
New shared helper → `assets/lib/<name>.test.ts`. New component or hook tests → `assets/test/component/<name>.test.ts` or `assets/test/hooks/<name>.test.ts`. Export small pure functions from hooks when logic is not otherwise testable.
129+
New shared helper → `assets/test/lib/<name>.test.ts`. New component or hook tests → `assets/test/component/<name>.test.ts` or `assets/test/hooks/<name>.test.ts`. Export small pure functions from hooks when logic is not otherwise testable.
129130
| Design CSS | `mix assets.build`, visual check in e2e styling pages |
130131
| Moduledoc only | `mix docs` (fix any warnings) |
131132
| Installer | `cd installer && mix test` |

assets/components/angle-slider.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { connect, machine, type Props, type Api } from "@zag-js/angle-slider";
22
import { VanillaMachine } from "@zag-js/vanilla";
33
import { Component } from "../lib/core";
4+
import { syncHiddenInputValue } from "../lib/value-form-sync";
45

56
export class AngleSlider extends Component<Props, Api> {
67
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -26,7 +27,15 @@ export class AngleSlider extends Component<Props, Api> {
2627
const hiddenInputEl = this.el.querySelector<HTMLElement>(
2728
'[data-scope="angle-slider"][data-part="hidden-input"]'
2829
);
29-
if (hiddenInputEl) this.spreadProps(hiddenInputEl, this.api.getHiddenInputProps());
30+
if (hiddenInputEl instanceof HTMLInputElement) {
31+
syncHiddenInputValue(
32+
hiddenInputEl,
33+
this.el,
34+
String(this.api.value),
35+
(el, props) => this.spreadProps(el, props),
36+
this.api.getHiddenInputProps() as Record<string, unknown>
37+
);
38+
}
3039

3140
const controlEl = this.el.querySelector<HTMLElement>(
3241
'[data-scope="angle-slider"][data-part="control"]'

assets/components/checkbox.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { connect, machine, type Props, type Api } from "@zag-js/checkbox";
22
import { VanillaMachine } from "@zag-js/vanilla";
33
import { Component } from "../lib/core";
4+
import { syncCheckableHiddenInput } from "../lib/checkable-form-sync";
45

56
export class Checkbox extends Component<Props, Api> {
67
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -20,8 +21,14 @@ export class Checkbox extends Component<Props, Api> {
2021
const inputEl = rootEl.querySelector<HTMLElement>(
2122
':scope > [data-scope="checkbox"][data-part="hidden-input"]'
2223
);
23-
if (inputEl) {
24-
this.spreadProps(inputEl, this.api.getHiddenInputProps());
24+
if (inputEl instanceof HTMLInputElement) {
25+
syncCheckableHiddenInput(
26+
inputEl,
27+
this.el,
28+
this.api.checked === true,
29+
(el, props) => this.spreadProps(el, props),
30+
this.api.getHiddenInputProps() as Record<string, unknown>
31+
);
2532
}
2633

2734
const labelEl = rootEl.querySelector<HTMLElement>(

assets/components/color-picker.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { connect, machine, parse, type Props, type Api } from "@zag-js/color-picker";
22
import { VanillaMachine } from "@zag-js/vanilla";
33
import { Component } from "../lib/core";
4+
import { syncHiddenInputValue } from "../lib/value-form-sync";
45

56
export class ColorPicker extends Component<Props, Api> {
67
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -20,7 +21,15 @@ export class ColorPicker extends Component<Props, Api> {
2021
if (labelEl) this.spreadProps(labelEl, this.api.getLabelProps());
2122

2223
const hiddenInputEl = this.el.querySelector<HTMLInputElement>('[data-part="hidden-input"]');
23-
if (hiddenInputEl) this.spreadProps(hiddenInputEl, this.api.getHiddenInputProps());
24+
if (hiddenInputEl) {
25+
syncHiddenInputValue(
26+
hiddenInputEl,
27+
this.el,
28+
this.api.valueAsString ?? "",
29+
(el, props) => this.spreadProps(el, props),
30+
this.api.getHiddenInputProps() as Record<string, unknown>
31+
);
32+
}
2433

2534
const controlEl = this.el.querySelector<HTMLElement>('[data-part="control"]');
2635
if (controlEl) this.spreadProps(controlEl, this.api.getControlProps());

assets/components/combobox.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
} from "@zag-js/combobox";
1010
import { VanillaMachine } from "@zag-js/vanilla";
1111
import { Component } from "../lib/core";
12+
import { stripZagSubmitNames } from "../lib/form-field-array-submit";
13+
import { getString } from "../lib/util";
1214
import { itemValue, zagListCollectionConfig } from "../lib/list-collection";
1315
import { templatesContentRoot } from "../lib/util";
1416

@@ -299,8 +301,14 @@ export class Combobox extends Component<Props, Api> {
299301
if (hiddenInput) {
300302
const valueStr = this.hiddenInputValue();
301303
if (hiddenInput.value !== valueStr) hiddenInput.value = valueStr;
304+
if (getString(this.el, "submitName")) {
305+
hiddenInput.removeAttribute("name");
306+
hiddenInput.removeAttribute("form");
307+
}
302308
}
303309

310+
stripZagSubmitNames(this.el, "combobox", ["hidden-input", "input"]);
311+
304312
[
305313
"label",
306314
"control",

assets/components/file-upload.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ export function fileKeyFor(file: File): string {
2929
return zagFileId(`${file.name}-${file.size}`);
3030
}
3131

32+
export function labelFieldNameFor(fieldName: string): string {
33+
if (fieldName.includes("[")) {
34+
return fieldName.replace(/\[([^\]]+)\]$/, "[$1_label]");
35+
}
36+
37+
return `${fieldName}_label`;
38+
}
39+
40+
function setInputFiles(inputEl: HTMLInputElement, files: File[]): void {
41+
try {
42+
if (typeof window.DataTransfer !== "undefined") {
43+
const dataTransfer = new window.DataTransfer();
44+
for (const file of files) {
45+
dataTransfer.items.add(file);
46+
}
47+
inputEl.files = dataTransfer.files;
48+
}
49+
} catch {
50+
// ignore unsupported environments
51+
}
52+
}
53+
3254
export class FileUpload extends Component<Props, Api> {
3355
private previewCleanup = new Map<HTMLElement, VoidFunction>();
3456
private sentinelSnapshot = "";
@@ -159,9 +181,82 @@ export class FileUpload extends Component<Props, Api> {
159181
}
160182
}
161183

184+
this.syncFormSubmitInputs();
162185
this.touchSentinel();
163186
}
164187

188+
syncFormSubmitInputs(): void {
189+
const fileInput = this.el.querySelector<HTMLInputElement>(
190+
'[data-scope="file-upload"][data-part="hidden-input"]'
191+
);
192+
const sentinel = this.el.querySelector<HTMLInputElement>('[data-part="hidden-input-sentinel"]');
193+
const files = this.api.acceptedFiles;
194+
const name = this.el.dataset.name;
195+
196+
if (fileInput) {
197+
setInputFiles(fileInput, files);
198+
}
199+
200+
this.syncAcceptedNamesHidden(name, files);
201+
202+
if (!sentinel) return;
203+
204+
if (files.length > 0) {
205+
sentinel.disabled = true;
206+
sentinel.removeAttribute("name");
207+
return;
208+
}
209+
210+
sentinel.disabled = false;
211+
if (name) {
212+
sentinel.setAttribute("name", name);
213+
}
214+
}
215+
216+
private syncAcceptedNamesHidden(fieldName: string | undefined, files: File[]): void {
217+
if (!fieldName) return;
218+
219+
const labelFieldName = labelFieldNameFor(fieldName);
220+
221+
const region =
222+
this.el.querySelector<HTMLElement>('[data-scope="file-upload"][data-part="region"]') ??
223+
this.el;
224+
225+
let labelInput = region.querySelector<HTMLInputElement>(
226+
'[data-scope="file-upload"][data-part="accepted-names-hidden"]'
227+
);
228+
229+
if (!labelInput) {
230+
labelInput = document.createElement("input");
231+
labelInput.type = "hidden";
232+
labelInput.setAttribute("data-scope", "file-upload");
233+
labelInput.setAttribute("data-part", "accepted-names-hidden");
234+
region.appendChild(labelInput);
235+
}
236+
237+
const names = files
238+
.map((file) => file.name)
239+
.filter(Boolean)
240+
.join(", ");
241+
242+
if (names === "") {
243+
labelInput.disabled = true;
244+
labelInput.removeAttribute("name");
245+
labelInput.value = "";
246+
return;
247+
}
248+
249+
labelInput.disabled = false;
250+
labelInput.name = labelFieldName;
251+
labelInput.value = names;
252+
const formId = this.el.dataset.form;
253+
if (formId) {
254+
labelInput.setAttribute("form", formId);
255+
} else {
256+
labelInput.removeAttribute("form");
257+
}
258+
}
259+
165260
private touchSentinel(): void {
166261
const sentinel = this.el.querySelector<HTMLInputElement>('[data-part="hidden-input-sentinel"]');
167262
if (!sentinel) return;

assets/components/number-input.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { connect, machine, type Props, type Api } from "@zag-js/number-input";
22
import { VanillaMachine } from "@zag-js/vanilla";
33
import { Component } from "../lib/core";
4+
import { getString } from "../lib/util";
5+
import { syncHiddenInputValue } from "../lib/value-form-sync";
46

57
export class NumberInput extends Component<Props, Api> {
68
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -36,7 +38,12 @@ export class NumberInput extends Component<Props, Api> {
3638
const inputEl = this.el.querySelector<HTMLElement>(
3739
'[data-scope="number-input"][data-part="input"]'
3840
);
39-
if (inputEl) this.spreadProps(inputEl, this.api.getInputProps());
41+
if (inputEl) {
42+
const visibleProps = { ...(this.api.getInputProps() as Record<string, unknown>) };
43+
delete visibleProps.name;
44+
delete visibleProps.form;
45+
this.spreadProps(inputEl, visibleProps);
46+
}
4047

4148
const decrementEl = this.el.querySelector<HTMLElement>(
4249
'[data-scope="number-input"][data-part="decrement-trigger"]'
@@ -47,5 +54,19 @@ export class NumberInput extends Component<Props, Api> {
4754
'[data-scope="number-input"][data-part="increment-trigger"]'
4855
);
4956
if (incrementEl) this.spreadProps(incrementEl, this.api.getIncrementTriggerProps());
57+
58+
const valueInputEl = this.el.querySelector<HTMLElement>(
59+
'[data-scope="number-input"][data-part="value-input"]'
60+
);
61+
if (valueInputEl instanceof HTMLInputElement) {
62+
const value = this.api.value || getString(this.el, "defaultValue") || "";
63+
syncHiddenInputValue(
64+
valueInputEl,
65+
this.el,
66+
value,
67+
(el, props) => this.spreadProps(el, props),
68+
{}
69+
);
70+
}
5071
}
5172
}

assets/components/pin-input.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { connect, machine, type Props, type Api } from "@zag-js/pin-input";
22
import { VanillaMachine } from "@zag-js/vanilla";
33
import { Component } from "../lib/core";
4+
import { stripZagSubmitNames } from "../lib/form-field-array-submit";
5+
import { getString } from "../lib/util";
6+
import { syncHiddenInputValue } from "../lib/value-form-sync";
47

58
export class PinInput extends Component<Props, Api> {
69
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -25,7 +28,21 @@ export class PinInput extends Component<Props, Api> {
2528
const hiddenInputEl = this.el.querySelector<HTMLElement>(
2629
'[data-scope="pin-input"][data-part="hidden-input"]'
2730
);
28-
if (hiddenInputEl) this.spreadProps(hiddenInputEl, this.api.getHiddenInputProps());
31+
if (hiddenInputEl instanceof HTMLInputElement) {
32+
syncHiddenInputValue(
33+
hiddenInputEl,
34+
this.el,
35+
this.api.valueAsString ?? "",
36+
(el, props) => this.spreadProps(el, props),
37+
this.api.getHiddenInputProps() as Record<string, unknown>
38+
);
39+
if (getString(this.el, "submitName")) {
40+
hiddenInputEl.removeAttribute("name");
41+
hiddenInputEl.removeAttribute("form");
42+
}
43+
}
44+
45+
stripZagSubmitNames(this.el, "pin-input");
2946

3047
const controlEl = this.el.querySelector<HTMLElement>(
3148
'[data-scope="pin-input"][data-part="control"]'

assets/components/radio-group.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { connect, machine, type Props, type Api } from "@zag-js/radio-group";
22
import type { ItemProps } from "@zag-js/radio-group";
33
import { VanillaMachine } from "@zag-js/vanilla";
44
import { Component } from "../lib/core";
5+
import { hiddenInputPropsWithoutChecked } from "../lib/checkable-form-sync";
6+
import { syncInputFormAssociation } from "../lib/util";
57

68
export class RadioGroup extends Component<Props, Api> {
79
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -59,15 +61,20 @@ export class RadioGroup extends Component<Props, Api> {
5961
const hiddenInputEl = itemEl.querySelector<HTMLElement>(
6062
'[data-scope="radio-group"][data-part="item-hidden-input"]'
6163
);
62-
if (hiddenInputEl)
64+
if (hiddenInputEl instanceof HTMLInputElement) {
6365
this.spreadProps(
6466
hiddenInputEl,
65-
this.api.getItemHiddenInputProps({
66-
value,
67-
disabled,
68-
invalid,
69-
} as ItemProps)
67+
hiddenInputPropsWithoutChecked(
68+
this.api.getItemHiddenInputProps({
69+
value,
70+
disabled,
71+
invalid,
72+
} as ItemProps) as Record<string, unknown>
73+
)
7074
);
75+
hiddenInputEl.checked = this.api.value === value;
76+
syncInputFormAssociation(hiddenInputEl, this.el);
77+
}
7178
});
7279
}
7380
}

assets/components/signature-pad.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { connect, machine, type Props, type Api } from "@zag-js/signature-pad";
22
import { VanillaMachine } from "@zag-js/vanilla";
33
import { Component } from "../lib/core";
4+
import { stripZagSubmitNames } from "../lib/form-field-array-submit";
5+
import { getString } from "../lib/util";
46

57
export class SignaturePad extends Component<Props, Api> {
68
imageURL: string = "";
@@ -109,8 +111,14 @@ export class SignaturePad extends Component<Props, Api> {
109111
value: this.api.paths.length > 0 ? this.api.paths.join("\n") : "",
110112
})
111113
);
114+
if (getString(this.el, "submitName")) {
115+
hiddenInput.removeAttribute("name");
116+
hiddenInput.removeAttribute("form");
117+
}
112118
}
113119

120+
stripZagSubmitNames(this.el, "signature-pad");
121+
114122
this.syncPaths();
115123
}
116124
}

0 commit comments

Comments
 (0)