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
4 changes: 2 additions & 2 deletions .changeset/blue-pumas-type.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
"@zag-js/cascade-select": patch
"@zag-js/cascade-select": minor
---

Add cascade select machine
**Cascade Select [New]**: Initial release of cascade select state machine
6 changes: 6 additions & 0 deletions .changeset/fix-select-autofill-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@zag-js/select": patch
---

- Added `autoComplete` prop for browser autofill hints (e.g. "address-level1" for state fields)
- Fix issue where autofill does not update value when the hidden select value changes
9 changes: 9 additions & 0 deletions e2e/models/select.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,13 @@ export class SelectModel extends Model {
const item = this.getItem(text)
expect(await isInViewport(this.content, item)).toBe(true)
}

autofill = async (value: string) => {
await this.page.evaluate((value) => {
const select = document.querySelector<HTMLSelectElement>("[data-scope='select'] select[aria-hidden]")
if (!select) throw new Error("Hidden select not found")
select.value = value
select.dispatchEvent(new Event("change", { bubbles: true }))
}, value)
}
}
12 changes: 12 additions & 0 deletions e2e/select.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,18 @@ test.describe("closed state + keyboard selection", () => {
})
})

test.describe("hidden select / autofill", () => {
test("should sync value when hidden select changes externally (e.g. autofill)", async () => {
await I.seeTriggerHasText("Select option")

// Simulate browser autofill: native select value changes + change event fires
await I.autofill("AL")

// Component should sync and display the new value
await I.seeTriggerHasText("Albania (AL)")
})
})

