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
19 changes: 0 additions & 19 deletions .changeset/date-picker-range-attrs.md

This file was deleted.

7 changes: 0 additions & 7 deletions .changeset/dialog-non-modal.md

This file was deleted.

18 changes: 0 additions & 18 deletions .changeset/drawer-swipe-area.md

This file was deleted.

7 changes: 0 additions & 7 deletions .changeset/focus-trap-single-tab-stop.md

This file was deleted.

6 changes: 0 additions & 6 deletions .changeset/interact-outside-safari-focus.md

This file was deleted.

22 changes: 0 additions & 22 deletions .changeset/pin-input-improvements.md

This file was deleted.

64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,70 @@ All notable changes to this project will be documented in this file.

> For v0.x changelog, see the [v0 branch](https://github.com/chakra-ui/zag/blob/v0/CHANGELOG.md)

## [1.38.0](./#1.38.0) - 2026-03-24

### Added

- **Date Picker**
- Add missing range data attributes (`data-range-start`, `data-range-end`, `data-in-hover-range`,
`data-hover-range-start`, `data-hover-range-end`) to month and year cell triggers for range picker mode
- `TableCellState` now includes `firstInRange`, `lastInRange`, `inHoveredRange`, `firstInHoveredRange`,
`lastInHoveredRange`, and `outsideRange`
- Range boundary dates now announce "Starting range from {date}" and "Range ending at {date}" for better screen reader
context

- **Drawer**
- Add `description` anatomy part with `aria-describedby` support on the content element
- Add `SwipeArea` part for swipe-to-open gestures from screen edges

```tsx
<div {...api.getSwipeAreaProps()} />
```

- Add `getDescriptionProps()` and `getSwipeAreaProps()` to the connect API
- Require intentional swipe movement before showing the drawer (no flash on pointer down)
- Smooth settle animation from release point to fully open position
- Add cross-axis scroll preservation to prevent drawer drag when scrolling horizontally
- Add initial focus management for non-modal mode

- **Pin Input**
- **No more holes**: Delete and Backspace now splice values left instead of leaving empty gaps. Deleting "2" from
`[1, 2, 3]` yields `[1, 3, ""]` — not `[1, "", 3]`. Cut (`Ctrl+X`) behaves the same way.
- **Smarter focus management**: Backspace always moves back, click and ArrowRight are clamped to the insertion point,
same-key skip advances focus, and roving tabIndex treats the entire pin input as a single tab stop
- **New keyboard shortcuts**: Home/End to jump to first slot or last filled slot, `enterKeyHint` shows "next" on
intermediate slots and "done" on the last
- Add `autoSubmit` prop to automatically submit the owning form when all inputs are filled
- Add `sanitizeValue` prop to sanitize pasted values before validation (e.g. strip dashes from "1-2-3")

### Fixed

- **Date Picker**: Fix inverted year cell `selectable` state that caused years outside the visible decade or min/max
range to appear selectable

- **Dialog**
- Set `pointer-events: none` on positioner in non-modal mode so the page stays interactive
- Add initial focus management for non-modal mode (mirrors popover behavior)
- Fix `aria-modal` to reflect actual `modal` prop value instead of hardcoded `true`

- **Drawer**: Fix swipe-to-dismiss in controlled mode (`open: true` without `onOpenChange` now blocks dismiss)

- **Floating Panel**: Fix `closeOnEscape` not working when focus is on a child element (e.g., input) inside the panel

- **Focus Trap**: Fix focus trapping when the content has a single effective tab stop, such as a native radio group.
Handle disconnected `initialFocus` nodes more safely.

- **Interact Outside**: Fix issue where nested popovers and select within popovers didn't toggle correctly in Safari due
to `focusin` events racing with pointer interactions

- **Splitter**: Fix global cursor styles when splitter is used in a shadow root

### Changed

- **Date Picker**: `DayTableCellState.formattedDate` removed — use `valueText` instead (inherited from `TableCellState`)

- **Drawer**: Set `pointer-events: none` on positioner in non-modal mode so the page stays interactive

## [1.37.0](./#1.37.0) - 2026-03-16

### Added
Expand Down
4 changes: 4 additions & 0 deletions e2e/models/pin-input.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export class PinInputModel extends Model {
await expect(this.getInput(index)).toBeFocused()
}

async seeInputIsNotFocused(index: number) {
await expect(this.getInput(index)).not.toBeFocused()
}

async seeInputHasValue(index: number, value: string) {
await expect(this.getInput(index)).toHaveValue(value)
}
Expand Down
25 changes: 25 additions & 0 deletions e2e/pin-input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,31 @@ test.describe("pin input", () => {
await I.seeInputIsFocused(2)
})

// --- Blur on complete ---

test("blurOnComplete: should blur after entering last value", async () => {
await I.controls.bool("blurOnComplete")
await I.fillInput(1, "1")
await I.fillInput(2, "2")
await I.fillInput(3, "3")
await I.seeInputIsNotFocused(3)
})

test("blurOnComplete: backspace on last input should not blur", async () => {
await I.controls.bool("blurOnComplete")
await I.fillInput(1, "1")
await I.fillInput(2, "2")
await I.fillInput(3, "3")
// blurred after complete
await I.seeInputIsNotFocused(3)

// click back into the last input and backspace
await I.clickInput(3)
await I.pressKey("Backspace")
// should stay focused on input 2, not blur
await I.seeInputIsFocused(2)
})

// --- RTL ---

test("rtl: arrow keys should be reversed", async () => {
Expand Down
17 changes: 17 additions & 0 deletions examples/next-ts/hooks/use-focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { trapFocus, type TrapFocusOptions } from "@zag-js/focus-trap"
import { useEffect, type RefObject } from "react"

export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
options: TrapFocusOptions & { enabled?: boolean } = {},
) {
const { enabled = true, ...trapOptions } = options

useEffect(() => {
if (!enabled) return
const el = ref.current
if (!el) return

return trapFocus(el, trapOptions)
}, [enabled])
}
18 changes: 9 additions & 9 deletions examples/next-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@internationalized/date": "3.11.0",
"@tanstack/react-virtual": "3.13.19",
"@internationalized/date": "3.12.0",
"@tanstack/react-virtual": "3.13.23",
"@types/textarea-caret": "3.0.4",
"@zag-js/accordion": "workspace:*",
"@zag-js/anatomy": "workspace:*",
Expand Down Expand Up @@ -97,25 +97,25 @@
"@zag-js/utils": "workspace:*",
"form-serialize": "0.7.2",
"image-conversion": "2.1.1",
"lucide-react": "0.575.0",
"next": "16.1.6",
"lucide-react": "1.6.0",
"next": "16.2.1",
"next-cloudinary": "6.17.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "7.71.2",
"react-hook-form": "7.72.0",
"react-spinners": "0.17.0",
"textarea-caret": "^3.1.0",
"virtua": "^0.48.6"
"virtua": "^0.48.8"
},
"devDependencies": {
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.2.1",
"@types/form-serialize": "0.7.4",
"@types/node": "25.3.3",
"@types/node": "25.5.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"typescript": "5.9.3"
"typescript": "6.0.2"
},
"license": "MIT"
}
72 changes: 72 additions & 0 deletions examples/next-ts/pages/floating-panel/focus-trap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as floating from "@zag-js/floating-panel"
import { normalizeProps, useMachine } from "@zag-js/react"
import { ArrowDownLeft, Maximize2, Minus, XIcon } from "lucide-react"
import { useId, useRef } from "react"
import { StateVisualizer } from "../../components/state-visualizer"
import { useFocusTrap } from "../../hooks/use-focus-trap"
import { Toolbar } from "../../components/toolbar"

