From b4e39f797be373a9fb8d8178d7d42b09e4b0c3b2 Mon Sep 17 00:00:00 2001 From: Hwacc Date: Mon, 20 Jul 2026 14:13:33 +0800 Subject: [PATCH 1/5] Fix bindable initial value precedence in Vue/Svelte --- packages/frameworks/svelte/src/bindable.svelte.ts | 2 +- packages/frameworks/vue/src/bindable.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frameworks/svelte/src/bindable.svelte.ts b/packages/frameworks/svelte/src/bindable.svelte.ts index 9d58f36920..b2db4e023e 100644 --- a/packages/frameworks/svelte/src/bindable.svelte.ts +++ b/packages/frameworks/svelte/src/bindable.svelte.ts @@ -3,7 +3,7 @@ import { identity, isFunction } from "@zag-js/utils" import { flushSync, onDestroy, untrack } from "svelte" export function bindable(props: () => BindableParams): Bindable { - const initial = props().defaultValue ?? props().value + const initial = props().value ?? props().defaultValue const eq = props().isEqual ?? Object.is let value = $state(initial) diff --git a/packages/frameworks/vue/src/bindable.ts b/packages/frameworks/vue/src/bindable.ts index ea78c6b966..36656c536e 100644 --- a/packages/frameworks/vue/src/bindable.ts +++ b/packages/frameworks/vue/src/bindable.ts @@ -3,7 +3,7 @@ import { isFunction } from "@zag-js/utils" import { computed as __computed, onUnmounted, shallowRef } from "vue" export function bindable(props: () => BindableParams): Bindable { - const initial = props().defaultValue ?? props().value + const initial = props().value ?? props().defaultValue const eq = props().isEqual ?? Object.is const v = shallowRef(initial) From 5d887ecd55d537d13bdc976ec3d11b28504b13ec Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 20 Jul 2026 12:38:17 +0200 Subject: [PATCH 2/5] fix: add changeset for vue/svelte bindable initial value --- .changeset/bright-coins-dress.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/bright-coins-dress.md diff --git a/.changeset/bright-coins-dress.md b/.changeset/bright-coins-dress.md new file mode 100644 index 0000000000..f284b74ebe --- /dev/null +++ b/.changeset/bright-coins-dress.md @@ -0,0 +1,6 @@ +--- +"@zag-js/vue": patch +"@zag-js/svelte": patch +--- + +Fix bindable initial value so `defaultValue: null` is preserved (align with React/Solid). From ba1efdf05691d44acd3da9d199c728503e591a6c Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 20 Jul 2026 12:39:58 +0200 Subject: [PATCH 3/5] fix: clarify vue/svelte bindable changeset --- .changeset/bright-coins-dress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bright-coins-dress.md b/.changeset/bright-coins-dress.md index f284b74ebe..5451170062 100644 --- a/.changeset/bright-coins-dress.md +++ b/.changeset/bright-coins-dress.md @@ -3,4 +3,4 @@ "@zag-js/svelte": patch --- -Fix bindable initial value so `defaultValue: null` is preserved (align with React/Solid). +Fix bindable to resolve `value` before `defaultValue`, matching React/Solid. From 2bf453c418e2bb03597d23ffce9f632c584e85b8 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 20 Jul 2026 13:04:33 +0200 Subject: [PATCH 4/5] fix(date-input): keep segment text in sync during deferred edits --- .changeset/stale-segment-display.md | 5 ++++ e2e/date-input.e2e.ts | 28 +++++++++++++++++++ .../date-input/src/date-input.machine.ts | 9 +++--- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .changeset/stale-segment-display.md diff --git a/.changeset/stale-segment-display.md b/.changeset/stale-segment-display.md new file mode 100644 index 0000000000..340081024f --- /dev/null +++ b/.changeset/stale-segment-display.md @@ -0,0 +1,5 @@ +--- +"@zag-js/date-input": patch +--- + +Fix segment text lagging behind in-progress edits when typing over a committed date. diff --git a/e2e/date-input.e2e.ts b/e2e/date-input.e2e.ts index 3c7d4ae09a..c5f49c2ac4 100644 --- a/e2e/date-input.e2e.ts +++ b/e2e/date-input.e2e.ts @@ -464,6 +464,34 @@ test.describe("date-input [single]", () => { await I.seeSegmentIsNotPlaceholder("day") }) + test("[editingValue] re-typing over a committed date shows the new digits before blur", async () => { + await I.focusSegment("month") + await I.type("6") + await I.seeSegmentFocused("day") + await I.type("2") + await I.pressKey("Tab") + await I.seeSegmentFocused("year") + await I.type("2000") + await I.clickOutsideToBlur() + await I.seeSelectedValue("6/2/2000") + await I.seeSegmentText("day", "2") + + await I.focusSegment("month") + await I.type("6") + await I.seeSegmentFocused("day") + await I.type("3") + await I.pressKey("Tab") + await I.seeSegmentFocused("year") + + await I.seeSegmentText("month", "6") + await I.seeSegmentText("day", "3") + await I.seeSegmentText("year", "2000") + + await I.clickOutsideToBlur() + await I.seeSelectedValue("6/3/2000") + await I.seeSegmentText("day", "3") + }) + test("[placeholderValue] ArrowUp after clearing a previously typed year starts from placeholder year", async () => { // Regression: before the editingValue fix, clearing year after a full commit left // a stale edited year in the context, causing ArrowUp to start from year 1 instead diff --git a/packages/machines/date-input/src/date-input.machine.ts b/packages/machines/date-input/src/date-input.machine.ts index dc92f09b9b..c56d7a2cc5 100644 --- a/packages/machines/date-input/src/date-input.machine.ts +++ b/packages/machines/date-input/src/date-input.machine.ts @@ -193,11 +193,12 @@ export const machine = createMachine({ return Array.from({ length: computed("groupCount") }, (_, i) => { const displayValue = displayValues[i] ?? new IncompleteDate(placeholderValue.calendar, resolvedHourCycle(formatter)) - // When all segments are filled, use the committed value for display; otherwise - // fall back through the IncompleteDate's toValue() which fills missing fields from placeholderValue. + // Use displayValues when complete so deferred edits stay in sync with segment text. const committedValue = value?.[i] - const isFullyCommitted = committedValue && displayValue.isComplete(allSegmentTypes) - const displayDate = isFullyCommitted ? committedValue : displayValue.toValue(placeholderValue) + const displayDate = + committedValue && displayValue.isComplete(allSegmentTypes) + ? displayValue.toValue(committedValue) + : displayValue.toValue(placeholderValue) // Show the era segment when the display value is in BC era (Gregorian calendar). // Create the era formatter inline — no dedicated prop needed. From 7f7a6a40fdbe9494de4b9718179b25eb75857fca Mon Sep 17 00:00:00 2001 From: arman <129011616+Arman-Luthra@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:43:54 -0700 Subject: [PATCH 5/5] fix(auto-resize): stop re-dispatching input event on programmatic value writes (#3206) Co-authored-by: Segun Adebayo --- .../fix-autoresize-controlled-inputs.md | 5 + e2e/autoresize.e2e.ts | 79 +++++++++ e2e/models/autoresize.model.ts | 61 +++++++ .../next-ts/pages/autoresize/controlled.tsx | 1 + .../nuxt-ts/app/pages/autoresize/basic.vue | 25 +++ .../app/pages/autoresize/controlled.vue | 47 ++++++ .../preact-ts/src/pages/autoresize/basic.tsx | 26 +++ .../src/pages/autoresize/controlled.tsx | 51 ++++++ examples/preact-ts/src/routes.ts | 2 + .../solid-ts/src/routes/autoresize/basic.tsx | 28 ++++ .../src/routes/autoresize/controlled.tsx | 56 +++++++ examples/svelte-ts/src/app.html | 1 + .../src/routes/autoresize/basic/+page.svelte | 18 +++ .../routes/autoresize/controlled/+page.svelte | 37 +++++ .../auto-resize/src/autoresize-textarea.ts | 17 +- .../tests/autoresize-textarea.test.ts | 153 ++++++++++++++++++ shared/src/routes.ts | 8 + 17 files changed, 604 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-autoresize-controlled-inputs.md create mode 100644 e2e/autoresize.e2e.ts create mode 100644 e2e/models/autoresize.model.ts create mode 100644 examples/nuxt-ts/app/pages/autoresize/basic.vue create mode 100644 examples/nuxt-ts/app/pages/autoresize/controlled.vue create mode 100644 examples/preact-ts/src/pages/autoresize/basic.tsx create mode 100644 examples/preact-ts/src/pages/autoresize/controlled.tsx create mode 100644 examples/solid-ts/src/routes/autoresize/basic.tsx create mode 100644 examples/solid-ts/src/routes/autoresize/controlled.tsx create mode 100644 examples/svelte-ts/src/routes/autoresize/basic/+page.svelte create mode 100644 examples/svelte-ts/src/routes/autoresize/controlled/+page.svelte create mode 100644 packages/utilities/auto-resize/tests/autoresize-textarea.test.ts diff --git a/.changeset/fix-autoresize-controlled-inputs.md b/.changeset/fix-autoresize-controlled-inputs.md new file mode 100644 index 0000000000..0eccace7ec --- /dev/null +++ b/.changeset/fix-autoresize-controlled-inputs.md @@ -0,0 +1,5 @@ +--- +"@zag-js/auto-resize": patch +--- + +Fix controlled textareas broken by re-dispatching `input` on programmatic value writes. diff --git a/e2e/autoresize.e2e.ts b/e2e/autoresize.e2e.ts new file mode 100644 index 0000000000..5b5825aaa3 --- /dev/null +++ b/e2e/autoresize.e2e.ts @@ -0,0 +1,79 @@ +import { test } from "@playwright/test" +import { AutoresizeModel } from "./models/autoresize.model" + +let I: AutoresizeModel + +test.describe("autoresize / basic", () => { + test.beforeEach(async ({ page }) => { + I = new AutoresizeModel(page) + await I.goto("/autoresize/basic") + }) + + test("should grow textarea height as content increases", async () => { + const initialHeight = await I.getTextareaHeight() + + await I.typeInTextarea("line1\nline2\nline3\nline4\nline5\nline6") + + await I.seeTextareaGrew(initialHeight) + }) + + test("should grow textarea height when pasting multiline text", async ({ context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + + const initialHeight = await I.getTextareaHeight() + + await I.pasteInTextarea("line1\nline2\nline3\nline4\nline5\nline6") + + await I.seeTextareaGrew(initialHeight) + }) +}) + +test.describe("autoresize / controlled", () => { + test.beforeEach(async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + I = new AutoresizeModel(page) + await I.goto("/autoresize/controlled") + }) + + test("should sync typed value to state", async () => { + await I.typeInTextarea("hello") + + await I.seeTextareaHasValue("hello") + await I.seeStateValue("hello") + }) + + test("should sync pasted value to state", async () => { + await I.pasteInTextarea("pasted text") + + await I.seeTextareaHasValue("pasted text") + await I.seeStateValue("pasted text") + }) + + test("should update value after clear and retype same character", async () => { + await I.typeInTextarea("a") + await I.seeStateValue("a") + + await I.clear() + await I.seeTextareaHasValue("") + await I.seeStateValue("") + + await I.typeInTextarea("a") + + await I.seeTextareaHasValue("a") + await I.seeStateValue("a") + }) + + test("should update value after clear and paste same text", async () => { + await I.pasteInTextarea("a") + await I.seeStateValue("a") + + await I.clear() + await I.seeTextareaHasValue("") + await I.seeStateValue("") + + await I.pasteInTextarea("a") + + await I.seeTextareaHasValue("a") + await I.seeStateValue("a") + }) +}) diff --git a/e2e/models/autoresize.model.ts b/e2e/models/autoresize.model.ts new file mode 100644 index 0000000000..53af5e4433 --- /dev/null +++ b/e2e/models/autoresize.model.ts @@ -0,0 +1,61 @@ +import { expect, type Page } from "@playwright/test" +import { Model } from "./model" + +export class AutoresizeModel extends Model { + constructor(public page: Page) { + super(page) + } + + async goto(url = "/autoresize/basic") { + await this.page.goto(url, { waitUntil: "networkidle" }) + await this.textarea.waitFor({ state: "visible" }) + } + + get textarea() { + return this.page.locator("textarea").first() + } + + get clearButton() { + return this.page.getByRole("button", { name: /clear/i }) + } + + get valueOutput() { + return this.page.getByTestId("value") + } + + async focusTextarea() { + await this.textarea.click() + await expect(this.textarea).toBeFocused() + } + + async typeInTextarea(value: string) { + await this.focusTextarea() + await this.page.keyboard.type(value) + } + + async pasteInTextarea(value: string) { + await this.focusTextarea() + await this.page.evaluate((text) => navigator.clipboard.writeText(text), value) + await this.page.keyboard.press("ControlOrMeta+v") + } + + clear() { + return this.clearButton.click() + } + + getTextareaHeight() { + return this.textarea.evaluate((el) => el.getBoundingClientRect().height) + } + + seeTextareaHasValue(value: string) { + return expect(this.textarea).toHaveValue(value) + } + + async seeStateValue(value: string) { + await expect(this.valueOutput).toHaveText(JSON.stringify(value)) + } + + async seeTextareaGrew(fromHeight: number, minDelta = 5) { + await expect.poll(async () => this.getTextareaHeight(), { timeout: 5_000 }).toBeGreaterThan(fromHeight + minDelta) + } +} diff --git a/examples/next-ts/pages/autoresize/controlled.tsx b/examples/next-ts/pages/autoresize/controlled.tsx index b4d7c2e794..9fdf82efcc 100644 --- a/examples/next-ts/pages/autoresize/controlled.tsx +++ b/examples/next-ts/pages/autoresize/controlled.tsx @@ -58,6 +58,7 @@ export default function AutoresizeControlled() {
Value from state:
+import { autoresizeTextarea } from "@zag-js/auto-resize"
+import { onMounted, onUnmounted, ref } from "vue"
+
+const textareaRef = ref(null)
+let cleanup: VoidFunction | undefined
+
+onMounted(() => {
+  cleanup = autoresizeTextarea(textareaRef.value)
+})
+
+onUnmounted(() => {
+  cleanup?.()
+})
+
+
+