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() {
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?.()
+})
+
+
+