export default function Page() {
const service = useMachine(floating.machine, {
id: useId(),
closeOnEscape: true,
})

const api = floating.connect(service, normalizeProps)
const contentRef = useRef<HTMLDivElement>(null)

useFocusTrap(contentRef, { enabled: api.open })

return (
<>
<main className="floating-panel">
<div>
<button {...api.getTriggerProps()}>Toggle Panel</button>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()} ref={contentRef}>
<div {...api.getDragTriggerProps()}>
<div {...api.getHeaderProps()}>
<p {...api.getTitleProps()}>Floating Panel (Focus Trap)</p>
<div {...api.getControlProps()}>
<button {...api.getStageTriggerProps({ stage: "minimized" })}>
<Minus />
</button>
<button {...api.getStageTriggerProps({ stage: "maximized" })}>
<Maximize2 />
</button>
<button {...api.getStageTriggerProps({ stage: "default" })}>
<ArrowDownLeft />
</button>
<button {...api.getCloseTriggerProps()}>
<XIcon />
</button>
</div>
</div>
</div>
<div {...api.getBodyProps()}>
<p>Focus is trapped within this panel when open.</p>
<label>
Name
<input type="text" placeholder="Enter name" />
</label>
<label>
Email
<input type="email" placeholder="Enter email" />
</label>
<button type="button">Submit</button>
</div>

{floating.resizeTriggerAxes.map((axis) => (
<div key={axis} {...api.getResizeTriggerProps({ axis })} />
))}
</div>
</div>
</div>
</main>

<Toolbar>
<StateVisualizer state={service} />
</Toolbar>
</>
)
}
Loading