diff --git a/src/control-presentation/control-action-button.test.tsx b/src/control-presentation/control-action-button.test.tsx new file mode 100644 index 00000000..0c76aab6 --- /dev/null +++ b/src/control-presentation/control-action-button.test.tsx @@ -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(Clear) + + const button = screen.getByRole('button', { name: 'Clear' }) + expect(button).toHaveClass(styles.controlActionButton!) + }) + + it('renders the icon-only IconButton branch with compact field styling', () => { + render() + + const button = screen.getByRole('button', { name: 'Clear' }) + expect(button).toHaveClass(styles.controlActionButton!) + }) +}) diff --git a/src/control-presentation/control-action-button.tsx b/src/control-presentation/control-action-button.tsx new file mode 100644 index 00000000..0ba71b94 --- /dev/null +++ b/src/control-presentation/control-action-button.tsx @@ -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 + icon?: never + } & Omit) + | ({ + icon: IconButtonProps['icon'] + children?: never + } & Omit) + +/** + * 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( + function ControlActionButton({ exceptionallySetClassName, ...props }, ref) { + const sharedProps = { + ref, + variant: 'quaternary' as const, + exceptionallySetClassName: classNames([ + styles.controlActionButton, + exceptionallySetClassName, + ]), + } + + return 'children' in props ? ( + + , + ) + const control = screen.getByRole('button', { name: 'Subject' }) + expect(control.tagName).toBe('BUTTON') + expect(control).toHaveTextContent('Choose') + }) + + it('does not alter attributes on the control', () => { + render( + + + , + ) + const control = screen.getByRole('textbox', { name: 'Subject' }) + expect(control).toHaveAttribute('type', 'email') + expect(control).toHaveAttribute('placeholder', 'you@example.com') + expect(control).toHaveAttribute('data-custom', 'yes') + expect(control).toHaveAttribute('readonly') + expect(control).toBeDisabled() + }) + + it('focuses the control when a non-interactive startSlot is clicked', async () => { + render( + }> + + , + ) + const control = screen.getByRole('textbox', { name: 'Subject' }) + expect(control).not.toHaveFocus() + + await userEvent.click(screen.getByTestId('test-icon')) + expect(control).toHaveFocus() + }) + + it('does not forward interactive slot clicks to the control', async () => { + const onControlClick = jest.fn() + const onSlotClick = jest.fn() + render( + + Clear + + } + > + + , + ) + + const slotButton = screen.getByRole('button', { name: 'Clear' }) + await userEvent.click(slotButton) + + expect(onSlotClick).toHaveBeenCalledTimes(1) + expect(onControlClick).not.toHaveBeenCalled() + expect(slotButton).toHaveFocus() + }) + + it('merges exceptionallySetClassName onto the wrapper', () => { + const { container } = render( + + + , + ) + expect(container.firstElementChild).toHaveClass('custom-class') + }) + + it('endSlot accepts multi-child composition', () => { + render( + + + + + } + > + + , + ) + expect(screen.getByTestId('a')).toBeInTheDocument() + expect(screen.getByTestId('b')).toBeInTheDocument() + }) + + it('renders numeric zero slot content', () => { + render( + + + , + ) + expect(screen.getAllByText('0')).toHaveLength(2) + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + + + } + endSlot={ + + } + > + + + , + ) + expect(await axe(container)).toHaveNoViolations() + }) + }) +}) diff --git a/src/control-presentation/control-presentation.tsx b/src/control-presentation/control-presentation.tsx new file mode 100644 index 00000000..b7524666 --- /dev/null +++ b/src/control-presentation/control-presentation.tsx @@ -0,0 +1,81 @@ +import * as React from 'react' +import { type ComponentProps, forwardRef } from 'react' + +import classNames from 'classnames' + +import { Box } from '../box' + +import { OutlinedControlContainer } from './outlined-control-container' + +import styles from './control-presentation.module.css' + +type SlotContent = React.ReactElement | string | number + +export type ControlPresentationProps = { + /** + * A leading element rendered before the control — a decorative icon, a row + * of chips/tags, or any other content that should sit on the leading edge. + */ + startSlot?: SlotContent + + /** + * Trailing content rendered immediately after the control (e.g. a unit, + * counter, supplementary text, or an action button such as a clear button + * or dropdown-trigger chevron). + */ + endSlot?: SlotContent + + /** + * The single control element to wrap. + */ + children: React.ReactElement +} & Omit, 'borderRadius' | 'children'> + +/** + * The visual chrome of an inline, single-row, text-field-style input: a + * 32px-tall row with optional start/end slots around a control element. + * + * Slot order (left to right): `startSlot` → control (children) → `endSlot`. + * + * Click handlers belong on the control itself, not on this wrapper. + * Clicking the wrapper focuses the control. + */ +export const ControlPresentation = forwardRef( + function ControlPresentation( + { startSlot, endSlot, exceptionallySetClassName, onClick, children }, + ref, + ) { + return ( + + {startSlot != null ? ( + {startSlot} + ) : null} +
+ {children} +
+ {endSlot != null ? ( + {endSlot} + ) : null} +
+ ) + }, +) + +function Slot(props: ComponentProps) { + return ( + + ) +} diff --git a/src/control-presentation/index.ts b/src/control-presentation/index.ts new file mode 100644 index 00000000..5bd30a66 --- /dev/null +++ b/src/control-presentation/index.ts @@ -0,0 +1,2 @@ +export * from './control-action-button' +export * from './control-presentation' diff --git a/src/control-presentation/outlined-control-container.module.css b/src/control-presentation/outlined-control-container.module.css new file mode 100644 index 00000000..d4aa0a07 --- /dev/null +++ b/src/control-presentation/outlined-control-container.module.css @@ -0,0 +1,102 @@ +.container { + display: flex; + align-items: center; + overflow: hidden; + + /* border */ + border: 1px solid var(--reactist-inputs-idle); + + /* color */ + background: var(--reactist-field-background); + color: var(--reactist-field-content); +} + +/* Read-only: matches native :read-only on form controls (scoped to + * /