Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-coins-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@zag-js/vue": patch
"@zag-js/svelte": patch
---

Fix bindable to resolve `value` before `defaultValue`, matching React/Solid.
5 changes: 5 additions & 0 deletions .changeset/fix-autoresize-controlled-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/auto-resize": patch
---

Fix controlled textareas broken by re-dispatching `input` on programmatic value writes.
5 changes: 5 additions & 0 deletions .changeset/stale-segment-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/date-input": patch
---

Fix segment text lagging behind in-progress edits when typing over a committed date.
79 changes: 79 additions & 0 deletions e2e/autoresize.e2e.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
28 changes: 28 additions & 0 deletions e2e/date-input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions e2e/models/autoresize.model.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions examples/next-ts/pages/autoresize/controlled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default function AutoresizeControlled() {
<div style={{ marginTop: 20 }}>
<strong>Value from state:</strong>
<pre
data-testid="value"
style={{
background: "#f5f5f5",
padding: 12,
Expand Down
25 changes: 25 additions & 0 deletions examples/nuxt-ts/app/pages/autoresize/basic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { autoresizeTextarea } from "@zag-js/auto-resize"
import { onMounted, onUnmounted, ref } from "vue"

const textareaRef = ref<HTMLTextAreaElement | null>(null)
let cleanup: VoidFunction | undefined

onMounted(() => {
cleanup = autoresizeTextarea(textareaRef.value)
})

onUnmounted(() => {
cleanup?.()
})
</script>

<template>
<main>
<textarea
ref="textareaRef"
rows="4"
style="width: 100%; resize: none; padding: 20px; scroll-padding-block: 20px; max-height: 180px"
/>
</main>
</template>
47 changes: 47 additions & 0 deletions examples/nuxt-ts/app/pages/autoresize/controlled.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { autoresizeTextarea } from "@zag-js/auto-resize"
import { onMounted, onUnmounted, ref } from "vue"

const textareaRef = ref<HTMLTextAreaElement | null>(null)
const value = ref("")
let cleanup: VoidFunction | undefined

onMounted(() => {
cleanup = autoresizeTextarea(textareaRef.value)
})

onUnmounted(() => {
cleanup?.()
})

function onInput(e: Event) {
value.value = (e.target as HTMLTextAreaElement).value
}
</script>

<template>
<main style="padding: 20px; max-width: 600px">
<h1>Autoresize Controlled Textarea</h1>
<p>Type a character, click Clear, then type the same character again. The value should update each time.</p>

<textarea
ref="textareaRef"
:value="value"
rows="2"
placeholder="Type something..."
style="width: 100%; resize: none; padding: 12px; font-size: 16px; border-radius: 8px; border: 1px solid #ccc"
@input="onInput"
/>

<div style="margin-top: 12px">
<button type="button" style="padding: 8px 16px; font-size: 14px; cursor: pointer" @click="value = ''">
Clear the field
</button>
</div>

<div style="margin-top: 20px">
<strong>Value from state:</strong>
<pre data-testid="value" style="background: #f5f5f5; padding: 12px; border-radius: 4px; min-height: 40px">{{ JSON.stringify(value) }}</pre>
</div>
</main>
</template>
26 changes: 26 additions & 0 deletions examples/preact-ts/src/pages/autoresize/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { autoresizeTextarea } from "@zag-js/auto-resize"
import { useEffect, useRef } from "preact/hooks"

export default function Page() {
const textareaRef = useRef<HTMLTextAreaElement>(null)

useEffect(() => {
return autoresizeTextarea(textareaRef.current)
}, [])

return (
<main>
<textarea
ref={textareaRef}
rows={4}
style={{
width: "100%",
resize: "none",
padding: 20,
scrollPaddingBlock: 20,
maxHeight: 180,
}}
/>
</main>
)
}
51 changes: 51 additions & 0 deletions examples/preact-ts/src/pages/autoresize/controlled.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement>(null)
const [value, setValue] = useState("")

useEffect(() => {
return autoresizeTextarea(textareaRef.current)
}, [])

return (
<main style={{ padding: 20, maxWidth: 600 }}>
<h1>Autoresize Controlled Textarea</h1>
<p>Type a character, click Clear, then type the same character again. The value should update each time.</p>

<textarea
ref={textareaRef}
value={value}
rows={2}
placeholder="Type something..."
style={{
width: "100%",
resize: "none",
padding: 12,
fontSize: 16,
borderRadius: 8,
border: "1px solid #ccc",
}}
onInput={(e) => setValue((e.currentTarget as HTMLTextAreaElement).value)}
/>

<div style={{ marginTop: 12 }}>
<button
type="button"
style={{ padding: "8px 16px", fontSize: 14, cursor: "pointer" }}
onClick={() => setValue("")}
>
Clear the field
</button>
</div>

<div style={{ marginTop: 20 }}>
<strong>Value from state:</strong>
<pre data-testid="value" style={{ background: "#f5f5f5", padding: 12, borderRadius: 4, minHeight: 40 }}>
{JSON.stringify(value)}
</pre>
</div>
</main>
)
}
2 changes: 2 additions & 0 deletions examples/preact-ts/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) },
Expand Down
28 changes: 28 additions & 0 deletions examples/solid-ts/src/routes/autoresize/basic.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<textarea
ref={textareaRef}
rows={4}
style={{
width: "100%",
resize: "none",
padding: "20px",
"scroll-padding-block": "20px",
"max-height": "180px",
}}
/>
</main>
)
}
Loading
Loading