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 bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
"workspaces": [
"packages/*",
"templates/**",
"integrations/**",
"scripts",
"website"
],
"scripts": {
"postinstall": "lefthook install",
"build": "turbo run build --filter='!./templates/**' --filter='!./website/**' --filter='!./integrations/**'",
"build": "turbo run build --filter='!./templates/**' --filter='!./website/**'",
"check:anatomy": "bun scripts check:anatomy",
"check:exports": "bun scripts check:exports",
"check:zag": "bun scripts check:zag",
Expand All @@ -29,7 +28,7 @@
"svelte": "bun run --cwd packages/svelte",
"vue": "bun run --cwd packages/vue",
"web": "bun run --cwd website",
"mcp": "bun run --cwd integrations/mcp",
"mcp": "bun run --cwd packages/mcp",
"setup": "vc link --scope=chakra-ui -p ark-docs -y && vc env pull website/.env",
"setup:prod": "vc link --scope=chakra-ui -p ark-docs -y && vc env pull --environment production website/.env"
},
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"repository": {
"type": "git",
"url": "https://github.com/chakra-ui/ark",
"directory": "integrations/mcp"
"directory": "packages/mcp"
},
"scripts": {
"build": "tsup && chmod 755 dist/stdio.js",
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
6 changes: 6 additions & 0 deletions packages/react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
## [Unreleased]

### Added

- **Field**: Added `Field.Item` component and `target` prop on `Field.Root` for multi-control fields (e.g., currency
select + amount input). Use `Field.Item` with a `value` to scope controls, and `target` to specify which item the
label should focus when clicked.

## [5.34.1] - 2026-03-03

### Fixed
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/components/field/examples/item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Field } from '@ark-ui/react/field'
import styles from 'styles/field.module.css'

export const Item = () => (
<Field.Root className={styles.Root} target="amount">
<Field.Label className={styles.Label}>Amount</Field.Label>
<Field.Item value="currency">
<Field.Select className={styles.Select}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</Field.Select>
</Field.Item>
<Field.Item value="amount">
<Field.Input className={styles.Input} />
</Field.Item>
<Field.HelperText className={styles.HelperText}>Enter the amount</Field.HelperText>
<Field.ErrorText className={styles.ErrorText}>Invalid amount</Field.ErrorText>
</Field.Root>
)
64 changes: 64 additions & 0 deletions packages/react/src/components/field/field-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useMemo, type PropsWithChildren } from 'react'
import type { HTMLProps } from '../factory'
import { parts } from './field.anatomy'
import { FieldProvider, useFieldContext } from './use-field-context'

export interface FieldItemBaseProps {
value: string
}

export interface FieldItemProps extends PropsWithChildren<FieldItemBaseProps> {}

export const FieldItem = (props: FieldItemProps) => {
const { value, children } = props
const parentField = useFieldContext()

const itemField = useMemo(() => {
if (!parentField) {
throw new Error('Field.Item must be used within Field.Root')
}

const controlId = `field::${parentField.ids.control}::item::${value}`
const labelId = `${controlId}::label`

const getControlProps = () =>
({
...parentField.getInputProps(),
id: controlId,
}) as HTMLProps<'input'>

return {
...parentField,
ids: {
...parentField.ids,
control: controlId,
label: labelId,
},
getLabelProps: () =>
({
...parentField.getLabelProps(),
id: labelId,
htmlFor: controlId,
}) as HTMLProps<'label'>,
getInputProps: () =>
({
...getControlProps(),
...parts.input.attrs,
}) as HTMLProps<'input'>,
getSelectProps: () =>
({
...getControlProps(),
...parts.select.attrs,
}) as HTMLProps<'select'>,
getTextareaProps: () =>
({
...getControlProps(),
...parts.textarea.attrs,
}) as HTMLProps<'textarea'>,
}
}, [parentField, value])

return <FieldProvider value={itemField}>{children}</FieldProvider>
}

