Skip to content

Commit b6842d3

Browse files
committed
refactor: compose ControlPresentation on top of FieldChromeContainer
1 parent 957bcc0 commit b6842d3

3 files changed

Lines changed: 20 additions & 190 deletions

File tree

src/control-presentation/control-presentation.module.css

Lines changed: 1 addition & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -9,72 +9,8 @@
99
/* slot-to-control gap (only takes effect between rendered children) */
1010
gap: 6px;
1111

12-
/* default outer padding; shrunk on the side(s) where a slot is present (see below) */
12+
/* default outer padding; shrunk on the side(s) where a slot is present */
1313
padding-inline: 10px;
14-
15-
/* border */
16-
border: 1px solid var(--reactist-inputs-idle);
17-
18-
/* color */
19-
background: var(--reactist-field-background);
20-
color: var(--reactist-field-content);
21-
22-
/* Read-only: matches native :read-only on form controls (scoped to
23-
* <input> / <textarea> to avoid catching unrelated elements that
24-
* default to :read-only), the ARIA convention (aria-readonly="true"),
25-
* and the data-attr convention used by Ariakit/Radix-style components.
26-
* The :not(='false') exclusion treats aria-readonly="false" /
27-
* data-readonly="false" as "not read-only" per the ARIA spec. */
28-
&:has(
29-
input:read-only,
30-
textarea:read-only,
31-
[aria-readonly]:not([aria-readonly='false']),
32-
[data-readonly]:not([data-readonly='false'])
33-
) {
34-
background-color: var(--reactist-field-readonly-background);
35-
color: var(--reactist-field-readonly-content);
36-
}
37-
38-
/* Disabled: matches the native disabled attribute (form controls),
39-
* the soft-disable ARIA convention (used by Reactist's own Button to
40-
* keep elements focusable while announcing as disabled), and the
41-
* data-attr convention used by Ariakit/Radix. */
42-
&:has(
43-
:disabled,
44-
[aria-disabled]:not([aria-disabled='false']),
45-
[data-disabled]:not([data-disabled='false'])
46-
) {
47-
background-color: var(--reactist-field-disabled-background);
48-
color: var(--reactist-field-disabled-content);
49-
}
50-
51-
/* Interactive border (hover/focus) — suppressed when the control is
52-
* in any disabled state (same three conventions as the disabled
53-
* background-color rule above). */
54-
&:not(
55-
:has(
56-
:disabled,
57-
[aria-disabled]:not([aria-disabled='false']),
58-
[data-disabled]:not([data-disabled='false'])
59-
)
60-
) {
61-
&:hover {
62-
border-color: var(--reactist-inputs-hover);
63-
}
64-
65-
&:focus-within {
66-
border-color: var(--reactist-inputs-focus);
67-
}
68-
}
69-
70-
/* Invalid: matches the ARIA convention only. Native :invalid is
71-
* deliberately omitted — it fires before user interaction (e.g. on
72-
* any required-but-empty field at first paint), which is rarely the
73-
* desired UX. Consumers that want HTML5-validation-driven errors
74-
* forward :invalid → aria-invalid in their own component. */
75-
&:has([aria-invalid]:not([aria-invalid='false'])) {
76-
border-color: var(--reactist-inputs-alert) !important;
77-
}
7814
}
7915