test.describe("multiple", () => {
test("should select multiple items", async () => {
await I.controls.bool("multiple", true)
Expand Down
104 changes: 104 additions & 0 deletions examples/next-ts/pages/select-autofill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Manual testing page for Select with browser autofill.
*
* To test:
* 1. Save an address in Chrome (Settings → Autofill → Addresses)
* 2. Run: pnpm start-react (or pnpm dev in examples/next-ts)
* 3. Open http://localhost:3000/select-autofill
* 4. Click the Street address input
* 5. When Chrome shows "Use saved address", select it
* 6. The State select should update to show the autofilled value
*/
import { normalizeProps, Portal, useMachine } from "@zag-js/react"
import * as select from "@zag-js/select"
import { useId } from "react"

const US_STATES = [
{ label: "Alabama", value: "AL" },
{ label: "California", value: "CA" },
{ label: "Florida", value: "FL" },
{ label: "New York", value: "NY" },
{ label: "Texas", value: "TX" },
// ... add more as needed
]

export default function SelectAutofillPage() {
const service = useMachine(select.machine, {
collection: select.collection({ items: US_STATES }),
id: useId(),
name: "state",
autoComplete: "address-level1",
})

const api = select.connect(service, normalizeProps)

return (
<main style={{ padding: "2rem", maxWidth: 400 }}>
<h1>Select Autofill Test</h1>
<p style={{ marginBottom: "1rem", color: "#666" }}>
Fill street address first, then use Chrome autofill. State should update.
</p>

<form
onSubmit={(e) => {
e.preventDefault()
console.log("Form submitted", Object.fromEntries(new FormData(e.currentTarget)))
}}
>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="street">Street address</label>
<input
id="street"
name="street"
type="text"
autoComplete="street-address"
style={{ display: "block", width: "100%", padding: 8, marginTop: 4 }}
/>
</div>

<div {...api.getRootProps()} style={{ marginBottom: "1rem" }}>
<label {...api.getLabelProps()}>State</label>
<div {...api.getControlProps()}>
<button
{...api.getTriggerProps()}
style={{
display: "flex",
width: "100%",
padding: 8,
marginTop: 4,
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>{api.valueAsString || "Select state"}</span>
<span {...api.getIndicatorProps()}>▼</span>
</button>
</div>

<select {...api.getHiddenSelectProps()}>
{api.empty && <option value="" />}
{US_STATES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>

<Portal>
<div {...api.getPositionerProps()}>
<ul {...api.getContentProps()}>
{US_STATES.map((item) => (
<li key={item.value} {...api.getItemProps({ item })}>
{item.label}
</li>
))}
</ul>
</div>
</Portal>
</div>

<button type="submit">Submit</button>
</form>
</main>
)
}
87 changes: 87 additions & 0 deletions examples/nuxt-ts/app/components/CascadeSelectNode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script setup lang="ts">
import type { Api } from "@zag-js/cascade-select"
import type { TreeCollection } from "@zag-js/collection"

interface Node {
label: string
value: string
continents?: Node[]
countries?: Node[]
code?: string
states?: Node[]
}

interface TreeNodeProps {
node: Node
indexPath: number[]
value: string[]
api: Api
collection: TreeCollection<Node>
}

const props = defineProps<TreeNodeProps>()

const nodeProps = computed(() => ({
indexPath: props.indexPath,
value: props.value,
item: props.node,
}))

const nodeState = computed(() => props.api.getItemState(nodeProps.value))
const children = computed(() => props.collection.getNodeChildren(props.node))
</script>

<template>
<ul v-bind="api.getListProps(nodeProps)">
<li
v-for="(item, index) in children"
:key="item.label"
v-bind="
api.getItemProps({
indexPath: [...indexPath, index],
value: [...value, collection.getNodeValue(item)],
item,
})
"
>
<span
v-bind="
api.getItemTextProps({
indexPath: [...indexPath, index],
value: [...value, collection.getNodeValue(item)],
item,
})
"
>{{ item.label }}</span
>
<span
v-bind="
api.getItemIndicatorProps({
indexPath: [...indexPath, index],
value: [...value, collection.getNodeValue(item)],
item,
})
"
>✓</span
>
<span
v-if="
api.getItemState({
indexPath: [...indexPath, index],
value: [...value, collection.getNodeValue(item)],
item,
}).hasChildren
"
>&gt;</span
>
</li>
</ul>
<CascadeSelectNode
v-if="nodeState.highlightedChild && collection.isBranchNode(nodeState.highlightedChild)"
:node="nodeState.highlightedChild"
:api="api"
:collection="collection"
:index-path="[...indexPath, nodeState.highlightedIndex]"
:value="[...value, collection.getNodeValue(nodeState.highlightedChild)]"
/>
</template>
82 changes: 82 additions & 0 deletions examples/nuxt-ts/app/pages/cascade-select.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
import * as cascadeSelect from "@zag-js/cascade-select"
import { cascadeSelectControls, cascadeSelectData } from "@zag-js/shared"
import { normalizeProps, useMachine } from "@zag-js/vue"

interface Node {
label: string
value: string
continents?: Node[]
countries?: Node[]
code?: string
states?: Node[]
}

const collection = cascadeSelect.collection<Node>({
nodeToValue: (node) => node.value,
nodeToString: (node) => node.label,
nodeToChildren: (node) => node.continents ?? node.countries ?? node.states,
rootNode: cascadeSelectData,
})

const controls = useControls(cascadeSelectControls)

const service = useMachine(
cascadeSelect.machine,
controls.mergeProps<cascadeSelect.Props>({
id: useId(),
collection,
name: "location",
}),
)

const api = computed(() => cascadeSelect.connect(service, normalizeProps))
</script>

<template>
<main class="cascade-select">
<div v-bind="api.getRootProps()">
<label v-bind="api.getLabelProps()">Select a location</label>

<div v-bind="api.getControlProps()">
<button v-bind="api.getTriggerProps()">
<span>{{ api.valueAsString || "Select a location" }}</span>
<span v-bind="api.getIndicatorProps()">▼</span>
</button>
<button v-bind="api.getClearTriggerProps()">X</button>
</div>

<input v-bind="api.getHiddenInputProps()" />

<Teleport to="#teleports">
<div v-bind="api.getPositionerProps()">
<div v-bind="api.getContentProps()">
<CascadeSelectNode
:node="collection.rootNode"
:api="api"
:collection="collection"
:index-path="[]"
:value="[]"
/>
</div>
</div>
</Teleport>
</div>

<div style="margin-top: 350px">
<h3>Highlighted Value:</h3>
<pre>{{ JSON.stringify(api.highlightedValue, null, 2) }}</pre>
</div>
<div style="margin-top: 20px">
<h3>Selected Value:</h3>
<pre>{{ JSON.stringify(api.value, null, 2) }}</pre>
</div>
</main>

<Toolbar>
<StateVisualizer :state="service" :omit="['collection']" />
<template #controls>
<Controls :control="controls" />
</template>
</Toolbar>
</template>
1 change: 1 addition & 0 deletions examples/nuxt-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@zag-js/auto-resize": "workspace:*",
"@zag-js/avatar": "workspace:*",
"@zag-js/bottom-sheet": "workspace:*",
"@zag-js/cascade-select": "workspace:*",
"@zag-js/carousel": "workspace:*",
"@zag-js/checkbox": "workspace:*",
"@zag-js/clipboard": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions examples/preact-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@zag-js/auto-resize": "workspace:*",
"@zag-js/avatar": "workspace:*",
"@zag-js/bottom-sheet": "workspace:*",
"@zag-js/cascade-select": "workspace:*",
"@zag-js/carousel": "workspace:*",
"@zag-js/checkbox": "workspace:*",
"@zag-js/clipboard": "workspace:*",
Expand Down
Loading
Loading