From 9927dc52eb4365ebe3169597550c78e467d967ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Sat, 16 May 2026 14:49:08 -0500 Subject: [PATCH 1/6] feat: add BorderedTextField component Co-Authored-By: Claude Sonnet 4.6 --- .../bordered-text-field.module.css | 39 ++++++ .../bordered-text-field.test.tsx | 100 +++++++++++++++ .../bordered-text-field.tsx | 116 ++++++++++++++++++ src/bordered-text-field/index.ts | 1 + src/index.ts | 1 + 5 files changed, 257 insertions(+) create mode 100644 src/bordered-text-field/bordered-text-field.module.css create mode 100644 src/bordered-text-field/bordered-text-field.test.tsx create mode 100644 src/bordered-text-field/bordered-text-field.tsx create mode 100644 src/bordered-text-field/index.ts diff --git a/src/bordered-text-field/bordered-text-field.module.css b/src/bordered-text-field/bordered-text-field.module.css new file mode 100644 index 00000000..c7f1b37d --- /dev/null +++ b/src/bordered-text-field/bordered-text-field.module.css @@ -0,0 +1,39 @@ +.outlinedChrome { + padding: var(--reactist-spacing-small); + cursor: text; +} + +.label { + font-size: var(--reactist-font-size-caption); + font-weight: 500; + letter-spacing: -0.15px; + line-height: inherit; + padding-bottom: 4px; + cursor: text; +} + +.label > span { + cursor: default; +} + +.input { + font-size: var(--reactist-font-size-body); + font-weight: var(--reactist-font-weight-regular); + line-height: calc(var(--reactist-font-size-body) + 7px); + letter-spacing: -0.15px; + + color: var(--reactist-content-primary); + height: 24px; + padding: 0; + margin: 0; + width: 100%; + box-sizing: border-box; + background: transparent; + border: none; + outline: none; +} + +.fullHeightSlot { + padding-left: var(--reactist-spacing-small); + align-self: stretch; +} diff --git a/src/bordered-text-field/bordered-text-field.test.tsx b/src/bordered-text-field/bordered-text-field.test.tsx new file mode 100644 index 00000000..ae9908fd --- /dev/null +++ b/src/bordered-text-field/bordered-text-field.test.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' + +import { BorderedTextField } from './bordered-text-field' + +/** Locate the outlined chrome element by its CSS-module class name (hashed + * in tests but still containing 'outlinedChrome' as a substring). Used in + * several tests where we need to assert against the chrome wrapper rather + * than relying on tree position (BaseField's Stack wraps the output). */ +function getChrome(container: HTMLElement): HTMLElement { + const el = container.querySelector('[class*="outlinedChrome"]') + if (!el) throw new Error('Chrome wrapper not found') + return el +} + +describe('BorderedTextField', () => { + it('renders an input with the given label associated via htmlFor', () => { + render() + const input = screen.getByLabelText('Email') + expect(input.tagName).toBe('INPUT') + }) + + it('forwards the ref to the underlying input', () => { + const ref = React.createRef() + render() + expect(ref.current?.tagName).toBe('INPUT') + }) + + it('renders the label visually inside the chrome', () => { + const { container } = render() + const chrome = getChrome(container) + const label = screen.getByText('Email') + expect(chrome).toContainElement(label) + }) + + it('focuses the input when the chrome wrapper is clicked outside the input', () => { + const { container } = render() + const input = screen.getByLabelText('Email') + expect(input).not.toHaveFocus() + + userEvent.click(getChrome(container)) + expect(input).toHaveFocus() + }) + + it('renders a message below the chrome', () => { + render() + expect(screen.getByText('Please enter your email')).toBeInTheDocument() + }) + + it('renders the endSlot inside the chrome', () => { + const { container } = render( + end} />, + ) + const chrome = getChrome(container) + expect(chrome).toContainElement(screen.getByTestId('end')) + }) + + it('renders the character count below when maxLength is set and characterCountPosition is default', () => { + render() + expect(screen.getByText(/0\/30/)).toBeInTheDocument() + }) + + it('hides the character count when characterCountPosition="hidden"', () => { + render() + expect(screen.queryByText(/0\/30/)).not.toBeInTheDocument() + }) + + it('does not accept startSlot, endSlotPosition, or characterCountPosition="inline" at the type level', () => { + // Compile-time guards. + // @ts-expect-error — startSlot is not part of BorderedTextField's API. + ;} /> + // @ts-expect-error — endSlotPosition is not part of BorderedTextField's API. + ; + // @ts-expect-error — 'inline' is not a valid characterCountPosition for bordered. + ; + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + x + + } + /> + , + ) + expect(await axe(container)).toHaveNoViolations() + }) + }) +}) diff --git a/src/bordered-text-field/bordered-text-field.tsx b/src/bordered-text-field/bordered-text-field.tsx new file mode 100644 index 00000000..01643979 --- /dev/null +++ b/src/bordered-text-field/bordered-text-field.tsx @@ -0,0 +1,116 @@ +import * as React from 'react' + +import { BaseField } from '../base-field' +import { Box } from '../box' +import { OutlinedControlContainer } from '../control-presentation/outlined-control-container' + +import styles from './bordered-text-field.module.css' + +import type { FieldComponentProps } from '../base-field' + +type BorderedTextFieldType = 'email' | 'search' | 'tel' | 'text' | 'url' + +interface BorderedTextFieldProps + extends Omit< + FieldComponentProps, + | 'type' + | 'supportsStartAndEndSlots' + | 'endSlot' + | 'endSlotPosition' + | 'characterCountPosition' + > { + type?: BorderedTextFieldType + /** Optional full-height slot rendered to the right of the label+input column. */ + endSlot?: React.ReactElement | string | number + /** Position of the character count. `'inline'` is not supported in this layout. */ + characterCountPosition?: 'below' | 'hidden' + /** The maximum number of characters that the input field can accept. */ + maxLength?: number +} + +const BorderedTextField = React.forwardRef( + function BorderedTextField( + { + id, + label, + value, + auxiliaryLabel, + message, + tone, + type = 'text', + maxWidth, + maxLength, + hidden, + endSlot, + characterCountPosition = 'below', + 'aria-describedby': ariaDescribedBy, + onChange: originalOnChange, + ...props + }, + ref, + ) { + return ( + + ) + }, +) + +export { BorderedTextField } +export type { BorderedTextFieldProps, BorderedTextFieldType } diff --git a/src/bordered-text-field/index.ts b/src/bordered-text-field/index.ts new file mode 100644 index 00000000..63c55db6 --- /dev/null +++ b/src/bordered-text-field/index.ts @@ -0,0 +1 @@ +export * from './bordered-text-field' diff --git a/src/index.ts b/src/index.ts index 7be0452b..da546d71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export * from './button' export * from './text-link' // form fields +export * from './bordered-text-field' export * from './checkbox-field' export * from './control-presentation' export * from './password-field' From 713633c4db3582639096ae7691fdd574ffa88c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Sat, 16 May 2026 14:51:43 -0500 Subject: [PATCH 2/6] docs: add BorderedTextField stories --- .../bordered-text-field.stories.mdx | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/bordered-text-field/bordered-text-field.stories.mdx diff --git a/src/bordered-text-field/bordered-text-field.stories.mdx b/src/bordered-text-field/bordered-text-field.stories.mdx new file mode 100644 index 00000000..dae7dd63 --- /dev/null +++ b/src/bordered-text-field/bordered-text-field.stories.mdx @@ -0,0 +1,98 @@ +import { Meta, Story, Canvas, ArgsTable, Description } from '@storybook/addon-docs' +import { BorderedTextField } from './bordered-text-field' +import { Box } from '../box' +import { IconButton } from '../button' + + + +# BorderedTextField + +The outlined text-field layout: the label sits inside a rounded chrome, +above the input. Used when a field's label should be visually integrated +with its control (e.g. dense form layouts). + +`BorderedTextField` is a sibling to `TextField`, not a variant of it. The +two have meaningfully different APIs: + +- `BorderedTextField` does **not** support `startSlot`. +- `endSlot`, when set, is rendered as a full-height side column. There is + no inline `endSlot` position. +- `characterCountPosition` does not support `'inline'`. Use `'below'` + (default) or `'hidden'`. + +## Playground + + + + {(args) => ( + + + + )} + + + + + +## With a full-height endSlot + + + + + } + /> + + + + +## States + + + + + + + + + + + + + +## Migration from `TextField variant="bordered"` + +Replace `` with +``. Drop any `startSlot`, `endSlotPosition`, or +`characterCountPosition="inline"` props — those are no longer supported in +the outlined layout. If you need a leading icon, consider using `TextField` +(default) instead. If you need a side action, use `endSlot`. From f4e7ba934ab9279ca5da189d738c0aa0eb9abb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Sat, 16 May 2026 14:55:54 -0500 Subject: [PATCH 3/6] feat!: drop variant prop from TextField; compose ControlPresentation Co-Authored-By: Claude Sonnet 4.6 --- src/text-field/text-field.module.css | 86 ------------------------- src/text-field/text-field.stories.mdx | 52 +-------------- src/text-field/text-field.test.tsx | 35 ----------- src/text-field/text-field.tsx | 91 ++++++++------------------- 4 files changed, 29 insertions(+), 235 deletions(-) delete mode 100644 src/text-field/text-field.module.css diff --git a/src/text-field/text-field.module.css b/src/text-field/text-field.module.css deleted file mode 100644 index be6201c3..00000000 --- a/src/text-field/text-field.module.css +++ /dev/null @@ -1,86 +0,0 @@ -.inputWrapper { - cursor: text; - height: var(--reactist-input-wrapper-height); -} - -.inputWrapper.readOnly { - background-color: var(--reactist-field-readonly-background); -} - -.inputWrapper.readOnly input { - background-color: var(--reactist-field-readonly-background); - color: var(--reactist-content-primary); -} - -.inputWrapper:not(.bordered) { - --reactist-input-wrapper-height: 32px; - - border-radius: var(--reactist-border-radius-small); - border: 1px solid var(--reactist-inputs-idle); - overflow: clip; -} - -.inputWrapper.bordered { - --reactist-input-wrapper-height: 24px; -} - -.inputWrapper.bordered input { - padding: 0; - height: var(--reactist-input-wrapper-height); -} - -.inputWrapper:not(.bordered):hover { - border-color: var(--reactist-inputs-hover); -} - -.inputWrapper:not(.bordered):focus-within { - border-color: var(--reactist-inputs-focus); -} - -.inputWrapper:not(.bordered).error { - border-color: var(--reactist-inputs-alert) !important; -} - -.inputWrapper input { - color: var(--reactist-content-primary); - flex: 1; - outline: none; /* we take care of the focus state above */ - box-sizing: border-box; - width: 100%; - background: transparent; - border: none; - - /** - * The desired height below is 30px. This is so that, with +2px from the wrapper borders - * we obtain a 32px height on the wrapper. - * - * Unlike with buttons, the `input` does not own the visible border, as this is set in - * the parent container. This is so that we can place other stuff to appear to be - * "inside" the input (e.g. the toggle password visibility button). So in order to have - * the perceived text field be of height 32px we need the actual `input` to be of height - * 30px. - */ - --tmp-desired-height: 30px; - --tmp-line-height-increase: 4px; - --tmp-vertical-padding: calc( - ( - var(--tmp-desired-height) - var(--reactist-font-size-body) - - var(--tmp-line-height-increase) - ) / - 2 - ); - - margin: 0; -} - -.inputWrapper:not(.bordered) input { - padding: var(--tmp-vertical-padding) var(--reactist-spacing-small); -} - -.slot { - align-items: center; -} - -.slot button { - --reactist-btn-height: 24px !important; -} diff --git a/src/text-field/text-field.stories.mdx b/src/text-field/text-field.stories.mdx index 8ffb6fe6..360773da 100644 --- a/src/text-field/text-field.stories.mdx +++ b/src/text-field/text-field.stories.mdx @@ -21,6 +21,8 @@ import { selectWithNone } from '../utils/storybook-helper' A component used to accept textual input from the user. +For the outlined layout with the label inside the chrome, see `BorderedTextField`. + export function SearchIcon() { return ( @@ -100,11 +102,6 @@ export function InteractivePropsStory({ control: { type: 'boolean' }, defaultValue: false, }, - variant: { - options: ['default', 'bordered'], - control: { type: 'inline-radio' }, - defaultValue: 'default', - }, auxiliaryLabel: { control: { type: 'text' }, defaultValue: 'Need help?', @@ -135,11 +132,6 @@ export function InteractivePropsStory({ control: { type: 'inline-radio' }, defaultValue: undefined, }, - endSlotPosition: { - options: [undefined, 'bottom', 'fullHeight'], - control: { type: 'inline-radio' }, - defaultValue: undefined, - }, }} > {InteractivePropsStory.bind({})} @@ -230,13 +222,7 @@ Note that these variables are shared with other components such as `PasswordFiel > } - placeholder="Text field with an icon" - /> - } placeholder="Text field with an icon" /> @@ -335,38 +321,6 @@ Hence, the description is never needed when the field is not focused anyway. -## Variants - -The `variant` prop is used to change the style of the text field. The default variant is `default`. The other variant is `bordered`. - -export function WithBorderedExample() { - return ( - - ) -} - -export function WithBorderedAndEndSlotExample({ endSlotPosition = 'fullHeight' } = {}) { - return ( - } - endSlotPosition={endSlotPosition} - /> - ) -} - - - - - - - - - - - ## Max length The `maxLength` prop is used to limit the number of characters that the user can input. When the user tries to input more characters than the maximum allowed, the field won't accept the input. diff --git a/src/text-field/text-field.test.tsx b/src/text-field/text-field.test.tsx index a26cd84c..50848ebe 100644 --- a/src/text-field/text-field.test.tsx +++ b/src/text-field/text-field.test.tsx @@ -6,8 +6,6 @@ import { axe } from 'jest-axe' import { TextField } from './' -import type { TextFieldProps } from './' - describe('TextField', () => { it('supports having an externally provided id attribute', () => { render() @@ -251,39 +249,6 @@ describe('TextField', () => { }) }) - describe('endSlotPosition', () => { - test.each(['bottom', 'fullHeight', undefined])( - 'renders the end slot for default variant when endSlotPosition is %s', - (endSlotPosition) => { - render( - , - ) - expect(screen.getByText('Kwijibo')).toBeInTheDocument() - }, - ) - - test.each(['bottom', 'fullHeight', undefined])( - 'renders the end slot for bordered variant when endSlotPosition is %s', - (endSlotPosition) => { - render( - , - ) - expect(screen.getByText('Kwijibo')).toBeInTheDocument() - }, - ) - }) - describe('character count', () => { it('renders the character count element when characterCountPosition is "below"', () => { render( diff --git a/src/text-field/text-field.tsx b/src/text-field/text-field.tsx index 28819818..23575ec3 100644 --- a/src/text-field/text-field.tsx +++ b/src/text-field/text-field.tsx @@ -1,34 +1,24 @@ import * as React from 'react' -import { useMergeRefs } from 'use-callback-ref' - import { BaseField } from '../base-field' -import { Box } from '../box' - -import styles from './text-field.module.css' +import { ControlPresentation } from '../control-presentation' -import type { BaseFieldProps, BaseFieldVariantProps, FieldComponentProps } from '../base-field' +import type { BaseFieldProps, FieldComponentProps } from '../base-field' type TextFieldType = 'email' | 'search' | 'tel' | 'text' | 'url' interface TextFieldProps extends Omit, 'type' | 'supportsStartAndEndSlots'>, - BaseFieldVariantProps, Pick { type?: TextFieldType startSlot?: React.ReactElement | string | number endSlot?: React.ReactElement | string | number - /** - * The maximum number of characters that the input field can accept. - * When this limit is reached, the input field will not accept any more characters. - * The counter element will turn red when the number of characters is within 10 of the maximum limit. - */ + /** Maximum number of characters that the input field can accept. */ maxLength?: number } const TextField = React.forwardRef(function TextField( { - variant = 'default', id, label, value, @@ -44,26 +34,12 @@ const TextField = React.forwardRef(function Te endSlot, onChange: originalOnChange, characterCountPosition = 'below', - endSlotPosition = 'bottom', ...props }, ref, ) { - const internalRef = React.useRef(null) - const combinedRef = useMergeRefs([ref, internalRef]) - - function handleClick(event: React.MouseEvent) { - if (event.currentTarget === combinedRef.current) return - internalRef.current?.focus() - } - - const displayEndSlot = - endSlot && - (variant === 'default' || (variant === 'bordered' && endSlotPosition === 'bottom')) - return ( (function Te hidden={hidden} aria-describedby={ariaDescribedBy} characterCountPosition={characterCountPosition} - supportsStartAndEndSlots - endSlot={endSlot} - endSlotPosition={variant === 'bordered' ? endSlotPosition : undefined} > - {({ onChange, characterCountElement, ...extraProps }) => ( - ( + + {characterCountElement} + {endSlot} + + ) : undefined + } > - {startSlot ? ( - - {startSlot} - - ) : null} + )} ) From d6924e085e7d1e703109767c45a784ab82919e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Sat, 16 May 2026 14:59:13 -0500 Subject: [PATCH 4/6] feat!: drop variant prop from SelectField; compose ControlPresentation Co-Authored-By: Claude Sonnet 4.6 --- src/select-field/select-field.module.css | 65 +---------------------- src/select-field/select-field.stories.mdx | 9 ++-- src/select-field/select-field.test.tsx | 6 --- src/select-field/select-field.tsx | 44 +++++++-------- 4 files changed, 23 insertions(+), 101 deletions(-) diff --git a/src/select-field/select-field.module.css b/src/select-field/select-field.module.css index 11020ce5..7f9184d2 100644 --- a/src/select-field/select-field.module.css +++ b/src/select-field/select-field.module.css @@ -1,65 +1,4 @@ -.selectWrapper { - width: 100%; - position: relative; -} - -.selectWrapper.bordered select { - padding: 0; - height: 24px; - border-color: transparent; - outline: none; -} - -.selectWrapper svg { - position: absolute; - right: 10px; - top: 8px; - bottom: 8px; - color: var(--reactist-content-secondary); -} - -.selectWrapper select { - /* position is set so that z-index is acknowledged */ - position: relative; - - /* z-index is set so that the select appears on top of the svg, and picks up all clicks */ - /* since the select is transparent, visually there's no effect. */ - z-index: 1; - - --tmp-desired-height: 32px; - --tmp-line-height-increase: 4px; - --tmp-vertical-padding: calc( - ( - var(--tmp-desired-height) - var(--reactist-font-size-body) - - var(--tmp-line-height-increase) - ) / - 2 - ); - padding: var(--tmp-vertical-padding) 10px; - padding-right: 30px; /* to make room for the absolutely positioned chevron icon */ - +.select { + /* Suppress the native dropdown arrow; CP renders our own chevron in endSlot. */ appearance: none; - box-sizing: border-box; - width: 100%; - color: var(--reactist-content-primary); - background: none; - border-radius: var(--reactist-border-radius-small); - border: 1px solid var(--reactist-inputs-idle); - margin: 0; -} - -.selectWrapper:not(.bordered).error select { - border-color: var(--reactist-inputs-alert) !important; -} - -.selectWrapper:not(.bordered) option { - background-color: var(--reactist-bg-aside); -} - -.selectWrapper:not(.bordered) select:hover { - border-color: var(--reactist-inputs-hover); -} - -.selectWrapper:not(.bordered) select:focus { - border-color: var(--reactist-inputs-focus); } diff --git a/src/select-field/select-field.stories.mdx b/src/select-field/select-field.stories.mdx index e27237cf..b43c0bef 100644 --- a/src/select-field/select-field.stories.mdx +++ b/src/select-field/select-field.stories.mdx @@ -72,11 +72,6 @@ export function InteractivePropsStory({ label, auxiliaryLabel, ...props }) { ['xsmall', 'small', 'medium', 'large', 'xlarge', 'full'], 'small', ), - variant: { - options: ['default', 'bordered'], - control: { type: 'inline-radio' }, - defaultValue: 'default', - }, auxiliaryLabel: { control: { type: 'text' }, defaultValue: 'Need help?', @@ -101,7 +96,7 @@ export function InteractivePropsStory({ label, auxiliaryLabel, ...props }) { ## Colors The following CSS custom properties are available so that the `SelectField`'s border colors can be customized. -Note that these variables are shared with other components such as `Textfield`, `PasswordField`, and `TextArea`. +Note that these variables are shared with other components such as `TextField`, `PasswordField`, and `TextArea`. ``` --reactist-inputs-focus @@ -109,6 +104,8 @@ Note that these variables are shared with other components such as `Textfield`, --reactist-inputs-alert ``` +> **Note:** The `variant` prop has been removed. `SelectField` now always renders using the default (outlined) style. A bordered variant is no longer supported. + { expect(screen.getByTestId('container')).toHaveAttribute('data-theme', 'light') }) - it('supports providing a variant', () => { - render() - - expect(screen.getByTestId('select-wrapper')).toHaveClass('bordered') - }) - describe('a11y', () => { it('renders with no a11y violations', async () => { const { container } = render( diff --git a/src/select-field/select-field.tsx b/src/select-field/select-field.tsx index 23335784..2168ff76 100644 --- a/src/select-field/select-field.tsx +++ b/src/select-field/select-field.tsx @@ -1,26 +1,23 @@ import * as React from 'react' import { BaseField } from '../base-field' -import { Box } from '../box' +import { ControlPresentation } from '../control-presentation' import styles from './select-field.module.css' -import type { BaseFieldVariantProps, FieldComponentProps } from '../base-field' +import type { FieldComponentProps } from '../base-field' -interface SelectFieldProps - extends Omit< - FieldComponentProps, - | 'maxLength' - | 'characterCountPosition' - | 'endSlot' - | 'supportsStartAndEndSlots' - | 'endSlotPosition' - >, - BaseFieldVariantProps {} +type SelectFieldProps = Omit< + FieldComponentProps, + | 'maxLength' + | 'characterCountPosition' + | 'endSlot' + | 'supportsStartAndEndSlots' + | 'endSlotPosition' +> const SelectField = React.forwardRef(function SelectField( { - variant = 'default', id, label, value, @@ -38,7 +35,6 @@ const SelectField = React.forwardRef(functi ) { return ( (functi hidden={hidden} aria-describedby={ariaDescribedBy} > - {({ characterCountElement, ...extraProps }) => ( - + {({ id: resolvedId, 'aria-describedby': describedBy, 'aria-invalid': invalid }) => ( + }> - - + )} ) From c69ff1d8cc3e913bb5ceccf466c58dfa2b55aae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Sat, 16 May 2026 15:03:07 -0500 Subject: [PATCH 5/6] feat!: drop variant prop from TextArea; compose OutlinedControlContainer Co-Authored-By: Claude Sonnet 4.6 --- src/text-area/text-area.module.css | 86 +++++++---------------------- src/text-area/text-area.stories.mdx | 15 ----- src/text-area/text-area.tsx | 67 +++++++++++----------- 3 files changed, 54 insertions(+), 114 deletions(-) diff --git a/src/text-area/text-area.module.css b/src/text-area/text-area.module.css index 395b4679..8e002ab4 100644 --- a/src/text-area/text-area.module.css +++ b/src/text-area/text-area.module.css @@ -1,94 +1,46 @@ -/*************************************** +/* + * Auto-expand uses a grid layout where the textarea and an ::after + * pseudo-element overlap. Chrome (border, hover/focus/error/readonly/disabled) + * is provided by OutlinedControlContainer; this stylesheet handles only + * textarea-specific concerns. + */ -Auto-expand works thanks to some aspects of the .innerContainer - -- .innerContainer::after styles need to be at all times the same as styles on the textarea. -- .innerContainer needs to have the value of the textarea stored in a data- attribute (js React - effect keeps it in sync) -- .innerContainer::after and the textarea are positioned one on top of the other, overlapping at - all times. -- The fact that .innerContainer is a grid layout (and that the content and styles are in sync) - allows them both to expand and shrink in sync. - -See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ - -***************************************/ - -.textAreaContainer { - font-family: var(--reactist-font-family); +.chrome { + /* Reserved for any TextArea-specific chrome adjustments. */ } .innerContainer::after, -.textAreaContainer textarea { - outline: none; /* we're taking care of the outline styles ourselves */ +.innerContainer > textarea { + outline: none; border: none; - padding: 0; + padding: var(--reactist-spacing-small); box-sizing: border-box; width: 100%; resize: vertical; - overflow-wrap: anywhere; /* to stop unnecessary horizontal expansion of text-area when `autoExpand` is enabled.*/ -} - -.textAreaContainer textarea[readonly] { - background-color: var(--reactist-field-readonly-background); - color: var(--reactist-content-primary); -} - -.textAreaContainer:not(.bordered) .innerContainer::after, -.textAreaContainer:not(.bordered) textarea { - border-radius: var(--reactist-border-radius-small); - padding: var(--reactist-spacing-small); -} - -.textAreaContainer.bordered { - border-radius: var(--reactist-border-radius-large); -} - -.textAreaContainer:not(.bordered) .innerContainer::after, -.textAreaContainer:not(.bordered) textarea, -.textAreaContainer.bordered { - border: 1px solid var(--reactist-inputs-idle); -} - -.textAreaContainer:not(.bordered) textarea:hover, -.textAreaContainer.bordered:hover { - border-color: var(--reactist-inputs-hover); -} - -.textAreaContainer:not(.bordered) textarea:focus, -.textAreaContainer.bordered:focus-within { - border-color: var(--reactist-inputs-focus); -} - -.textAreaContainer.error:not(.bordered) .innerContainer::after, -.textAreaContainer.error:not(.bordered) textarea, -.textAreaContainer.bordered.error { - border-color: var(--reactist-inputs-alert) !important; + overflow-wrap: anywhere; + background: transparent; + color: inherit; + font-family: var(--reactist-font-family); + font-size: var(--reactist-font-size-body); + line-height: calc(var(--reactist-font-size-body) + 7px); } .innerContainer { - /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ display: grid; } .innerContainer::after { - /* Note the weird space! Needed to preventy jumpy behavior */ content: attr(data-replicated-value) ' '; - - /* This is how textarea text behaves */ white-space: pre-wrap; - - /* Hidden from view, clicks, and screen readers */ visibility: hidden; } .innerContainer > textarea, .innerContainer::after { - /* Place on top of each other */ grid-area: 1 / 1 / 2 / 2; } textarea.disableResize { - resize: none; /* You could leave this, but after a user resizes, then it ruins the auto sizing */ - overflow: hidden; /* Firefox shows scrollbar on growth, you can hide like this. */ + resize: none; + overflow: hidden; } diff --git a/src/text-area/text-area.stories.mdx b/src/text-area/text-area.stories.mdx index eabdaeb5..033a836b 100644 --- a/src/text-area/text-area.stories.mdx +++ b/src/text-area/text-area.stories.mdx @@ -74,11 +74,6 @@ export function InteractivePropsStory({ ['xsmall', 'small', 'medium', 'large', 'xlarge', 'full'], 'small', ), - variant: { - options: ['default', 'bordered'], - control: { type: 'inline-radio' }, - defaultValue: 'default', - }, auxiliaryLabel: { control: { type: 'text' }, defaultValue: 'Need help?', @@ -240,11 +235,6 @@ export function AutoExpandStory(props) { ['xsmall', 'small', 'medium', 'large', 'xlarge', 'full'], 'small', ), - variant: { - options: ['default', 'bordered'], - control: { type: 'inline-radio' }, - defaultValue: 'default', - }, auxiliaryLabel: { control: false }, rows: { control: { type: 'number' }, @@ -307,11 +297,6 @@ export function AutoExpandWithInitialValueStory(props) { ['xsmall', 'small', 'medium', 'large', 'xlarge', 'full'], 'small', ), - variant: { - options: ['default', 'bordered'], - control: { type: 'inline-radio' }, - defaultValue: 'default', - }, auxiliaryLabel: { control: false }, rows: { control: { type: 'number' }, diff --git a/src/text-area/text-area.tsx b/src/text-area/text-area.tsx index e9574f38..eb193794 100644 --- a/src/text-area/text-area.tsx +++ b/src/text-area/text-area.tsx @@ -5,17 +5,14 @@ import { useMergeRefs } from 'use-callback-ref' import { BaseField } from '../base-field' import { Box } from '../box' +import { OutlinedControlContainer } from '../control-presentation/outlined-control-container' import styles from './text-area.module.css' -import type { BaseFieldVariantProps, FieldComponentProps } from '../base-field' +import type { FieldComponentProps } from '../base-field' interface TextAreaProps - extends Omit, 'characterCountPosition'>, - Omit< - BaseFieldVariantProps, - 'supportsStartAndEndSlots' | 'endSlot' | 'endSlotPosition' | 'value' - > { + extends Omit, 'characterCountPosition'> { /** * The value of the text area. * @@ -55,7 +52,6 @@ interface TextAreaProps const TextArea = React.forwardRef(function TextArea( { - variant = 'default', id, label, value, @@ -87,7 +83,6 @@ const TextArea = React.forwardRef(function T return ( (function T tone={tone} hidden={hidden} aria-describedby={ariaDescribedBy} - className={[ - styles.textAreaContainer, - tone === 'error' ? styles.error : null, - variant === 'bordered' ? styles.bordered : null, - ]} maxWidth={maxWidth} maxLength={maxLength} > - {({ onChange, characterCountElement, ...extraProps }) => ( - ( + - + + + )} ) From 91bb0e35c524038c1c65b61791db24e26a40f7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Sat, 16 May 2026 15:07:00 -0500 Subject: [PATCH 6/6] feat!: scope BaseField down to scaffolding only Remove variant, BaseFieldVariantProps, supportsStartAndEndSlots, endSlot, endSlotPosition, and bordered chrome from BaseField; strip .bordered.* CSS rules; update all callers (password-field, bordered-text-field, select-field, text-field). Co-Authored-By: Claude Sonnet 4.6 --- src/base-field/base-field.module.css | 55 +---- src/base-field/base-field.tsx | 212 +++--------------- .../bordered-text-field.tsx | 9 +- src/password-field/password-field.tsx | 5 +- src/select-field/select-field.tsx | 6 +- src/text-field/text-field.tsx | 2 +- 6 files changed, 40 insertions(+), 249 deletions(-) diff --git a/src/base-field/base-field.module.css b/src/base-field/base-field.module.css index e50898cf..31ae3dba 100644 --- a/src/base-field/base-field.module.css +++ b/src/base-field/base-field.module.css @@ -2,68 +2,29 @@ --reactist-field-label-padding-bottom: 6px; --reactist-field-label-line-height: inherit; } + .container { font-family: var(--reactist-font-family); + display: flex; + flex-direction: column; } .container label { letter-spacing: -0.15px; + padding-bottom: var(--reactist-field-label-padding-bottom); + line-height: var(--reactist-field-label-line-height); } -.container label, .container .auxiliaryLabel { + text-align: right; padding-bottom: var(--reactist-field-label-padding-bottom); line-height: var(--reactist-field-label-line-height); } -.container.bordered { - border-radius: var(--reactist-border-radius-large); - border: 1px solid var(--reactist-inputs-idle); - padding: var(--reactist-spacing-small); - padding-bottom: var(--reactist-spacing-xsmall); - overflow: clip; -} - -.container.bordered label { - /* so that clicking in blank areas to the right of the label will focus the field element */ - flex-grow: 1; - /* to convey that clicking in blank area to the right of the label places focus on the text field */ - cursor: text; -} - -.container.bordered label span { - /* overrides the cursor set above, so that hovering over the non-blank parts of the label stay unaffected */ - cursor: default; -} - -.container.bordered:hover { - border-color: var(--reactist-inputs-hover) !important; -} - -.container.bordered:focus-within { - border-color: var(--reactist-inputs-focus) !important; -} - -.container.bordered.error { - border-color: var(--reactist-inputs-alert) !important; -} - -.container.bordered .primaryLabel { - font-weight: 500; -} - -.container.bordered .auxiliaryLabel { - font-size: var(--reactist-font-size-caption); -} - -.container:not(.bordered) .primaryLabel { +.container .primaryLabel { font-weight: var(--reactist-font-weight-strong); } -.container:not(.bordered) .auxiliaryLabel { - font-size: var(--reactist-font-size-body); -} - .container input, .container textarea, .container select { @@ -75,7 +36,7 @@ } .auxiliaryLabel { - text-align: right; + font-size: var(--reactist-font-size-body); } .loadingIcon { diff --git a/src/base-field/base-field.tsx b/src/base-field/base-field.tsx index 08c253d6..1e270f8e 100644 --- a/src/base-field/base-field.tsx +++ b/src/base-field/base-field.tsx @@ -12,8 +12,6 @@ import styles from './base-field.module.css' import type { BoxProps } from '../box' import type { WithEnhancedClassName } from '../utils/common-types' -// Define the remaining characters before the character count turns red -// See: https://twist.com/a/1585/ch/765851/t/6664583/c/93631846 for latest spec const MAX_LENGTH_THRESHOLD = 0 type FieldTone = 'neutral' | 'success' | 'error' | 'loading' @@ -74,25 +72,16 @@ function validateInputLength({ maxLength, }: ValidateInputLengthProps): ValidateInputLengthResult { if (!maxLength) { - return { - count: null, - tone: 'neutral', - } + return { count: null, tone: 'neutral' } } - const currentLength = String(value || '').length const isNearMaxLength = maxLength - currentLength <= MAX_LENGTH_THRESHOLD - return { count: `${currentLength}/${maxLength}`, tone: isNearMaxLength ? 'error' : 'neutral', } } -// -// BaseField -// - type ChildrenRenderProps = { id: string value?: React.InputHTMLAttributes['value'] @@ -107,144 +96,29 @@ type HtmlInputProps = React.DetailedHTMLProps< T > -type BaseFieldVariant = 'default' | 'bordered' -type BaseFieldVariantProps = { - /** - * Provides alternative visual layouts or modes that the field can be rendered in. - * - * Namely, there are two variants supported: - * - * - the default one - * - a "bordered" variant, where the border of the field surrounds also the labels, instead - * of just surrounding the actual field element - * - * In both cases, the message and description texts for the field lie outside the bordered - * area. - */ - variant?: BaseFieldVariant -} - export type BaseFieldProps = WithEnhancedClassName & Pick, 'id' | 'hidden' | 'maxLength' | 'aria-describedby'> & { - /** - * The main label for this field element. - * - * This prop is not optional. Consumers of field components must be explicit about not - * wanting a label by passing `label=""` or `label={null}`. In those situations, consumers - * should make sure that fields are properly labelled semantically by other means (e.g using - * `aria-labelledby`, or rendering a `` element referencing the field by id). - * - * Avoid providing interactive elements in the label. Prefer `auxiliaryLabel` for that. - * - * @see BaseFieldProps['auxiliaryLabel'] - */ + /** Main label. Pass `null` for components that render the label themselves. */ label: React.ReactNode - - /** - * The initial value for this field element. - * - * This prop is used to calculate the character count for the initial value, and is then - * passed to the underlying child element. - */ value?: React.InputHTMLAttributes['value'] - - /** - * An optional extra element to be placed to the right of the main label. - * - * This extra element is not included in the accessible name of the field element. Its only - * purpose is either visual, or functional (if you include interactive elements in it). - * - * @see BaseFieldProps['label'] - * - * @deprecated The usage of this element is discouraged given that it was removed from the - * latest form field spec revision. - */ + /** @deprecated removed from the latest form-field spec; will be deleted in a future major. */ auxiliaryLabel?: React.ReactNode - - /** - * A message associated with the field. It is rendered below the field, and with an - * appearance that conveys the tone of the field (e.g. coloured red for errors, green for - * success, etc). - * - * The message element is associated to the field via the `aria-describedby` attribute. - * - * In the future, when `aria-errormessage` gets better user agent support, we should use it - * to associate the filed with a message when tone is `"error"`. - * - * @see BaseFieldProps['tone'] - * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-errormessage - * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid - */ message?: React.ReactNode - - /** - * The tone with which the message, if any, is presented. - * - * If the tone is `"error"`, the field border turns red, and the message, if any, is also - * red. - * - * When the tone is `"loading"`, it is recommended that you also disable the field. However, - * this is not enforced by the component. It is only a recommendation. - * - * @see BaseFieldProps['message'] - * @see BaseFieldProps['hint'] - */ tone?: FieldTone - - /** - * The maximum width that the input field can expand to. - */ maxWidth?: BoxProps['maxWidth'] - - /** - * The maximum number of characters that the input field can accept. - * When this limit is reached, the input field will not accept any more characters. - * The counter element will turn red when the number of characters is within 10 of the maximum limit. - */ maxLength?: number - - /** - * Used internally by components composed using `BaseField`. It is not exposed as part of - * the public props of such components. - */ children: (props: ChildrenRenderProps) => React.ReactNode - - /** - * The position of the character count element. - * It can be shown below the field or inline with the field. - * - * @default 'below' - */ + /** 'below' (default) renders the count under the field; 'inline' yields it via the render-prop; 'hidden' suppresses. */ characterCountPosition?: 'below' | 'inline' | 'hidden' - } & ( - | { - supportsStartAndEndSlots?: false - endSlot?: never - endSlotPosition?: never - } - | { - supportsStartAndEndSlots: true - endSlot?: React.ReactElement | string | number - /** - * This is solely for `bordered` variants of TextField. When set to `bottom` (the default), - * the endSlot will be placed inline with the input field. When set to `fullHeight`, the endSlot - * will be placed to the side of both the input field and the label. - */ - endSlotPosition?: 'bottom' | 'fullHeight' - } - ) + } type FieldComponentProps = Omit< BaseFieldProps, - 'children' | 'className' | 'fieldRef' | 'variant' + 'children' | 'className' | 'fieldRef' > & Omit, 'className' | 'style'> -/** - * BaseField is a base component that provides a consistent structure for form fields. - */ function BaseField({ - variant = 'default', label, value, auxiliaryLabel, @@ -258,9 +132,7 @@ function BaseField({ 'aria-describedby': originalAriaDescribedBy, id: originalId, characterCountPosition = 'below', - endSlot, - endSlotPosition = 'bottom', -}: BaseFieldProps & BaseFieldVariantProps & WithEnhancedClassName) { +}: BaseFieldProps) { const id = useId(originalId) const messageId = useId() @@ -288,19 +160,14 @@ function BaseField({ ...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : {}), 'aria-invalid': tone === 'error' ? true : undefined, onChange(event) { - if (!maxLength) { - return - } - + if (!maxLength) return const inputLength = validateInputLength({ value: event.currentTarget.value, maxLength, }) - setCharacterCount(inputLength.count) setCharacterCountTone(inputLength.tone) }, - // If the character count is inline, we pass it as a prop to the children element so it can be rendered inline characterCountElement: renderCharacterCountInline ? renderCharacterCount() : null, } @@ -313,45 +180,25 @@ function BaseField({ return ( {label} - ) : null} - - {auxiliaryLabel ? ( - - {auxiliaryLabel} - - ) : null} - - ) : null} - {children(childrenProps)} - - {endSlot && endSlotPosition === 'fullHeight' ? endSlot : null} + + {label || auxiliaryLabel ? ( + + + {label ? {label} : null} + + {auxiliaryLabel ? ( + + {auxiliaryLabel} + + ) : null} + + ) : null} + {children(childrenProps)} {message || renderCharacterCountBelow ? ( @@ -363,9 +210,6 @@ function BaseField({ ) : null} - - {/* If the character count is below the field, we render it, if it's inline, - we pass it as a prop to the children element so it can be rendered inline */} {characterCountPosition === 'below' ? ( {renderCharacterCount()} ) : null} @@ -376,4 +220,4 @@ function BaseField({ } export { BaseField, FieldMessage } -export type { BaseFieldVariant, BaseFieldVariantProps, FieldComponentProps } +export type { FieldComponentProps } diff --git a/src/bordered-text-field/bordered-text-field.tsx b/src/bordered-text-field/bordered-text-field.tsx index 01643979..b0822bd2 100644 --- a/src/bordered-text-field/bordered-text-field.tsx +++ b/src/bordered-text-field/bordered-text-field.tsx @@ -11,14 +11,7 @@ import type { FieldComponentProps } from '../base-field' type BorderedTextFieldType = 'email' | 'search' | 'tel' | 'text' | 'url' interface BorderedTextFieldProps - extends Omit< - FieldComponentProps, - | 'type' - | 'supportsStartAndEndSlots' - | 'endSlot' - | 'endSlotPosition' - | 'characterCountPosition' - > { + extends Omit, 'type' | 'characterCountPosition'> { type?: BorderedTextFieldType /** Optional full-height slot rendered to the right of the label+input column. */ endSlot?: React.ReactElement | string | number diff --git a/src/password-field/password-field.tsx b/src/password-field/password-field.tsx index 52f68a45..a3d0026c 100644 --- a/src/password-field/password-field.tsx +++ b/src/password-field/password-field.tsx @@ -5,12 +5,9 @@ import { PasswordHiddenIcon } from '../icons/password-hidden-icon' import { PasswordVisibleIcon } from '../icons/password-visible-icon' import { TextField } from '../text-field' -import type { BaseFieldVariantProps } from '../base-field' import type { TextFieldProps } from '../text-field' -interface PasswordFieldProps - extends Omit, - BaseFieldVariantProps { +interface PasswordFieldProps extends Omit { togglePasswordLabel?: string endSlot?: React.ReactElement | string | number } diff --git a/src/select-field/select-field.tsx b/src/select-field/select-field.tsx index 2168ff76..ca75ac99 100644 --- a/src/select-field/select-field.tsx +++ b/src/select-field/select-field.tsx @@ -9,11 +9,7 @@ import type { FieldComponentProps } from '../base-field' type SelectFieldProps = Omit< FieldComponentProps, - | 'maxLength' - | 'characterCountPosition' - | 'endSlot' - | 'supportsStartAndEndSlots' - | 'endSlotPosition' + 'maxLength' | 'characterCountPosition' > const SelectField = React.forwardRef(function SelectField( diff --git a/src/text-field/text-field.tsx b/src/text-field/text-field.tsx index 23575ec3..53465d21 100644 --- a/src/text-field/text-field.tsx +++ b/src/text-field/text-field.tsx @@ -8,7 +8,7 @@ import type { BaseFieldProps, FieldComponentProps } from '../base-field' type TextFieldType = 'email' | 'search' | 'tel' | 'text' | 'url' interface TextFieldProps - extends Omit, 'type' | 'supportsStartAndEndSlots'>, + extends Omit, 'type'>, Pick { type?: TextFieldType startSlot?: React.ReactElement | string | number