Skip to content

Commit

Permalink
feat(*): add focus-trap (#3200)
Browse files Browse the repository at this point in the history
* chore: init

* chore: impl vue and solid

* fix: typecheck

* build: add use client to focus trap

* refactor: streamline props

* docs: add changelog
  • Loading branch information
segunadebayo authored Jan 9, 2025
1 parent 18d52db commit 430d5cc
Show file tree
Hide file tree
Showing 29 changed files with 502 additions and 1 deletion.
Binary file modified bun.lockb
Binary file not shown.
4 changes: 4 additions & 0 deletions packages/react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ description: All notable changes will be documented in this file.

## [Unreleased]

### Added

- **[NEW] FocusTrap**: Added `FocusTrap` component for trapping focus within a container.

## [4.7.0] - 2025-01-08

### Added
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
"@zag-js/dom-query": "0.81.0",
"@zag-js/editable": "0.81.0",
"@zag-js/file-upload": "0.81.0",
"@zag-js/focus-trap": "0.81.0",
"@zag-js/file-utils": "0.81.0",
"@zag-js/highlight-word": "0.81.0",
"@zag-js/hover-card": "0.81.0",
Expand Down
34 changes: 34 additions & 0 deletions packages/react/src/components/focus-trap/examples/autofocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useRef, useState } from 'react'
import { FocusTrap } from '../focus-trap'

export const Autofocus = () => {
const [trapped, setTrapped] = useState(false)
const toggle = () => setTrapped((c) => !c)

const buttonRef = useRef<HTMLButtonElement | null>(null)
const getButtonNode = () => {
const node = buttonRef.current
if (!node) throw new Error('Button not found')
return node
}

return (
<div>
<button ref={buttonRef} onClick={toggle}>
{trapped ? 'End Trap' : 'Start Trap'}
</button>
{trapped && (
<FocusTrap disabled={!trapped} setReturnFocus={getButtonNode}>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '1rem', paddingBlock: '1rem' }}
>
<input type="text" placeholder="Regular input" />
{/* biome-ignore lint/a11y/noAutofocus: <explanation> */}
<input type="text" placeholder="Autofocused input" autoFocus />
<button onClick={() => setTrapped(false)}>End Trap</button>
</div>
</FocusTrap>
)}
</div>
)
}
20 changes: 20 additions & 0 deletions packages/react/src/components/focus-trap/examples/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useState } from 'react'
import { FocusTrap } from '../focus-trap'

export const Basic = () => {
const [trapped, setTrapped] = useState(false)
return (
<>
<button onClick={() => setTrapped(true)}>Start Trap</button>
<FocusTrap returnFocusOnDeactivate={false} disabled={!trapped}>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '1rem', paddingBlock: '1rem' }}
>
<input type="text" placeholder="input" />
<textarea placeholder="textarea" />
<button onClick={() => setTrapped(false)}>End Trap</button>
</div>
</FocusTrap>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useState, useRef } from 'react'
import { FocusTrap } from '../focus-trap'

export const InitialFocus = () => {
const [trapped, setTrapped] = useState(false)
const toggle = () => setTrapped((c) => !c)

const inputRef = useRef<HTMLInputElement>(null)

return (
<div>
<button onClick={toggle}>{trapped ? 'End Trap' : 'Start Trap'}</button>
<FocusTrap disabled={!trapped} initialFocus={() => inputRef.current}>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '1rem', paddingBlock: '1rem' }}
>
<input type="text" placeholder="First input" />
<input ref={inputRef} type="text" placeholder="Second input (initial focus)" />
<textarea placeholder="textarea" />
<button onClick={() => setTrapped(false)}>End Trap</button>
</div>
</FocusTrap>
</div>
)
}
11 changes: 11 additions & 0 deletions packages/react/src/components/focus-trap/focus-trap.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Meta } from '@storybook/react'

const meta: Meta = {
title: 'Components / Focus Trap',
}

export default meta

export { Basic } from './examples/basic'
export { InitialFocus } from './examples/initial-focus'
export { Autofocus } from './examples/autofocus'
50 changes: 50 additions & 0 deletions packages/react/src/components/focus-trap/focus-trap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type FocusTrapOptions, trapFocus } from '@zag-js/focus-trap'
import { forwardRef, useRef } from 'react'
import type { Assign } from '../../types'
import { composeRefs } from '../../utils/compose-refs'
import { createSplitProps } from '../../utils/create-split-props'
import { useSafeLayoutEffect } from '../../utils/use-safe-layout-effect'
import { type HTMLProps, type PolymorphicProps, ark } from '../factory'

