Skip to content

feat: add ControlPresentation primitive#1037

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

feat: add ControlPresentation primitive#1037
pawelgrimm wants to merge 18 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 appropriate border based on state (idle / hover / focus / disabled / readonly / invalid) using html/aria props when appropriate (e.g. aria-readonly)
  • wraps an arbitrary focusable control passed as children, forwards clicks to it
  • exposes two slots (startSlot, endSlot) flanking the control
    • public API also includes ControlActionButton, a compact 24×24 button variant sized to fit inside the chrome alongside a 16px icon
  • Spacing matches the design specs
    It is Intended to be responsible for the presentation of the control in TextField, the forthcoming SelectField, PasswordField, ComboboxField, and similar — anything that wants the visual chrome of a text field around an arbitrary control.

Out of scope

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

@pawelgrimm pawelgrimm force-pushed the pawel/input-presentation/extract-from-text-field branch from 2682265 to 7687126 Compare May 28, 2026 21:29
pawelgrimm and others added 16 commits May 28, 2026 16:47
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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace `Omit<ComponentProps<typeof Box>, 'className'>` on FCC with
explicit `onClick` + `ObfuscatedClassName`. Move `display:flex`,
`align-items:center`, `overflow:hidden` into FCC's CSS. CP keeps its
loose public type (Box props) but only forwards `onClick` to FCC;
other Box props (maxWidth, etc.) are silently dropped pending a
follow-up CP API tightening.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ghten ControlPresentation type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ot bubble

The guard was cleared only on the next handler invocation. If a consumer's
onClick stops propagation on the inner control, control.click() never bubbles
back to the wrapper, the guard stays true, and the next real wrapper click is
silently swallowed.

Always clear immediately after control.click() returns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per project convention (no CSS nesting; followed by all other component
module.css files). Preserve the tripled .controlActionButton specificity hack
with a comment explaining why it overrides Button's .baseButton.size-* rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Less brittle: refers to the CSS module's resolved className directly rather
than coupling to internal naming. jest-dom's toHaveClass also gives better
failure messages than .className.toMatch().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@pawelgrimm pawelgrimm marked this pull request as ready for review May 28, 2026 21:47
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.

Thanks pawelgrimm for your contribution 😇. This introduces a solid foundation for unifying form control presentation and standardizing input layouts.

Few things worth tightening:

  • Refine the wrapper's DOM queries and event routing so interactive slot elements don't steal focus, trigger unintended synthetic clicks, or accidentally apply disabled styling to the entire container via broad :has() selectors.
  • Loosen ControlActionButton types to fully support the underlying button APIs, and update slot rendering checks to preserve valid numeric content like 0.
  • Gate the wrapper's synthetic .click() dispatch to only activate picker-style controls, avoiding redundant event bubbling for plain text entries.
  • Expand test coverage for ControlActionButton branches, synthetic click forwarding, and edge cases like interactive or numeric slot content.

I also included a few optional follow-up notes in the details below.

Optional follow-up notes (2)
  • [P3] src/control-presentation/control-presentation.test.tsx:18: Project conventions specify querying elements by role. Since the inner control has an aria-label, consider replacing getByTestId('subject') with screen.getByRole('textbox', { name: 'Subject' }) here and throughout the file to align with established accessibility testing patterns.
  • [P3] src/control-presentation/control-action-button.tsx:11: ControlActionButton is a new public export, but its branch-specific props are undocumented. The repo’s component conventions call for JSDoc on each public prop so consumers get proper hover/docs coverage. Please add prop comments for the children and icon branches here (or refactor this union into documented interfaces).

Share FeedbackReview Logs

// direct child (ControlPresentation) or nested inside a column
// layout (BorderedTextField).
const wrapper = controlWrapperRef.current
const control = wrapper?.querySelector<HTMLElement>('input, select, textarea')
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 This query searches the entire wrapper subtree, including slots. If startSlot contains a focusable element (like a country-code <select>), this will incorrectly target the slot element instead of the primary control. Furthermore, restricting the query to 'input, select, textarea' prevents click forwarding from working on custom controls (like a <button role="combobox">). Consider having consumers pass a controlRef, or querying a specific marker (e.g., [data-primary-control]) so the main control is identified unambiguously and supports arbitrary focusable elements.