FieldItem.displayName = 'FieldItem'
1 change: 1 addition & 0 deletions packages/react/src/components/field/field-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const FieldRoot = forwardRef<HTMLDivElement, FieldRootProps>((props, ref)
'invalid',
'readOnly',
'required',
'target',
])

const field = useField(useFieldProps)
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/components/field/field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export { Textarea } from './examples/textarea'
export { TextareaAutoresize } from './examples/textarea-autoresize'
export { Disabled } from './examples/disabled'
export { CustomControl } from './examples/custom-control'
export { Item } from './examples/item'
212 changes: 212 additions & 0 deletions packages/react/src/components/field/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,215 @@ describe('Field / Input', () => {
await waitFor(() => expect(textbox).toHaveAttribute('aria-describedby'))
})
})

describe('Field / Item', () => {
const ItemTest = () => (
<Field.Root target="amount">
<Field.Label>Amount</Field.Label>
<Field.Item value="currency">
<Field.Select>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</Field.Select>
</Field.Item>
<Field.Item value="amount">
<Field.Input />
</Field.Item>
<Field.HelperText>Enter the amount</Field.HelperText>
</Field.Root>
)

function formatFieldParts(parts: Array<{ name: string; element: Element | null; attrs?: string[] }>): string {
return parts
.filter((p) => p.element)
.map((p) => {
const el = p.element as Element
const attrList = (p.attrs ?? ['id'])
.map((a) => `${a}=${a === 'id' ? (el as HTMLElement).id : el.getAttribute(a)}`)
.join(', ')
return `${p.name} (${attrList})`
})
.join('\n')
}

it('should render the correct html structure', async () => {
const { container } = render(<ItemTest />)
await waitFor(() => {
const root = container.firstElementChild!
const structure = formatFieldParts([
{ name: 'label', element: root.querySelector('[data-part=label]'), attrs: ['id', 'for'] },
{ name: 'Field.Select', element: root.querySelector('[data-part=select]') },
{ name: 'Field.Input', element: root.querySelector('[data-part=input]') },
])
expect(structure).toMatchInlineSnapshot(`
"label (id=field::_r_9_::label, for=field::_r_9_::item::amount)
Field.Select (id=field::_r_9_::item::currency)
Field.Input (id=field::_r_9_::item::amount)"
`)
})
})

it('should focus the target input when label is clicked', async () => {
render(<ItemTest />)
await user.click(screen.getByText('Amount'))
expect(screen.getByRole('textbox')).toHaveFocus()
})

it('should scope control id for custom controls via Field.Context', () => {
const CustomControlTest = () => (
<Field.Root target="custom">
<Field.Label>Custom</Field.Label>
<Field.Item value="custom">
<Field.Context>
{(context) => <input data-testid="custom-input" {...context.getInputProps()} />}
</Field.Context>
</Field.Item>
</Field.Root>
)
render(<CustomControlTest />)
const input = screen.getByTestId('custom-input')
expect(input.id).toContain('item::custom')
expect(screen.getByText('Custom')).toHaveAttribute('for', input.id)
})

it('should work when mixing Field.Item with a direct control under Field.Root', async () => {
const MixedTest = () => (
<Field.Root>
<Field.Label>Amount</Field.Label>
<Field.Item value="currency">
<Field.Select data-testid="currency-select">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</Field.Select>
</Field.Item>
<Field.Input data-testid="amount-input" />
<Field.HelperText>Enter the amount</Field.HelperText>
</Field.Root>
)
const { container } = render(<MixedTest />)

await waitFor(() => {
const root = container.firstElementChild!
const structure = formatFieldParts([
{ name: 'label', element: root.querySelector('[data-part=label]'), attrs: ['id', 'for'] },
{ name: 'Field.Select', element: root.querySelector('[data-part=select]') },
{ name: 'Field.Input', element: root.querySelector('[data-part=input]') },
])
expect(structure).toMatchInlineSnapshot(`
"label (id=field::_r_c_::label, for=_r_c_)
Field.Select (id=field::_r_c_::item::currency)
Field.Input (id=_r_c_)"
`)
})

// label points to the root control (no target set)
await user.click(screen.getByText('Amount'))
expect(screen.getByTestId('amount-input')).toHaveFocus()
})

it('should focus the item control when mixing Field.Item with a direct control and target is set', async () => {
const MixedWithTargetTest = () => (
<Field.Root target="currency">
<Field.Label>Currency</Field.Label>
<Field.Item value="currency">
<Field.Select data-testid="currency-select">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</Field.Select>
</Field.Item>
<Field.Input data-testid="amount-input" />
<Field.HelperText>Select a currency</Field.HelperText>
</Field.Root>
)
const { container } = render(<MixedWithTargetTest />)

await waitFor(() => {
const root = container.firstElementChild!
const structure = formatFieldParts([
{ name: 'label', element: root.querySelector('[data-part=label]'), attrs: ['id', 'for'] },
{ name: 'Field.Select', element: root.querySelector('[data-part=select]') },
{ name: 'Field.Input', element: root.querySelector('[data-part=input]') },
])
expect(structure).toMatchInlineSnapshot(`
"label (id=field::_r_d_::label, for=field::_r_d_::item::currency)
Field.Select (id=field::_r_d_::item::currency)
Field.Input (id=_r_d_)"
`)
})

// label points to the item control (target set to "currency")
await user.click(screen.getByText('Currency'))
expect(screen.getByTestId('currency-select')).toHaveFocus()
})

it('should propagate disabled state to items', () => {
render(
<Field.Root disabled target="amount">
<Field.Label>Amount</Field.Label>
<Field.Item value="currency">
<Field.Select data-testid="currency-select">
<option value="USD">USD</option>
</Field.Select>
</Field.Item>
<Field.Item value="amount">
<Field.Input data-testid="amount-input" />
</Field.Item>
</Field.Root>,
)
expect(screen.getByTestId('currency-select')).toBeDisabled()
expect(screen.getByTestId('amount-input')).toBeDisabled()
expect(screen.getByText('Amount')).toHaveAttribute('data-disabled')
})

it('should propagate invalid state to items', () => {
render(
<Field.Root invalid target="amount">
<Field.Label>Amount</Field.Label>
<Field.Item value="amount">
<Field.Input data-testid="amount-input" />
</Field.Item>
<Field.ErrorText>Invalid amount</Field.ErrorText>
</Field.Root>,
)
expect(screen.getByTestId('amount-input')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByTestId('amount-input')).toHaveAttribute('data-invalid')
expect(screen.getByText('Invalid amount')).toBeInTheDocument()
})

it('should work with NumberInput inside Field.Item', async () => {
const { NumberInput } = await import('@ark-ui/react/number-input')

render(
<Field.Root target="amount">
<Field.Label>Amount</Field.Label>
<Field.Item value="currency">
<Field.Select data-testid="currency-select">
<option value="USD">USD</option>
</Field.Select>
</Field.Item>
<Field.Item value="amount">
<NumberInput.Root>
<NumberInput.Input data-testid="number-input" />
</NumberInput.Root>
</Field.Item>
<Field.HelperText>Enter the amount</Field.HelperText>
</Field.Root>,
)

const input = screen.getByTestId('number-input')
expect(input.id).toContain('item::amount')

await user.click(screen.getByText('Amount'))
expect(input).toHaveFocus()
})

it('should throw when Field.Item is used outside Field.Root', () => {
expect(() =>
render(
<Field.Item value="amount">
<Field.Input />
</Field.Item>,
),
).toThrow('Field.Item must be used within Field.Root')
})
})
5 changes: 5 additions & 0 deletions packages/react/src/components/field/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export {
type FieldInputBaseProps as InputBaseProps,
type FieldInputProps as InputProps,
} from './field-input'
export {
FieldItem as Item,
type FieldItemBaseProps as ItemBaseProps,
type FieldItemProps as ItemProps,
} from './field-item'
export {
FieldLabel as Label,
type FieldLabelBaseProps as LabelBaseProps,
Expand Down
Loading