Skip to content

feat: add ControlPresentation primitive#1037

Draft
pawelgrimm wants to merge 7 commits into
mainfrom
pawel/input-presentation/extract-from-text-field
Draft

feat: add ControlPresentation primitive#1037
pawelgrimm wants to merge 7 commits into
mainfrom
pawel/input-presentation/extract-from-text-field

Conversation

@pawelgrimm
Copy link
Copy Markdown
Contributor

@pawelgrimm pawelgrimm commented May 15, 2026

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 as children, and exposes two slots (startSlot, endSlot) flanking it. Intended for composition by TextField, the forthcoming SelectField, PasswordField, ComboboxField, and similar — anything that wants the visual chrome of a text field around an arbitrary control.

Architecture

ControlPresentation is built on a private OutlinedControlContainer primitive (not exported). The split:

  • OutlinedControlContainer (private) — owns the chrome itself: border + idle/hover/focus state colors, :has()-driven readonly/disabled tinting, error border via descendant aria-invalid, and the click-to-focus dispatch behavior. Configurable borderRadius (small | large). Narrow API: children, borderRadius, onClick, exceptionallySetClassName, forwarded ref.
  • ControlPresentation (public) — composes OutlinedControlContainer and adds the inline single-row layout on top: fixed 32px height, slot-aware padding-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 compose OutlinedControlContainer. Keeping chrome and slot-layout separate lets both consumers reuse the chrome without duplicating border/state CSS or click-dispatch JS.

Key design choices

  • HTML/ARIA-driven state styling. :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 treat aria-invalid="false" etc. as "not invalid". Native :invalid is deliberately omitted (fires pre-interaction).
  • Explicit conditional spacing. 10px outer padding by default; shrinks to 6px on the left when a startSlot is present and to 4px on the right when an endSlot is present. A 6px flex gap on 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.
  • Wrapper click forwards to the control. Clicking the wrapper background focus()es the inner control and synthesizes a click() for button-like activation. Native <select> uses showPicker() 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 via querySelector, so the control can be nested under arbitrary layout markup.
  • onClick is explicit, not Box-prop pass-through. ControlPresentation exposes an explicit onClick for composition with library wrappers (e.g. Ariakit's render={<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.
  • ControlActionButton ships alongside as a compact 24×24 button variant sized to fit inside the chrome alongside a 16px icon glyph.

Public API

type ControlPresentationProps = {
  startSlot?: ReactNode
  endSlot?: ReactNode
  children: ReactNode
  onClick?: MouseEventHandler<HTMLDivElement>
} & ObfuscatedClassName

Consumers handle their own status indicators (loading spinner, error icon, character count) by composing into endSlotControlPresentation deliberately doesn't own that machinery.

What's not here

  • A new Button / IconButton xsmall size to replace ControlActionButton's dimensions (separate Button-taxonomy spec, out of scope).
  • TextField / SelectField / TextArea migration onto this primitive (follow-up PR).
  • BorderedTextField (the outlined-with-label-inside sibling that will also compose OutlinedControlContainer) — follow-up PR.

PR Checklist

  • Added tests for bugs / new features
  • Updated docs (storybooks, readme)
  • Reviewed and approved Chromatic visual regression tests in CI

pawelgrimm and others added 7 commits May 15, 2026 09:59
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>
@pawelgrimm
Copy link
Copy Markdown
Contributor Author

@doistbot /review

Copy link
Copy Markdown
Member

@doistbot doistbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Share FeedbackReview Logs

// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 ? (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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'>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()
})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants