diff --git a/.changeset/bright-coins-dress.md b/.changeset/bright-coins-dress.md new file mode 100644 index 0000000000..5451170062 --- /dev/null +++ b/.changeset/bright-coins-dress.md @@ -0,0 +1,6 @@ +--- +"@zag-js/vue": patch +"@zag-js/svelte": patch +--- + +Fix bindable to resolve `value` before `defaultValue`, matching React/Solid. 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/.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/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/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/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() {
+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?.()
+})
+
+
+
+
+
+
+
diff --git a/examples/nuxt-ts/app/pages/autoresize/controlled.vue b/examples/nuxt-ts/app/pages/autoresize/controlled.vue
new file mode 100644
index 0000000000..824332e966
--- /dev/null
+++ b/examples/nuxt-ts/app/pages/autoresize/controlled.vue
@@ -0,0 +1,47 @@
+
+
+
+
+ Autoresize Controlled Textarea
+ Type a character, click Clear, then type the same character again. The value should update each time.
+
+
+
+
+
+
+
+
+ Value from state:
+ {{ JSON.stringify(value) }}
+
+
+
diff --git a/examples/preact-ts/src/pages/autoresize/basic.tsx b/examples/preact-ts/src/pages/autoresize/basic.tsx
new file mode 100644
index 0000000000..4eeccb4dd6
--- /dev/null
+++ b/examples/preact-ts/src/pages/autoresize/basic.tsx
@@ -0,0 +1,26 @@
+import { autoresizeTextarea } from "@zag-js/auto-resize"
+import { useEffect, useRef } from "preact/hooks"
+
+export default function Page() {
+ const textareaRef = useRef(null)
+
+ useEffect(() => {
+ return autoresizeTextarea(textareaRef.current)
+ }, [])
+
+ return (
+
+
+
+ )
+}
diff --git a/examples/preact-ts/src/pages/autoresize/controlled.tsx b/examples/preact-ts/src/pages/autoresize/controlled.tsx
new file mode 100644
index 0000000000..ad5663757a
--- /dev/null
+++ b/examples/preact-ts/src/pages/autoresize/controlled.tsx
@@ -0,0 +1,51 @@
+import { autoresizeTextarea } from "@zag-js/auto-resize"
+import { useEffect, useRef, useState } from "preact/hooks"
+
+export default function Page() {
+ const textareaRef = useRef(null)
+ const [value, setValue] = useState("")
+
+ useEffect(() => {
+ return autoresizeTextarea(textareaRef.current)
+ }, [])
+
+ return (
+
+ Autoresize Controlled Textarea
+ Type a character, click Clear, then type the same character again. The value should update each time.
+
+
+ )
+}
diff --git a/examples/preact-ts/src/routes.ts b/examples/preact-ts/src/routes.ts
index e76b88432d..1d8e861c19 100644
--- a/examples/preact-ts/src/routes.ts
+++ b/examples/preact-ts/src/routes.ts
@@ -186,6 +186,8 @@ export const routes: RouteDefinition[] = [
{ path: "/pin-input/basic", component: lazy(() => import("./pages/pin-input/basic")) },
{ path: "/pin-input/controlled", component: lazy(() => import("./pages/pin-input/controlled")) },
{ path: "/pin-input/transform-paste", component: lazy(() => import("./pages/pin-input/transform-paste")) },
+ { path: "/autoresize/basic", component: lazy(() => import("./pages/autoresize/basic")) },
+ { path: "/autoresize/controlled", component: lazy(() => import("./pages/autoresize/controlled")) },
{ path: "/popper/basic", component: lazy(() => import("./pages/popper/basic")) },
{ path: "/popover/basic", component: lazy(() => import("./pages/popover/basic")) },
{ path: "/popover/composition-controlled", component: lazy(() => import("./pages/popover/composition-controlled")) },
diff --git a/examples/solid-ts/src/routes/autoresize/basic.tsx b/examples/solid-ts/src/routes/autoresize/basic.tsx
new file mode 100644
index 0000000000..633749237f
--- /dev/null
+++ b/examples/solid-ts/src/routes/autoresize/basic.tsx
@@ -0,0 +1,28 @@
+import { autoresizeTextarea } from "@zag-js/auto-resize"
+import { onCleanup, onMount } from "solid-js"
+
+export default function Page() {
+ let textareaRef: HTMLTextAreaElement | undefined
+
+ onMount(() => {
+ if (!textareaRef) return
+ const cleanup = autoresizeTextarea(textareaRef)
+ onCleanup(() => cleanup?.())
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/examples/solid-ts/src/routes/autoresize/controlled.tsx b/examples/solid-ts/src/routes/autoresize/controlled.tsx
new file mode 100644
index 0000000000..45e497a9a2
--- /dev/null
+++ b/examples/solid-ts/src/routes/autoresize/controlled.tsx
@@ -0,0 +1,56 @@
+import { autoresizeTextarea } from "@zag-js/auto-resize"
+import { createSignal, onCleanup, onMount } from "solid-js"
+
+export default function Page() {
+ let textareaRef: HTMLTextAreaElement | undefined
+ const [value, setValue] = createSignal("")
+
+ onMount(() => {
+ if (!textareaRef) return
+ const cleanup = autoresizeTextarea(textareaRef)
+ onCleanup(() => cleanup?.())
+ })
+
+ return (
+
+ Autoresize Controlled Textarea
+ Type a character, click Clear, then type the same character again. The value should update each time.
+
+
+ )
+}
diff --git a/examples/svelte-ts/src/app.html b/examples/svelte-ts/src/app.html
index 5bd5fbc42f..22d5e06c37 100644
--- a/examples/svelte-ts/src/app.html
+++ b/examples/svelte-ts/src/app.html
@@ -4,6 +4,7 @@
+ Zag.js + Svelte
%sveltekit.head%
diff --git a/examples/svelte-ts/src/routes/autoresize/basic/+page.svelte b/examples/svelte-ts/src/routes/autoresize/basic/+page.svelte
new file mode 100644
index 0000000000..cb23c0f5fd
--- /dev/null
+++ b/examples/svelte-ts/src/routes/autoresize/basic/+page.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/examples/svelte-ts/src/routes/autoresize/controlled/+page.svelte b/examples/svelte-ts/src/routes/autoresize/controlled/+page.svelte
new file mode 100644
index 0000000000..603d485382
--- /dev/null
+++ b/examples/svelte-ts/src/routes/autoresize/controlled/+page.svelte
@@ -0,0 +1,37 @@
+
+
+
+ Autoresize Controlled Textarea
+ Type a character, click Clear, then type the same character again. The value should update each time.
+
+
+
+
+
+
+
+
+ Value from state:
+ {JSON.stringify(value)}
+
+
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)
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.
diff --git a/packages/utilities/auto-resize/src/autoresize-textarea.ts b/packages/utilities/auto-resize/src/autoresize-textarea.ts
index 3482309863..33ceb52e26 100644
--- a/packages/utilities/auto-resize/src/autoresize-textarea.ts
+++ b/packages/utilities/auto-resize/src/autoresize-textarea.ts
@@ -35,23 +35,18 @@ export const autoresizeTextarea = (el: HTMLTextAreaElement | null) => {
el.addEventListener("input", resize)
el.form?.addEventListener("reset", resize)
- const elementPrototype = Object.getPrototypeOf(el)
- const descriptor = Object.getOwnPropertyDescriptor(elementPrototype, "value")
+ // Prefer the framework's own value tracker when present.
+ const ownDescriptor = Object.getOwnPropertyDescriptor(el, "value")
+ const descriptor = ownDescriptor?.set
+ ? ownDescriptor
+ : Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")
- if (descriptor) {
+ if (descriptor?.set) {
Object.defineProperty(el, "value", {
...descriptor,
set(newValue: string) {
- const prevValue = descriptor.get?.call(this)
descriptor.set?.call(this, newValue)
resize()
-
- // Dispatch input event asynchronously to sync framework state trackers
- if (prevValue !== newValue) {
- queueMicrotask(() => {
- el.dispatchEvent(new win.InputEvent("input", { bubbles: true }))
- })
- }
},
})
}
diff --git a/packages/utilities/auto-resize/tests/autoresize-textarea.test.ts b/packages/utilities/auto-resize/tests/autoresize-textarea.test.ts
new file mode 100644
index 0000000000..6db9c3cca3
--- /dev/null
+++ b/packages/utilities/auto-resize/tests/autoresize-textarea.test.ts
@@ -0,0 +1,153 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeAll, expect, test, vi } from "vitest"
+import { autoresizeTextarea } from "../src"
+
+class ResizeObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+beforeAll(() => {
+ vi.stubGlobal("ResizeObserver", ResizeObserverStub)
+ return () => vi.unstubAllGlobals()
+})
+
+const nativeValueDescriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!
+
+/** Emulates React's controlled-input value tracker. */
+function trackValue(el: HTMLTextAreaElement) {
+ let currentValue = el.value
+
+ Object.defineProperty(el, "value", {
+ configurable: true,
+ enumerable: nativeValueDescriptor.enumerable,
+ get() {
+ return nativeValueDescriptor.get!.call(this)
+ },
+ set(value: string) {
+ currentValue = String(value)
+ nativeValueDescriptor.set!.call(this, value)
+ },
+ })
+
+ return {
+ getValue: () => currentValue,
+ updateValueIfChanged() {
+ const nextValue = nativeValueDescriptor.get!.call(el) as string
+ if (nextValue === currentValue) return false
+ currentValue = nextValue
+ return true
+ },
+ }
+}
+
+/** Native value write + trusted `input` event (bypasses own `value` setters). */
+function typeIntoTextarea(el: HTMLTextAreaElement, text: string) {
+ nativeValueDescriptor.set!.call(el, text)
+ el.dispatchEvent(new InputEvent("input", { bubbles: true }))
+}
+
+function flushMicrotasks() {
+ return new Promise((resolve) => {
+ queueMicrotask(() => queueMicrotask(resolve))
+ })
+}
+
+let cleanup: VoidFunction | undefined
+
+afterEach(() => {
+ cleanup?.()
+ cleanup = undefined
+ document.body.innerHTML = ""
+})
+
+test("programmatic value writes do not dispatch input events", async () => {
+ const el = document.createElement("textarea")
+ document.body.appendChild(el)
+
+ cleanup = autoresizeTextarea(el)
+
+ const onInput = vi.fn()
+ el.addEventListener("input", onInput)
+
+ el.value = "hello"
+ await flushMicrotasks()
+
+ expect(el.value).toBe("hello")
+ expect(onInput).not.toHaveBeenCalled()
+})
+
+test("programmatic value writes keep the framework value tracker in sync", async () => {
+ const el = document.createElement("textarea")
+ document.body.appendChild(el)
+
+ const tracker = trackValue(el)
+ cleanup = autoresizeTextarea(el)
+
+ el.value = "hello"
+ await flushMicrotasks()
+
+ expect(tracker.getValue()).toBe("hello")
+})
+
+test("controlled textarea that rejects input fires onChange once per keystroke", async () => {
+ const el = document.createElement("textarea")
+ document.body.appendChild(el)
+
+ const tracker = trackValue(el)
+ cleanup = autoresizeTextarea(el)
+
+ let state = ""
+ const onChange = vi.fn((value: string) => {
+ if (!/\d/.test(value)) state = value
+ })
+
+ el.addEventListener("input", () => {
+ if (!tracker.updateValueIfChanged()) return
+ onChange(el.value)
+ if (el.value !== state) el.value = state
+ })
+
+ typeIntoTextarea(el, "1")
+ await flushMicrotasks()
+
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith("1")
+ expect(el.value).toBe("")
+})
+
+test("clearing a controlled textarea programmatically keeps change detection working", async () => {
+ const el = document.createElement("textarea")
+ document.body.appendChild(el)
+
+ const tracker = trackValue(el)
+ cleanup = autoresizeTextarea(el)
+
+ let state = ""
+ const onChange = vi.fn((value: string) => {
+ state = value
+ })
+
+ el.addEventListener("input", () => {
+ if (!tracker.updateValueIfChanged()) return
+ onChange(el.value)
+ if (el.value !== state) el.value = state
+ })
+
+ typeIntoTextarea(el, "a")
+ await flushMicrotasks()
+ expect(onChange).toHaveBeenNthCalledWith(1, "a")
+
+ state = ""
+ el.value = ""
+ await flushMicrotasks()
+
+ typeIntoTextarea(el, "a")
+ await flushMicrotasks()
+
+ expect(onChange).toHaveBeenCalledTimes(2)
+ expect(onChange).toHaveBeenNthCalledWith(2, "a")
+ expect(state).toBe("a")
+})
diff --git a/shared/src/routes.ts b/shared/src/routes.ts
index ce1f686726..aa9e3a35d7 100644
--- a/shared/src/routes.ts
+++ b/shared/src/routes.ts
@@ -191,6 +191,14 @@ export const componentRoutes: ComponentRoute[] = [
label: "Presence",
examples: [{ slug: "basic", title: "Basic" }],
},
+ {
+ slug: "autoresize",
+ label: "Autoresize",
+ examples: [
+ { slug: "basic", title: "Basic" },
+ { slug: "controlled", title: "Controlled" },
+ ],
+ },
{
slug: "avatar",
label: "Avatar",