Skip to content

Commit 7b9c4e2

Browse files
pawelgrimmclaude
andcommitted
feat: add FieldChromeContainer private primitive
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fd3b6ea commit 7b9c4e2

3 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
.container {
2+
/* border */
3+
border: 1px solid var(--reactist-inputs-idle);
4+
5+
/* color */
6+
background: var(--reactist-field-background);
7+
color: var(--reactist-field-content);
8+
9+
/* Read-only: matches native :read-only on form controls (scoped to
10+
* <input> / <textarea> to avoid catching unrelated elements that
11+
* default to :read-only), the ARIA convention (aria-readonly="true"),
12+
* and the data-attr convention used by Ariakit/Radix-style components.
13+
* The :not(='false') exclusion treats aria-readonly="false" /
14+
* data-readonly="false" as "not read-only" per the ARIA spec. */
15+
&:has(
16+
input:read-only,
17+
textarea:read-only,
18+
[aria-readonly]:not([aria-readonly='false']),
19+
[data-readonly]:not([data-readonly='false'])
20+
) {
21+
background-color: var(--reactist-field-readonly-background);
22+
color: var(--reactist-field-readonly-content);
23+
}
24+
25+
/* Disabled: matches the native disabled attribute (form controls),
26+
* the soft-disable ARIA convention (used by Reactist's own Button to
27+
* keep elements focusable while announcing as disabled), and the
28+
* data-attr convention used by Ariakit/Radix. */
29+
&:has(
30+
:disabled,
31+
[aria-disabled]:not([aria-disabled='false']),
32+
[data-disabled]:not([data-disabled='false'])
33+
) {
34+
background-color: var(--reactist-field-disabled-background);
35+
color: var(--reactist-field-disabled-content);
36+
}
37+
38+
/* Interactive border (hover/focus) — suppressed when the control is
39+
* in any disabled state. */
40+
&:not(
41+
:has(
42+
:disabled,
43+
[aria-disabled]:not([aria-disabled='false']),
44+
[data-disabled]:not([data-disabled='false'])
45+
)
46+
) {
47+
&:hover {
48+
border-color: var(--reactist-inputs-hover);
49+
}
50+
51+
&:focus-within {
52+
border-color: var(--reactist-inputs-focus);
53+
}
54+
}
55+
56+
/* Invalid: matches the ARIA convention only. */
57+
&:has([aria-invalid]:not([aria-invalid='false'])) {
58+
border-color: var(--reactist-inputs-alert) !important;
59+
}
60+
}
61+
62+
.borderRadiusSmall {
63+
border-radius: var(--reactist-border-radius-small);
64+
}
65+
66+
.borderRadiusLarge {
67+
border-radius: var(--reactist-border-radius-large);
68+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as React from 'react'
2+
3+
import { render, screen } from '@testing-library/react'
4+
import userEvent from '@testing-library/user-event'
5+
import { axe } from 'jest-axe'
6+
7+
import { FieldChromeContainer } from './field-chrome-container'
8+
9+
describe('FieldChromeContainer', () => {
10+
it('renders children', () => {
11+
render(
12+
<FieldChromeContainer>
13+
<input data-testid="control" aria-label="control" />
14+
</FieldChromeContainer>,
15+
)
16+
expect(screen.getByTestId('control')).toBeInTheDocument()
17+
})
18+
19+
it('forwards ref to the wrapper element', () => {
20+
const ref = React.createRef<HTMLDivElement>()
21+
const { container } = render(
22+
<FieldChromeContainer ref={ref}>
23+
<input aria-label="control" />
24+
</FieldChromeContainer>,
25+
)
26+
expect(ref.current).toBe(container.firstElementChild)
27+
})
28+
29+
it('merges exceptionallySetClassName onto the wrapper', () => {
30+
const { container } = render(
31+
<FieldChromeContainer exceptionallySetClassName="custom">
32+
<input aria-label="control" />
33+
</FieldChromeContainer>,
34+
)
35+
expect(container.firstElementChild).toHaveClass('custom')
36+
})
37+
38+
describe('click-to-focus dispatch', () => {
39+
it('focuses the inner control when the wrapper is clicked', () => {
40+
const { container } = render(
41+
<FieldChromeContainer>
42+
<input data-testid="control" aria-label="control" />
43+
</FieldChromeContainer>,
44+
)
45+
const control = screen.getByTestId('control')
46+
expect(control).not.toHaveFocus()
47+
48+
userEvent.click(container.firstElementChild as Element)
49+
expect(control).toHaveFocus()
50+
})
51+
52+
it('does not double-fire when the inner control is clicked directly', () => {
53+
const onControlClick = jest.fn()
54+
render(
55+
<FieldChromeContainer>
56+
<button type="button" data-testid="control" onClick={onControlClick}>
57+
click
58+
</button>
59+
</FieldChromeContainer>,
60+
)
61+
userEvent.click(screen.getByTestId('control'))
62+
expect(onControlClick).toHaveBeenCalledTimes(1)
63+
})
64+
65+
it('calls a consumer onClick passed via Box props when the wrapper is clicked', () => {
66+
const onClick = jest.fn()
67+
const { container } = render(
68+
<FieldChromeContainer onClick={onClick}>
69+
<input aria-label="control" />
70+
</FieldChromeContainer>,
71+
)
72+
userEvent.click(container.firstElementChild as Element)
73+
expect(onClick).toHaveBeenCalledTimes(1)
74+
})
75+
76+
it('uses showPicker for native <select>', () => {
77+
const showPicker = jest.fn()
78+
const { container } = render(
79+
<FieldChromeContainer>
80+
<select aria-label="fruit" data-testid="control">
81+
<option value="a">A</option>
82+
</select>
83+
</FieldChromeContainer>,
84+
)
85+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
86+
const select = screen.getByRole('combobox') as unknown as HTMLSelectElement
87+
// jsdom does not implement showPicker; stub it.
88+
Object.assign(select, { showPicker })
89+
90+
userEvent.click(container.firstElementChild as Element)
91+
expect(showPicker).toHaveBeenCalledTimes(1)
92+
expect(select).toHaveFocus()
93+
})
94+
})
95+
96+
describe('a11y', () => {
97+
it('renders with no a11y violations', async () => {
98+
const { container } = render(
99+
<>
100+
<label htmlFor="plain">Plain</label>
101+
<FieldChromeContainer>
102+
<input id="plain" />
103+
</FieldChromeContainer>
104+
</>,
105+
)
106+
expect(await axe(container)).toHaveNoViolations()
107+
})
108+
})
109+
})
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import * as React from 'react'
2+
import { type ComponentProps, forwardRef } from 'react'
3+
4+
import { Box } from '../box'
5+
6+
import styles from './field-chrome-container.module.css'
7+
8+
import type { ObfuscatedClassName } from '../utils/common-types'
9+
10+
export type FieldChromeContainerProps = {
11+
/**
12+
* The control element to wrap. The wrapper chrome (border, hover/focus,
13+
* disabled/read-only background tint, error border) derives from
14+
* attributes on this descendant.
15+
*
16+
* Clicking the wrapper focuses the first focusable descendant and
17+
* dispatches a `.click()` (or `.showPicker()` for native `<select>`).
18+
*/
19+
children: React.ReactNode
20+
21+
/**
22+
* The wrapper's border-radius.
23+
* - `small` (default) — used by `ControlPresentation`'s inline chrome.
24+
* - `large` — used by `BorderedTextField`'s outlined chrome.
25+
*/
26+
borderRadius?: 'small' | 'large'
27+
} & ObfuscatedClassName &
28+
Omit<ComponentProps<typeof Box>, 'className'>
29+
30+
/**
31+
* Private primitive shared by `ControlPresentation` and `BorderedTextField`.
32+
*
33+
* Provides the visual chrome (border with idle/hover/focus/error states,
34+
* background tints for read-only/disabled descendants) and the
35+
* click-to-focus dispatch behavior. Has no padding, sizing, or slot layout
36+
* of its own — consumers add those on top.
37+
*
38+
* Contract: assumes a single focusable control descendant. Behavior is
39+
* undefined if multiple focusable controls are inside.
40+
*/
41+
export const FieldChromeContainer = forwardRef<HTMLDivElement, FieldChromeContainerProps>(
42+
function FieldChromeContainer(
43+
{ borderRadius = 'small', exceptionallySetClassName, children, ...rest },
44+
ref,
45+
) {
46+
const controlWrapperRef = React.useRef<HTMLDivElement>(null)
47+
const isDispatchedReentryRef = React.useRef(false)
48+
49+
const { onClick, ...restWithoutClick } = rest as typeof rest & {
50+
onClick?: React.MouseEventHandler<HTMLDivElement>
51+
}
52+
53+
function handleWrapperClick(event: React.MouseEvent<HTMLDivElement>) {
54+
if (isDispatchedReentryRef.current) {
55+
isDispatchedReentryRef.current = false
56+
return
57+
}
58+
59+
onClick?.(event)
60+
61+
// Find the first focusable form control descendant. More robust
62+
// than firstElementChild — works whether the control is the
63+
// direct child (ControlPresentation) or nested inside a column
64+
// layout (BorderedTextField).
65+
const wrapper = controlWrapperRef.current
66+
const control = wrapper?.querySelector<HTMLElement>('input, select, textarea')
67+
if (!control) return
68+
69+
if (event.target instanceof Node && control.contains(event.target)) return
70+
71+
control.focus()
72+
if (control instanceof HTMLSelectElement && typeof control.showPicker === 'function') {
73+
try {
74+
control.showPicker()
75+
} catch {
76+
// showPicker can throw (e.g. focus state issues) — fall back
77+
// silently; the focus above is the minimum useful behavior.
78+
}
79+
return
80+
}
81+
82+
isDispatchedReentryRef.current = true
83+
control.click()
84+
}
85+
86+
return (
87+
<Box
88+
ref={ref}
89+
{...restWithoutClick}
90+
className={[
91+
styles.container,
92+
borderRadius === 'large' ? styles.borderRadiusLarge : styles.borderRadiusSmall,
93+
exceptionallySetClassName,
94+
]}
95+
onClick={handleWrapperClick}
96+
>
97+
<div ref={controlWrapperRef} style={{ display: 'contents' }}>
98+
{children}
99+
</div>
100+
</Box>
101+
)
102+
},
103+
)

0 commit comments

Comments
 (0)