8016
/* Conditional outer padding. When a slot is present on a side, shrink

src/control-presentation/control-presentation.test.tsx

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,6 @@ describe('ControlPresentation', () => {
5454
expect(control).toBeDisabled()
5555
})
5656

57-
it('focuses the control when the wrapper is clicked', () => {
58-
const { container } = render(
59-
<ControlPresentation>
60-
<input aria-label="Subject" data-testid="subject" />
61-
</ControlPresentation>,
62-
)
63-
const control = screen.getByTestId('subject')
64-
expect(control).not.toHaveFocus()
65-
66-
userEvent.click(container.firstElementChild as Element)
67-
expect(control).toHaveFocus()
68-
})
69-
7057
it('focuses the control when a non-interactive startSlot is clicked', () => {
7158
render(
7259
<ControlPresentation startSlot={<TestIcon />}>
@@ -80,27 +67,6 @@ describe('ControlPresentation', () => {
8067
expect(control).toHaveFocus()
8168
})
8269

83-
it('fires a slot button onClick and focuses the control', () => {
84-
const onSlotClick = jest.fn()
85-
render(
86-
<ControlPresentation
87-
endSlot={
88-
<button type="button" onClick={onSlotClick}>
89-
clear
90-
</button>
91-
}
92-
>
93-
<input aria-label="Subject" data-testid="subject" />
94-
</ControlPresentation>,
95-
)
96-
const control = screen.getByTestId('subject')
97-
expect(control).not.toHaveFocus()
98-
99-
userEvent.click(screen.getByRole('button', { name: 'clear' }))
100-
expect(onSlotClick).toHaveBeenCalledTimes(1)
101-
expect(control).toHaveFocus()
102-
})
103-
10470
it('merges exceptionallySetClassName onto the wrapper', () => {
10571
const { container } = render(
10672
<ControlPresentation exceptionallySetClassName="custom-class">
@@ -127,27 +93,6 @@ describe('ControlPresentation', () => {
12793
expect(screen.getByTestId('b')).toBeInTheDocument()
12894
})
12995

130-
it('calls a consumer onClick passed via Box props when the wrapper is clicked', () => {
131-
const onClick = jest.fn()
132-
const { container } = render(
133-
<ControlPresentation onClick={onClick}>
134-
<input aria-label="Subject" />
135-
</ControlPresentation>,
136-
)
137-
userEvent.click(container.firstElementChild as Element)
138-
expect(onClick).toHaveBeenCalledTimes(1)
139-
})
140-
141-
it('forwards ref to the wrapper element', () => {
142-
const ref = React.createRef<HTMLDivElement>()
143-
const { container } = render(
144-
<ControlPresentation ref={ref}>
145-
<input aria-label="Subject" />
146-
</ControlPresentation>,
147-
)
148-
expect(ref.current).toBe(container.firstElementChild)
149-
})
150-
15196
describe('a11y', () => {
15297
it('renders with no a11y violations', async () => {
15398
const { container } = render(

src/control-presentation/control-presentation.tsx

Lines changed: 19 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import * as React from 'react'
22
import { type ComponentProps, forwardRef } from 'react'
33

4+
import classNames from 'classnames'
5+
46
import { Box } from '../box'
57

8+
import { FieldChromeContainer } from './field-chrome-container'
9+
610
import styles from './control-presentation.module.css'
711

812
import type { ObfuscatedClassName } from '../utils/common-types'
@@ -27,101 +31,46 @@ export type ControlPresentationProps = {
2731
* The control element (an `<input>`, an Ariakit Select trigger,
2832
* `<select>`, etc.). Attributes belonging to the control — `type`,
2933
* `value`, `onChange`, `readOnly`, `disabled`, `aria-invalid`, and so on —
30-
* are set on this element directly. The wrapper chrome (read-only
31-
* background, disabled state, error border) derives from those attributes.
34+
* are set on this element directly. The wrapper chrome derives from those
35+
* attributes.
3236
*
3337
* Click handlers belong on the control itself, not on this wrapper.
38+
* Clicking the wrapper focuses the control.
3439
*/
3540
children: React.ReactNode
3641
} & ObfuscatedClassName &
37-
Omit<ComponentProps<typeof Box>, 'className'>
42+
Omit<ComponentProps<typeof FieldChromeContainer>, 'borderRadius'>
3843

