feat: add ControlPresentation primitive#1037
Conversation
A presentational layout shell for input-like controls. Provides border chrome, focus/hover/disabled/readonly/invalid styling, and two slots (startSlot, endSlot) flanking an arbitrary focusable control passed as children. Forwards a ref to the wrapper and forwards click events to the inner control (toggleable via forwardClickToControl). State-driven styling fires from :has() selectors that match three signaling conventions — native HTML, ARIA, and data-attr — on the inner control. The single source of truth is the a11y attributes the control needs anyway. :invalid is deliberately omitted (fires pre-interaction). Spacing is explicit conditional padding: 10px outer on each side by default; left shrinks to 6px when startSlot is present (with 6px gap to control); right shrinks to 4px when endSlot is present (with 6px gap). ControlActionButton ships alongside as a compact 24x24 button variant sized to fit the chrome alongside a 16px icon glyph. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cover the public API surface (slot rendering, click-forwarding, ref forwarding, attribute passthrough, exceptionallySetClassName) plus each state-styling signaling convention (native HTML, ARIA, data-attr) and the slot-marker classes that drive the conditional outer padding. jest-axe smoke test for accessibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace margin-inline-{end,start} on .startSlot / .endSlot with a single
gap: 6px declaration on the .container flex wrapper. The rendered
geometry is identical (flex gap only applies between rendered children,
so absent slots still produce no gap), but the spacing rule lives in one
place and can't drift between the two sides.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the wrapper background is clicked, handleWrapperClick synthesizes control.click() to activate the inner control. That synthetic click bubbles back up to the wrapper and re-enters the same handler, which called onClick a second time. Track the dispatched re-entry via a ref and short-circuit it so consumer onClick fires exactly once per user gesture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
30 → 12 tests. The pruned tests were either (a) asserting React's own attribute-passthrough behavior (the entire state-styling describe block — JSDOM doesn't evaluate the :has() CSS that gives those attributes visual meaning), (b) locking in implementation details (role attributes, CSS-module class names on slot wrappers), or (c) duplicating other tests' coverage. Visual concerns (conditional padding, :has()-driven state styling) stay verified by Storybook/Chromatic — Jest is the wrong tool for CSS behavior. Added three small prop-guardrail tests so every documented prop has at least one direct test: forwardClickToControl, onClick, ref forwarding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion props Click handlers belong on the inner control, not on the wrapper. The wrapper's click-to-focus behavior is internal — there's no real need for consumers to disable it (forwardClickToControl) or hook into it (onClick) for the wrap pattern. The implementation still extracts onClick out of rest (via a type cast) to compose with the focus-forwarding handler — this path exists only for render-prop use, where Ariakit (or similar) forwards its own onClick to the wrapper-as-trigger. Consumers of ControlPresentation directly won't see onClick in autocomplete; TypeScript rejects passing it at call sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@doistbot /review |
doistbot
left a comment
There was a problem hiding this comment.
This PR introduces the ControlPresentation and ControlActionButton primitives, establishing a unified, state-responsive visual wrapper for input-like form controls. Centralizing the border chrome, conditional spacing, and native state reflection is a great step toward more composable and consistent form fields across the library. A few refinements have been noted around click-forwarding logic and event bubbling, tightening up TypeScript types and CSS scoping, expanding test coverage, and addressing styling duplication with existing field components.
| // Don't re-fire when the user clicked directly on the control — | ||
| // it handled itself, and re-dispatching would double-activate. | ||
| if (event.target instanceof Node && control.contains(event.target)) return | ||
|
|
There was a problem hiding this comment.
[P1] Clicks on interactive elements in slots (like a clear button) currently bubble up and synthetically focus/click the control. While a test expects focus to move to the control, this behavior also triggers control.click()/showPicker(). For example, clicking "Clear" on a composed <SelectField> would unintentionally pop open the dropdown.
Taking focus away from a clicked button also disrupts keyboard accessibility. Consider ignoring clicks that originate from interactive elements outside the control:
if (event.target instanceof Node && control.contains(event.target)) return
// Ignore clicks on slot buttons/links so they can behave natively
if (event.target instanceof Element && event.target.closest('button, a, input, select, textarea, [role="button"]')) return| return | ||
| } | ||
|
|
||
| isDispatchedReentryRef.current = true |
There was a problem hiding this comment.
[P2] If control.click() is caught by a child element's handler that calls e.stopPropagation(), the event won't bubble back up to handleWrapperClick. isDispatchedReentryRef will remain true, causing the next wrapper click to be incorrectly ignored.
Since .click() dispatches synchronously, you can prevent this state leak using a try/finally block:
isDispatchedReentryRef.current = true
try {
control.click()
} finally {
isDispatchedReentryRef.current = false
}(You can then safely remove isDispatchedReentryRef.current = false from the early return at line 71).
| * the soft-disable ARIA convention (used by Reactist's own Button to | ||
| * keep elements focusable while announcing as disabled), and the | ||
| * data-attr convention used by Ariakit/Radix. */ | ||
| &:has( |
There was a problem hiding this comment.
[P2] These :has() state selectors are matching any descendant of the container, not just the wrapped control. That means slot content can flip the field chrome — e.g. a disabled ControlActionButton in endSlot will match [aria-disabled] here and make an otherwise editable input look disabled. Scope the state selectors to the control subtree so slots don't drive the wrapper state.
| // - Everything else (inputs, native <button>, Ariakit Select | ||
| // triggers, anchors): .click() either activates the click | ||
| // handler or is harmlessly redundant. | ||
| control.focus() |
There was a problem hiding this comment.
[P2] The wrapper forwards activation unconditionally, even when the inner control advertises disabled state via aria-disabled/data-disabled (the same conventions the CSS treats as disabled). For custom triggers that don't prevent synthetic clicks themselves, clicking the chrome will still activate a disabled-looking control. Guard the forwarded click() with the same disabled-state checks before dispatching it.
| borderRadius="standard" | ||
| onClick={handleWrapperClick} | ||
| > | ||
| {startSlot ? ( |
There was a problem hiding this comment.
[P2] startSlot and endSlot are typed to accept numbers, but these truthiness checks drop valid 0 content. startSlot={0} / endSlot={0} currently render nothing. Use an explicit nullish check instead of a truthy check so numeric slot content is preserved.
| */ | ||
| children: React.ReactNode | ||
| } & ObfuscatedClassName & | ||
| Omit<ComponentProps<typeof Box>, 'className'> |
There was a problem hiding this comment.
[P2] Extending full ComponentProps<typeof Box> leaks Box's polymorphic as prop into this API, but the component is hard-coded around a div wrapper and forwards an HTMLDivElement ref. as="label" / as="button" will type-check today while changing semantics and making the ref type wrong. Omit as (and ideally narrow the forwarded Box props to the few supported layout/DOM props) to keep the abstraction stable.
| } | ||
|
|
||
| isDispatchedReentryRef.current = true | ||
| control.click() |
There was a problem hiding this comment.
[P2] This unconditionally dispatches a second click event for every wrapper-background click on any non-<select> control. For the main text-entry cases this component is meant to wrap (TextField, PasswordField, etc.), focus() already provides the needed behavior, so the extra .click() just adds another full event/bubble pass through React and any control-level onClick handlers. Please gate the synthetic click to controls that actually need activation (e.g. button-like/custom triggers or picker-like inputs) and skip it for plain text inputs/textarea.
| <input aria-label="Subject" data-testid="subject" /> | ||
| </ControlPresentation>, | ||
| ) | ||
| const control = screen.getByTestId('subject') |
There was a problem hiding this comment.
[P3] Project conventions specify querying elements by role. Since the nested controls have an aria-label, consider replacing getByTestId with screen.getByRole('textbox', { name: 'Subject' }) (and the equivalent getByRole for buttons) here and throughout the file.
|
|
||
| userEvent.click(container.firstElementChild as Element) | ||
| expect(control).toHaveFocus() | ||
| }) |
There was a problem hiding this comment.
[P2] This test verifies that the inner control receives focus, but it leaves the synthetic .click() and the native <select> .showPicker() fallbacks untested. Consider adding tests to ensure these core activation mechanics trigger correctly when the wrapper is clicked.
| * Reactist's `Button` / `IconButton` with a 24×24, 3px-radius variant sized to fit | ||
| * the field chrome alongside a 16px icon glyph. | ||
| */ | ||
| export const ControlActionButton = React.forwardRef<HTMLButtonElement, ControlActionButtonProps>( |
There was a problem hiding this comment.
[P2] This new exported primitive ships without any dedicated test coverage. Please add tests for both branches of the union (children -> Button, icon -> IconButton) so regressions in the prop discrimination and the injected compact styling don't slip through unnoticed.
Short description
Adds
ControlPresentation, a presentational layout primitive for input-like form controls. It draws the border chrome (idle / hover / focus / disabled / readonly / invalid), wraps an arbitrary focusable control passed aschildren, and exposes two slots (startSlot,endSlot) flanking it. Intended for composition byTextField, the forthcomingSelectField,PasswordField,ComboboxField, and similar — anything that wants the visual chrome of a text field around an arbitrary control.Architecture
ControlPresentationis built on a privateOutlinedControlContainerprimitive (not exported). The split:OutlinedControlContainer(private) — owns the chrome itself: border + idle/hover/focus state colors,:has()-driven readonly/disabled tinting, error border via descendantaria-invalid, and the click-to-focus dispatch behavior. ConfigurableborderRadius(small|large). Narrow API:children,borderRadius,onClick,exceptionallySetClassName, forwarded ref.ControlPresentation(public) — composesOutlinedControlContainerand adds the inline single-row layout on top: fixed 32px height, slot-awarepadding-inline,gap: 6px, start/control/end flex arrangement.The reason for the split: a forthcoming
BorderedTextField(the outlined label-inside-chrome layout — different visual shape, different slot semantics) will also composeOutlinedControlContainer. Keeping chrome and slot-layout separate lets both consumers reuse the chrome without duplicating border/state CSS or click-dispatch JS.Key design choices
:has()selectors recognize each state via three signaling conventions (native HTML, ARIA,data-*) on the inner control, so the wrapper visual stays in sync with the a11y attributes screen readers see. Single source of truth, no React-prop forwarding required. The:not([…='false'])guards correctly treataria-invalid="false"etc. as "not invalid". Native:invalidis deliberately omitted (fires pre-interaction).startSlotis present and to 4px on the right when anendSlotis present. A 6px flexgapon the wrapper provides the slot-to-control spacing — it only takes effect between rendered children, so absent slots produce no gap. The:has(.startSlot)/:has(.endSlot)rules drive the conditional padding.focus()es the inner control and synthesizes aclick()for button-like activation. Native<select>usesshowPicker()instead, since browsers block synthetic clicks from opening the OS-level dropdown. This is internal behavior — consumers don't opt in. The dispatch finds the first focusable input/select/textarea viaquerySelector, so the control can be nested under arbitrary layout markup.onClickis explicit, not Box-prop pass-through.ControlPresentationexposes an explicitonClickfor composition with library wrappers (e.g. Ariakit'srender={<ControlPresentation/>}pattern, which forwards an upstream onClick to the trigger). Component-level click handling normally lives on the inner control. No other Box props pass through.ControlActionButtonships alongside as a compact 24×24 button variant sized to fit inside the chrome alongside a 16px icon glyph.Public API
Consumers handle their own status indicators (loading spinner, error icon, character count) by composing into
endSlot—ControlPresentationdeliberately doesn't own that machinery.What's not here
Button/IconButtonxsmallsize to replaceControlActionButton's dimensions (separate Button-taxonomy spec, out of scope).TextField/SelectField/TextAreamigration onto this primitive (follow-up PR).BorderedTextField(the outlined-with-label-inside sibling that will also composeOutlinedControlContainer) — follow-up PR.PR Checklist