export interface TrapOptions
extends Pick<
FocusTrapOptions,
| 'onActivate'
| 'onDeactivate'
| 'initialFocus'
| 'fallbackFocus'
| 'returnFocusOnDeactivate'
| 'setReturnFocus'
> {
/**
* Whether the focus trap is disabled.
*/
disabled?: boolean
}

export interface FocusTrapBaseProps extends PolymorphicProps, TrapOptions {}

export interface FocusTrapProps extends Assign<HTMLProps<'div'>, FocusTrapBaseProps> {}

export const FocusTrap = forwardRef<HTMLDivElement, FocusTrapProps>((props, ref) => {
const localRef = useRef<HTMLDivElement | null>(null)
const [trapProps, localProps] = createSplitProps<TrapOptions>()(props, [
'disabled',
'onActivate',
'onDeactivate',
'initialFocus',
'fallbackFocus',
'returnFocusOnDeactivate',
'setReturnFocus',
])

useSafeLayoutEffect(() => {
const node = localRef.current
if (!node || trapProps.disabled) return
return trapFocus(node, trapProps)
}, [ref, trapProps])

return <ark.div ref={composeRefs(localRef, ref)} {...localProps} />
})

FocusTrap.displayName = 'FocusTrap'
2 changes: 2 additions & 0 deletions packages/react/src/components/focus-trap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { FocusTrap } from './focus-trap'
export type { FocusTrapBaseProps, FocusTrapProps } from './focus-trap'
2 changes: 1 addition & 1 deletion packages/react/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default defineConfig({

const renderBanner = (fileName: string) => {
const file = path.parse(fileName)
if (['portal', 'frame', 'client-only'].includes(file.name)) {
if (['portal', 'frame', 'client-only', 'focus-trap'].includes(file.name)) {
return `'use client';`
}
if (isBarrelComponent(file) || isSpecialFile(file)) {
Expand Down
4 changes: 4 additions & 0 deletions packages/solid/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ description: All notable changes will be documented in this file.

## [Unreleased]

### Added

- **[NEW] FocusTrap**: Added `FocusTrap` component for trapping focus within a container.

## [4.8.0] - 2025-01-08

### Added
Expand Down
1 change: 1 addition & 0 deletions packages/solid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
"@zag-js/dialog": "0.81.0",
"@zag-js/dom-query": "0.81.0",
"@zag-js/editable": "0.81.0",
"@zag-js/focus-trap": "0.81.0",
"@zag-js/file-upload": "0.81.0",
"@zag-js/file-utils": "0.81.0",
"@zag-js/highlight-word": "0.81.0",
Expand Down
31 changes: 31 additions & 0 deletions packages/solid/src/components/focus-trap/examples/autofocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { FocusTrap } from '@ark-ui/solid/focus-trap'
import { createSignal, Show } from 'solid-js'

export const Autofocus = () => {
const [trapped, setTrapped] = createSignal(false)
let buttonRef!: HTMLButtonElement

return (
<div>
<button ref={buttonRef} onClick={() => setTrapped((v) => !v)}>
{trapped() ? 'End Trap' : 'Start Trap'}
</button>
<Show when={trapped()}>
<FocusTrap disabled={!trapped()} setReturnFocus={buttonRef}>
<div
style={{
display: 'flex',
'flex-direction': 'column',
gap: '1rem',
'padding-block': '1rem',
}}
>
<input type="text" placeholder="Regular input" />
<input type="text" placeholder="Autofocused input" autofocus />
<button onClick={() => setTrapped(false)}>End Trap</button>
</div>
</FocusTrap>
</Show>
</div>
)
}
25 changes: 25 additions & 0 deletions packages/solid/src/components/focus-trap/examples/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FocusTrap } from '@ark-ui/solid/focus-trap'
import { createSignal } from 'solid-js'

export const Basic = () => {
const [trapped, setTrapped] = createSignal(false)
return (
<>
<button onClick={() => setTrapped(true)}>Start Trap</button>
<FocusTrap returnFocusOnDeactivate={false} disabled={!trapped()}>
<div
style={{
display: 'flex',
'flex-direction': 'column',
gap: '1rem',
'padding-block': '1rem',
}}
>
<input type="text" placeholder="input" />
<textarea placeholder="textarea" />
<button onClick={() => setTrapped(false)}>End Trap</button>
</div>
</FocusTrap>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FocusTrap } from '@ark-ui/solid/focus-trap'
import { createSignal } from 'solid-js'

export const InitialFocus = () => {
const [trapped, setTrapped] = createSignal(false)
const toggle = () => setTrapped((v) => !v)

let inputRef!: HTMLInputElement

return (
<div>
<button onClick={toggle}>{trapped() ? 'End Trap' : 'Start Trap'}</button>
<FocusTrap disabled={!trapped()} initialFocus={() => inputRef}>
<div
style={{
display: 'flex',
'flex-direction': 'column',
gap: '1rem',
'padding-block': '1rem',
}}
>
<input type="text" placeholder="First input" />
<input ref={inputRef} type="text" placeholder="Second input (initial focus)" />
<textarea placeholder="textarea" />
<button onClick={toggle}>End Trap</button>
</div>
</FocusTrap>
</div>
)
}
11 changes: 11 additions & 0 deletions packages/solid/src/components/focus-trap/focus-trap.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Meta } from 'storybook-solidjs'

const meta: Meta = {
title: 'Components / Focus Trap',
}

export default meta

export { Basic } from './examples/basic'
export { InitialFocus } from './examples/initial-focus'
export { Autofocus } from './examples/autofocus'
50 changes: 50 additions & 0 deletions packages/solid/src/components/focus-trap/focus-trap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type FocusTrapOptions, trapFocus } from '@zag-js/focus-trap'
import { createEffect, onCleanup } from 'solid-js'
import type { Assign } from '../../types'
import { composeRefs } from '../../utils/compose-refs'
import { createSplitProps } from '../../utils/create-split-props'
import { type HTMLProps, type PolymorphicProps, ark } from '../factory'

export interface TrapOptions
extends Pick<
FocusTrapOptions,
| 'onActivate'
| 'onDeactivate'
| 'initialFocus'
| 'fallbackFocus'
| 'returnFocusOnDeactivate'
| 'setReturnFocus'
> {
/**
* Whether the focus trap is disabled.
*/
disabled?: boolean
}

export interface FocusTrapBaseProps extends PolymorphicProps<'div'>, TrapOptions {}

export interface FocusTrapProps extends Assign<HTMLProps<'div'>, FocusTrapBaseProps> {}

export const FocusTrap = (props: FocusTrapProps) => {
let localNode!: HTMLDivElement

const [trapProps, localProps] = createSplitProps<TrapOptions>()(props, [
'disabled',
'onActivate',
'onDeactivate',
'initialFocus',
'fallbackFocus',
'returnFocusOnDeactivate',
'setReturnFocus',
])

createEffect(() => {
if (!localNode || trapProps.disabled) return
const autoFocusNode = localNode.querySelector<HTMLElement>('[autofocus], [data-autofocus]')
trapProps.initialFocus ||= autoFocusNode ?? undefined
onCleanup(trapFocus(localNode, trapProps))
})

// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
return <ark.div {...localProps} ref={composeRefs((el) => (localNode = el), props.ref)} />
}
2 changes: 2 additions & 0 deletions packages/solid/src/components/focus-trap/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { FocusTrap } from './focus-trap'
export type { FocusTrapBaseProps, FocusTrapProps } from './focus-trap'
1 change: 1 addition & 0 deletions packages/solid/src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from './factory'
export * from './field'
export * from './fieldset'
export * from './file-upload'
export * from './focus-trap'
export * from './format'
export * from './frame'
export * from './highlight'
Expand Down
4 changes: 4 additions & 0 deletions packages/vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ description: All notable changes will be documented in this file.

## [Unreleased]

### Added

- **[NEW] FocusTrap**: Added `FocusTrap` component for trapping focus within a container.

## [4.7.0] - 2025-01-08

### Added
Expand Down
1 change: 1 addition & 0 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@
"@zag-js/highlight-word": "0.81.0",
"@zag-js/hover-card": "0.81.0",
"@zag-js/i18n-utils": "0.81.0",
"@zag-js/focus-trap": "0.81.0",
"@zag-js/file-utils": "0.81.0",
"@zag-js/menu": "0.81.0",
"@zag-js/number-input": "0.81.0",
Expand Down
22 changes: 22 additions & 0 deletions packages/vue/src/components/focus-trap/examples/autofocus.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { ref } from 'vue'
import { FocusTrap } from '@ark-ui/vue/focus-trap'
const trapped = ref(false)
const buttonRef = ref<HTMLButtonElement>()
</script>

<template>
<div>
<button ref="buttonRef" @click="trapped = !trapped">
{{ trapped ? 'End Trap' : 'Start Trap' }}
</button>
<FocusTrap v-if="trapped" :disabled="!trapped" :set-return-focus="buttonRef">
<div style="display: flex; flex-direction: column; gap: 1rem; padding-block: 1rem">
<input type="text" placeholder="Regular input" />
<input type="text" placeholder="Autofocused input" autofocus />
<button @click="trapped = false">End Trap</button>
</div>
</FocusTrap>
</div>
</template>
Loading

0 comments on commit 430d5cc

Please sign in to comment.