diff --git a/.changeset/tags-input-hidden-input-stale-value.md b/.changeset/tags-input-hidden-input-stale-value.md new file mode 100644 index 0000000000..3693abd0e0 --- /dev/null +++ b/.changeset/tags-input-hidden-input-stale-value.md @@ -0,0 +1,7 @@ +--- +"@zag-js/tags-input": patch +--- + +Fix native form submit so `FormData` reflects the current tags (#3193). + +The hidden input kept its initial value after tags were added, removed, or cleared. With `defaultValue={["a"]}`, adding `"b"` and submitting sent `"a"` instead of `"a, b"`. diff --git a/e2e/models/model.ts b/e2e/models/model.ts index 8efbc3c53a..d0207dd1ac 100644 --- a/e2e/models/model.ts +++ b/e2e/models/model.ts @@ -130,6 +130,14 @@ export class Model { return this.page.getByText(text).click() } + clickButton(name: string, options?: { modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift"> }) { + return this.page.getByRole("button", { name }).first().click(options) + } + + seeTextValue(selector: string, value: string) { + return expect(this.page.locator(selector)).toHaveText(value) + } + see(text: string, context?: string) { const locator = context ? this.page.locator(context).getByText(text) : this.page.getByText(text) return expect(locator).toBeVisible() diff --git a/e2e/models/tags-input.model.ts b/e2e/models/tags-input.model.ts index 02283bd634..df37b8b42c 100644 --- a/e2e/models/tags-input.model.ts +++ b/e2e/models/tags-input.model.ts @@ -6,8 +6,8 @@ export class TagsInputModel extends Model { super(page) } - goto() { - return this.page.goto("/tags-input/basic") + goto(url = "/tags-input/basic") { + return this.page.goto(url) } private get input() { diff --git a/e2e/models/tree-view.model.ts b/e2e/models/tree-view.model.ts index 106bc68d0b..55858ee42e 100644 --- a/e2e/models/tree-view.model.ts +++ b/e2e/models/tree-view.model.ts @@ -42,10 +42,6 @@ export class TreeViewModel extends Model { return this.branchTrigger(name).click(options) } - clickButton(name: string, options?: ClickOptions) { - return this.button(name).click(options) - } - focusItem(name: string) { return this.item(name).focus() } diff --git a/e2e/tags-input.e2e.ts b/e2e/tags-input.e2e.ts index 1de0b1b1df..16b1fffac5 100644 --- a/e2e/tags-input.e2e.ts +++ b/e2e/tags-input.e2e.ts @@ -313,3 +313,30 @@ test.describe("tags-input", () => { await I.seeInputIsFocused() }) }) + +test.describe("tags-input / form", () => { + test.beforeEach(async ({ page }) => { + I = new TagsInputModel(page) + await I.goto("/tags-input/form") + }) + + test("native submit reflects tags added after mount", async () => { + await I.addTag("Svelte") + await I.clickButton("Submit") + await I.seeTextValue("[data-testid=last-submit]", "React, Vue, Svelte") + }) + + test("native submit reflects tags removed after mount", async () => { + await I.clickTagClose("Vue") + await I.dontSeeTag("Vue") + await I.clickButton("Submit") + await I.seeTextValue("[data-testid=last-submit]", "React") + }) + + test("native submit reflects cleared tags", async () => { + await I.clickClearTrigger() + await I.seeNoTags() + await I.clickButton("Submit") + await I.seeTextValue("[data-testid=last-submit]", "") + }) +}) diff --git a/examples/next-ts/pages/tags-input/form.tsx b/examples/next-ts/pages/tags-input/form.tsx new file mode 100644 index 0000000000..40f250ae14 --- /dev/null +++ b/examples/next-ts/pages/tags-input/form.tsx @@ -0,0 +1,84 @@ +import { normalizeProps, useMachine } from "@zag-js/react" +import { tagsInputControls } from "@zag-js/shared" +import * as tagsInput from "@zag-js/tags-input" +import { useId, useState } from "react" +import { StateVisualizer } from "../../components/state-visualizer" +import { Toolbar } from "../../components/toolbar" +import { useControls } from "../../hooks/use-controls" + +function toDashCase(str: string) { + return str.replace(/\s+/g, "-").toLowerCase() +} + +export default function Page() { + const controls = useControls(tagsInputControls) + + const [submitCount, setSubmitCount] = useState(0) + const [lastSubmit, setLastSubmit] = useState(null) + + const service = useMachine(tagsInput.machine, { + id: useId(), + name: "tags", + defaultValue: ["React", "Vue"], + ...controls.context, + }) + + const api = tagsInput.connect(service, normalizeProps) + + return ( + <> +
+
{ + e.preventDefault() + const data = new FormData(e.currentTarget) + const value = String(data.get("tags") ?? "") + setSubmitCount((c) => c + 1) + setLastSubmit(value) + }} + > +
+ +
+ {api.value.map((value, index) => ( + +
+ + {value}{" "} + + +
+ +
+ ))} + + +
+ +
+ +
+ +
+
+ +
+ submit count: {submitCount} +
+ last submitted value: {lastSubmit ?? "—"} +
+
+ + + + + + ) +} diff --git a/packages/machines/tags-input/src/tags-input.machine.ts b/packages/machines/tags-input/src/tags-input.machine.ts index 9566055e2f..beb3b00829 100644 --- a/packages/machines/tags-input/src/tags-input.machine.ts +++ b/packages/machines/tags-input/src/tags-input.machine.ts @@ -97,6 +97,9 @@ export const machine = createMachine({ }, watch({ track, context, action, computed, refs }) { + track([() => computed("valueAsString")], () => { + action(["dispatchChangeEvent"]) + }) track([() => context.get("editedTagValue")], () => { action(["syncEditedTagInputValue"]) }) @@ -438,7 +441,9 @@ export const machine = createMachine({ send({ type: "EXTERNAL_BLUR", id: event.id }) }, dispatchChangeEvent({ scope, computed }) { - dom.dispatchInputEvent(scope, computed("valueAsString")) + queueMicrotask(() => { + dom.dispatchInputEvent(scope, computed("valueAsString")) + }) }, highlightNextTag({ context, scope }) { const highlightedTagId = context.get("highlightedTagId") diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 5abee0cea0..ce1f686726 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -446,6 +446,7 @@ export const componentRoutes: ComponentRoute[] = [ label: "Tags Input", examples: [ { slug: "basic", title: "Basic" }, + { slug: "form", title: "Form" }, { slug: "validate", title: "Validate" }, { slug: "allow-duplicates", title: "Allow Duplicates" }, { slug: "sentence-builder", title: "Builder" },