const control = wrapper?.querySelector<HTMLElement>('input, select, textarea')
if (!control) return

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 the slots (like a "Clear" button) will bubble to the wrapper, bypassing this check because they are outside the targeted control. This causes the wrapper to synthetically focus and click the primary control, stealing focus from the slot button and potentially triggering unwanted actions. Ignore clicks that originate from interactive slot elements (e.g., if (event.target instanceof Element && event.target.closest('button, a, [role="button"]')) return).

* 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. */
.container: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.

P1 These :has() state selectors match any descendant of the container. Because OutlinedControlContainer wraps both the slots and the main control, a disabled element in a slot (e.g., a disabled "Clear" button in endSlot) will incorrectly trigger this rule and apply disabled styling to the entire field wrapper. Consider scoping these selectors to target the main control specifically (e.g., using a specific wrapper class), or passing state props directly.

onClick={onClick}
exceptionallySetClassName={classNames(styles.container, exceptionallySetClassName)}
>
{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 via SlotContent, but these truthiness checks drop valid 0 content, rendering nothing instead of the number 0. Use an explicit nullish check (e.g., startSlot != null) so numeric slot content is preserved.


export type ControlActionButtonProps =
| ({
children: React.ReactElement
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 wrapper narrows the underlying button APIs more than intended. Button accepts any ReactNode label and IconButton accepts ReactElement | string for icon, but this union requires ReactElement in both branches. Callers like <ControlActionButton>Clear</ControlActionButton> or icon="×" now fail type-check even though the wrapped components support them. Reuse the underlying children/icon prop types instead of redefining them here.

// back to this handler. Always clear after the dispatch returns so the
// guard cannot latch if a consumer's onClick stops propagation.
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 adds a second click dispatch for every wrapper click on non-<select> controls. For the main text-entry cases this primitive is meant to wrap (input[type=text|email|password|search|number|... ], textarea), focus() already gives the needed behavior, so the extra .click() just runs another full event/bubble cycle through React and any control onClick handlers. Please gate the synthetic click to controls that actually need activation (for example picker-like inputs) and skip it for plain text-entry controls.

* 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 ControlActionButton remains exported without any dedicated test coverage. Please add tests to verify both branches of its union API (rendering Button when passed children, and IconButton when passed icon) so the prop discrimination works correctly.

})

describe('click-to-focus dispatch', () => {
it('focuses the inner control when the wrapper is clicked', async () => {
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 confirms that the wrapper forwards focus, but the suite still lacks a test for the synthetic .click() forwarding. (For instance, the stop-propagation test on line 115 would still pass if control.click() were deleted from the implementation). Please add a test that asserts an onClick handler on the inner control is called when the wrapper is clicked.

expect(control).toBeDisabled()
})

it('focuses the control when a non-interactive startSlot is clicked', async () => {
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 This only covers a decorative slot. The main new surface here is interactive slot content (ControlActionButton, clear buttons, chevrons), and a regression there will forward the wrapper activation into the inner control — for example, clicking a slotted button on a wrapped <select> can open the picker. Please add a behavior test with a slotted button asserting the button handles the click and the wrapped control does not get activated.

expect(container.firstElementChild).toHaveClass('custom-class')
})

it('endSlot accepts multi-child composition', () => {
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 slot rendering tests never exercise the number branch of SlotContent. 0 is the edge case most likely to break here, and this API explicitly supports numeric slot content for things like counters/units. Add a case for startSlot={0} or endSlot={0} so valid numeric content can't be silently dropped.

@pawelgrimm pawelgrimm force-pushed the pawel/input-presentation/extract-from-text-field branch from 7687126 to 14f7734 Compare May 29, 2026 02:01
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