Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7a7c2b4
feat: add ControlPresentation primitive
pawelgrimm May 15, 2026
00bfaef
test: add coverage for ControlPresentation
pawelgrimm May 15, 2026
8d65534
fix: Support focusing <select>
pawelgrimm May 15, 2026
413e0a7
refactor: use flex gap instead of slot margins for slot spacing
pawelgrimm May 15, 2026
4b77d56
fix: prevent onClick from firing twice on wrapper background click
pawelgrimm May 15, 2026
f1fa78f
refactor: prune ControlPresentation test suite to behavioral coverage
pawelgrimm May 15, 2026
d1be6d2
refactor: drop onClick and forwardClickToControl from ControlPresenta…
pawelgrimm May 15, 2026
fa7ed1a
feat: add FieldChromeContainer private primitive
pawelgrimm May 15, 2026
adcb1e2
test: add FieldChromeContainer border-radius and class assertions
pawelgrimm May 15, 2026
4a7281c
refactor: compose ControlPresentation on top of FieldChromeContainer
pawelgrimm May 15, 2026
3ebe67a
refactor: narrow FieldChromeContainer's public surface
pawelgrimm May 15, 2026
0c96939
refactor: rename FieldChromeContainer to OutlinedControlContainer; ti…
pawelgrimm May 15, 2026
de7ca21
chore: update ControlPresentation stories for Storybook 10
pawelgrimm May 28, 2026
0e3714e
fix: prevent reentry guard from latching when dispatched click does n…
pawelgrimm May 28, 2026
915a823
refactor: flatten CSS in control-presentation modules
pawelgrimm May 28, 2026
3020859
test: use styles.X for class assertions instead of regex matches
pawelgrimm May 28, 2026
cfad4ac
fix: widen control action button props
pawelgrimm May 28, 2026
14f7734
fix: harden control presentation activation
pawelgrimm May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/control-presentation/control-action-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react'

import { render, screen } from '@testing-library/react'

import { ControlActionButton } from './control-action-button'

import styles from './control-presentation.module.css'

describe('ControlActionButton', () => {
it('renders the text-label Button branch with compact field styling', () => {
render(<ControlActionButton>Clear</ControlActionButton>)

const button = screen.getByRole('button', { name: 'Clear' })
expect(button).toHaveClass(styles.controlActionButton!)
})

it('renders the icon-only IconButton branch with compact field styling', () => {
render(<ControlActionButton icon="x" aria-label="Clear" />)

const button = screen.getByRole('button', { name: 'Clear' })
expect(button).toHaveClass(styles.controlActionButton!)
})
})
43 changes: 43 additions & 0 deletions src/control-presentation/control-action-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as React from 'react'

import classNames from 'classnames'

import { Button, IconButton } from '../button'

import styles from './control-presentation.module.css'

import type { ButtonProps, IconButtonProps } from '../button'

export type ControlActionButtonProps =
| ({
children: NonNullable<ButtonProps['children']>
icon?: never
} & Omit<ButtonProps, 'children' | 'variant' | 'size'>)
| ({
icon: IconButtonProps['icon']
children?: never
} & Omit<IconButtonProps, 'children' | 'icon' | 'variant' | 'size'>)

/**
* A compact action button intended for `ControlPresentation`'s `endSlot`. Wraps
* 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 creates a second compact field-action implementation alongside the existing TextField/PasswordField path: PasswordField still renders a raw IconButton, and text-field.module.css already shrinks slotted buttons to 24px. That leaves two parallel ways to express the same field affordance, with styling drift already possible. Please migrate the existing field action usage to this wrapper here, or move the 24px field-action treatment into a shared button size/token that both paths reuse.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Migrations are not in scope here — we will integrate in a follow-up PR.

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.

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.

function ControlActionButton({ exceptionallySetClassName, ...props }, ref) {
const sharedProps = {
ref,
variant: 'quaternary' as const,
exceptionallySetClassName: classNames([
styles.controlActionButton,
exceptionallySetClassName,
]),
}

return 'children' in props ? (
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] The shared props between Button and IconButton can be extracted to avoid duplicating the variant and exceptionallySetClassName setup.

const sharedProps = {
    ref,
    variant: 'quaternary' as const,
    exceptionallySetClassName: classNames([
        styles.controlActionButton,
        exceptionallySetClassName,
    ]),
}

return 'children' in props ? (
    <Button {...props} {...sharedProps} />
) : (
    <IconButton {...props} {...sharedProps} />
)

<Button {...props} {...sharedProps} />
) : (
<IconButton {...props} {...sharedProps} />
)
},
)
66 changes: 66 additions & 0 deletions src/control-presentation/control-presentation.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
:root {
--reactist-field-height: 32px;
}

.container {
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 source of truth for the input chrome that still exists in text-field.module.css and select-field.module.css (32px field height, idle/hover/focus/error borders, read-only styling, slot spacing). Until those components are migrated, any visual/state fix will have to be made in multiple places. Please either switch the existing field components to consume ControlPresentation in this PR, or extract the shared chrome rules into one reused path before publishing the new primitive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Migrations are not in scope here — we will integrate in a follow-up PR.

/* sizing */
height: var(--reactist-field-height);

/* slot-to-control gap (only takes effect between rendered children) */
gap: 6px;

/* default outer padding; shrunk on the side(s) where a slot is present */
padding-inline: 10px;
}

/* Conditional outer padding. When a slot is present on a side, shrink
* the outer padding on that side; the wrapper's flex `gap` then provides
* the visual spacing between the slot and the control. */
.container:has(.startSlot) {
padding-left: 6px;
}

.container:has(.endSlot) {
padding-right: 4px;
}

.control {
display: contents;
}

/* The wrapped control inherits chrome styling so that native elements
* (input/select/textarea) render flush with the surrounding wrapper. */
.control > * {
/* layout */
flex: 1;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;

/* border */
border: none;
outline: none; /* focus state is handled by the wrapper border */

/* color */
background: transparent;
color: inherit;
}

.slot {
/* color */
color: var(--reactist-field-slot-content);
}

/*
* Compact 24×24 action button variant for use inside `endSlot`. The 3px
* border-radius and reduced min-width make it fit the field chrome alongside a
* 16px icon glyph. The tripled class boosts specificity above Button's
* size-class rules (e.g. `.baseButton.size-normal`) so the height override
* wins regardless of stylesheet load order.
*/
.controlActionButton.controlActionButton.controlActionButton {
--reactist-btn-height: 24px;
border-radius: 3px;
min-width: var(--reactist-btn-height);
}
Loading
Loading