3944
/**
40-
* A presentational primitive that renders the visual chrome of a
41-
* text-field-style input (border, hover / focus / disabled / read-only / error
42-
* states, plus an optional row of slots) around an arbitrary control element.
45+
* The visual chrome of an inline, single-row, text-field-style input: a
46+
* 32px-tall row with optional start/end slots around a control element.
4347
*
44-
* Slot order (left to right):
45-
* `startSlot` → control (children) → `endSlot`.
48+
* Slot order (left to right): `startSlot` → control (children) → `endSlot`.
4649
*
47-
* Clicking anywhere on the wrapper focuses the control.
50+
* Click handlers belong on the control itself, not on this wrapper.
51+
* Clicking the wrapper focuses the control.
4852
*/
4953
export const ControlPresentation = forwardRef<HTMLDivElement, ControlPresentationProps>(
5054
function ControlPresentation(
5155
{ startSlot, endSlot, exceptionallySetClassName, children, ...rest },
5256
ref,
5357
) {
54-
const controlWrapperRef = React.useRef<HTMLDivElement>(null)
55-
// The synthetic .click() we dispatch below bubbles back up to this
56-
// handler. Track that we're inside that re-entry so we can skip
57-
// re-invoking the consumer onClick + activation for it.
58-
const isDispatchedReentryRef = React.useRef(false)
59-
60-
// `onClick` isn't part of `ControlPresentationProps` — consumers
61-
// should put click handlers on the inner control. This extraction
62-
// exists only to compose with onClick that arrives via render-prop
63-
// wrapping (e.g. Ariakit's Select trigger uses `render={<ControlPresentation/>}`
64-
// and forwards its own onClick), where the wrapper IS the trigger.
65-
const { onClick, ...restWithoutClick } = rest as typeof rest & {
66-
onClick?: React.MouseEventHandler<HTMLDivElement>
67-
}
68-
69-
function handleWrapperClick(event: React.MouseEvent<HTMLDivElement>) {
70-
if (isDispatchedReentryRef.current) {
71-
isDispatchedReentryRef.current = false
72-
return
73-
}
74-
75-
onClick?.(event)
76-
77-
const control = controlWrapperRef.current?.firstElementChild as HTMLElement | null
78-
if (!control) return
79-
80-
// Don't re-fire when the user clicked directly on the control —
81-
// it handled itself, and re-dispatching would double-activate.
82-
if (event.target instanceof Node && control.contains(event.target)) return
83-
84-
// .focus() handles inputs (cursor/keyboard focus). The activation
85-
// hop differs by element type:
86-
// - Native <select>: synthetic .click() is blocked by browsers
87-
// for the native dropdown. .showPicker() is the correct API.
88-
// - Everything else (inputs, native <button>, Ariakit Select
89-
// triggers, anchors): .click() either activates the click
90-
// handler or is harmlessly redundant.
91-
control.focus()
92-
if (control instanceof HTMLSelectElement && typeof control.showPicker === 'function') {
93-
try {
94-
control.showPicker()
95-
} catch {
96-
// showPicker can throw (e.g. focus state issues) — fall back
97-
// silently; the focus above is the minimum useful behavior.
98-
}
99-
return
100-
}
101-
102-
isDispatchedReentryRef.current = true
103-
control.click()
104-
}
105-
10658
return (
107-
<Box
59+
<FieldChromeContainer
10860
ref={ref}
109-
{...restWithoutClick}
110-
className={[styles.container, exceptionallySetClassName]}
61+
{...rest}
62+
borderRadius="small"
63+
exceptionallySetClassName={classNames(styles.container, exceptionallySetClassName)}
11164
display="flex"
11265
alignItems="center"
11366
overflow="hidden"
114-
borderRadius="standard"
115-
onClick={handleWrapperClick}
11667
>
11768
{startSlot ? (
11869
<Slot className={[styles.slot, styles.startSlot]}>{startSlot}</Slot>
11970
) : null}
120-
<div ref={controlWrapperRef} className={styles.control}>
121-
{children}
122-
</div>
71+
<div className={styles.control}>{children}</div>
12372
{endSlot ? <Slot className={[styles.slot, styles.endSlot]}>{endSlot}</Slot> : null}
124-
</Box>
73+
</FieldChromeContainer>
12574
)
12675
},
12776
)

0 commit comments

Comments
 (0)