feat: add ControlPresentation primitive#1037
Conversation
|
@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.
2682265 to
7687126
Compare
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>
doistbot
left a comment
There was a problem hiding this comment.
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
ControlActionButtontypes to fully support the underlying button APIs, and update slot rendering checks to preserve valid numeric content like0. - 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
ControlActionButtonbranches, 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 replacinggetByTestId('subject')withscreen.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:
ControlActionButtonis 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 thechildrenandiconbranches here (or refactor this union into documented interfaces).
| // direct child (ControlPresentation) or nested inside a column | ||
| // layout (BorderedTextField). | ||
| const wrapper = controlWrapperRef.current | ||
| const control = wrapper?.querySelector<HTMLElement>('input, select, textarea') |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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 ? ( |
|
|
||
| export type ControlActionButtonProps = | ||
| | ({ | ||
| children: React.ReactElement |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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>( |
| }) | ||
|
|
||
| describe('click-to-focus dispatch', () => { | ||
| it('focuses the inner control when the wrapper is clicked', async () => { |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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.
7687126 to
14f7734
Compare
Short description
Adds
ControlPresentation, a presentational layout primitive for input-like form controls.It:
aria-readonly)children, forwards clicks to itstartSlot,endSlot) flanking the controlControlActionButton, a compact 24×24 button variant sized to fit inside the chrome alongside a 16px iconIt is Intended to be responsible for the presentation of the control in
TextField, the forthcomingSelectField,PasswordField,ComboboxField, and similar — anything that wants the visual chrome of a text field around an arbitrary control.Out of scope
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