From 2595cc907d43470b930c6df7f1fdc65c3f78c41f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 3 Aug 2026 12:31:23 -0600 Subject: [PATCH 1/7] feat(ui): add Mosaic TextField component --- .changeset/quiet-fields-compose.md | 2 + .../src/mosaic/components/text-field/index.ts | 12 + .../text-field/text-field.ssr.test.tsx | 39 ++ .../text-field/text-field.styles.ts | 90 +++++ .../components/text-field/text-field.test.tsx | 332 ++++++++++++++++++ .../components/text-field/text-field.tsx | 267 ++++++++++++++ packages/ui/src/mosaic/icons/registry.tsx | 16 + packages/ui/src/mosaic/styles/index.ts | 12 + 8 files changed, 770 insertions(+) create mode 100644 .changeset/quiet-fields-compose.md create mode 100644 packages/ui/src/mosaic/components/text-field/index.ts create mode 100644 packages/ui/src/mosaic/components/text-field/text-field.ssr.test.tsx create mode 100644 packages/ui/src/mosaic/components/text-field/text-field.styles.ts create mode 100644 packages/ui/src/mosaic/components/text-field/text-field.test.tsx create mode 100644 packages/ui/src/mosaic/components/text-field/text-field.tsx diff --git a/.changeset/quiet-fields-compose.md b/.changeset/quiet-fields-compose.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-fields-compose.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/components/text-field/index.ts b/packages/ui/src/mosaic/components/text-field/index.ts new file mode 100644 index 00000000000..11f1a2f4954 --- /dev/null +++ b/packages/ui/src/mosaic/components/text-field/index.ts @@ -0,0 +1,12 @@ +export { TextField } from './text-field'; +export type { + TextFieldContentProps, + TextFieldDescriptionProps, + TextFieldErrorProps, + TextFieldIds, + TextFieldInputProps, + TextFieldLabelProps, + TextFieldLayout, + TextFieldRootProps, + TextFieldSize, +} from './text-field'; diff --git a/packages/ui/src/mosaic/components/text-field/text-field.ssr.test.tsx b/packages/ui/src/mosaic/components/text-field/text-field.ssr.test.tsx new file mode 100644 index 00000000000..e200dcd4806 --- /dev/null +++ b/packages/ui/src/mosaic/components/text-field/text-field.ssr.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment node + +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { TextField } from './text-field'; + +describe('Mosaic TextField SSR', () => { + it('emits complete label and message relationships on the first render', () => { + const html = renderToString( + + Email + + + Description + Error + + , + ); + + expect(html).toContain('for="email"'); + expect(html).toContain('id="email-label"'); + expect(html).toContain('id="email-description"'); + expect(html).toContain('id="email-error"'); + expect(html).toContain('aria-describedby="external email-description email-error"'); + expect(html).toContain('aria-invalid="true"'); + expect(html).toContain('required=""'); + }); +}); diff --git a/packages/ui/src/mosaic/components/text-field/text-field.styles.ts b/packages/ui/src/mosaic/components/text-field/text-field.styles.ts new file mode 100644 index 00000000000..a44fc992785 --- /dev/null +++ b/packages/ui/src/mosaic/components/text-field/text-field.styles.ts @@ -0,0 +1,90 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + color: colorVars['--cl-color-card-foreground'], + display: 'grid', + minWidth: 0, + width: '100%', + }, + label: { + alignItems: 'center', + alignSelf: 'start', + display: 'inline-flex', + fontWeight: fontWeightVars['--cl-font-medium'], + minWidth: 0, + }, + content: { + display: 'flex', + flexDirection: 'column', + rowGap: space['1.5'], + minWidth: 0, + }, + message: { + margin: 0, + }, + description: { + color: colorVars['--cl-color-neutral-faded'], + }, + error: { + gap: space['1'], + alignItems: 'flex-start', + color: colorVars['--cl-color-negative'], + display: 'flex', + }, + errorIcon: { + flexShrink: 0, + }, + disabledText: { + opacity: 0.5, + }, +}); + +export const layouts = stylex.create({ + stacked: { + gridTemplateColumns: 'minmax(0, 1fr)', + rowGap: space['2'], + }, + horizontal: { + columnGap: space['6'], + gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 24rem)', + }, +}); + +export const labelSizes = stylex.create({ + sm: { + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + md: { + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + lg: { + fontSize: typeScaleVars['--cl-text-base-size'], + lineHeight: 1.375, + }, +}); + +export const horizontalLabelSizes = stylex.create({ + sm: { minHeight: space['7'] }, + md: { minHeight: space['8'] }, + lg: { minHeight: space['9'] }, +}); + +export const messageSizes = stylex.create({ + sm: { + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + md: { + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + lg: { + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, +}); diff --git a/packages/ui/src/mosaic/components/text-field/text-field.test.tsx b/packages/ui/src/mosaic/components/text-field/text-field.test.tsx new file mode 100644 index 00000000000..6197e8bf39b --- /dev/null +++ b/packages/ui/src/mosaic/components/text-field/text-field.test.tsx @@ -0,0 +1,332 @@ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { TextField } from './text-field'; + +describe('Mosaic TextField', () => { + it('associates generated label and description IDs with the input', () => { + render( + + Email + + + Used for account notifications. + + , + ); + + const input = screen.getByRole('textbox', { name: 'Email' }); + const label = screen.getByText('Email'); + const description = screen.getByText('Used for account notifications.'); + + expect(input.id).not.toBe(''); + expect(label).toHaveAttribute('for', input.id); + expect(label).toHaveAttribute('id', `${input.id}-label`); + expect(description).toHaveAttribute('id', `${input.id}-description`); + expect(input).toHaveAttribute('aria-describedby', `${input.id}-description`); + }); + + it('uses explicit IDs and preserves deduplicated consumer descriptions', () => { + render( + + Email + + + Description + Error + + , + ); + + const input = screen.getByRole('textbox', { name: 'Email' }); + expect(input).toHaveAttribute('id', 'email'); + expect(screen.getByText('Email')).toHaveAttribute('id', 'email-label'); + expect(screen.getByText('Description')).toHaveAttribute('id', 'email-description'); + expect(screen.getByText('Error').closest('p')).toHaveAttribute('id', 'email-error'); + expect(input).toHaveAttribute('aria-describedby', 'external email-description email-error'); + }); + + it('propagates size and semantic state from the root', () => { + render( + + Website + + + Description + Error + + , + ); + + const root = screen.getByTestId('root'); + const input = screen.getByRole('textbox', { name: 'Website' }); + expect(root).toHaveAttribute('data-size', 'lg'); + expect(root).toHaveAttribute('data-invalid', ''); + expect(root).toHaveAttribute('data-disabled', ''); + expect(root).toHaveAttribute('data-required', ''); + expect(input).toHaveAttribute('data-size', 'lg'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toBeDisabled(); + expect(input).toBeRequired(); + expect(screen.getByText('Website')).toHaveAttribute('data-size', 'lg'); + expect(screen.getByTestId('content')).toHaveAttribute('data-disabled', ''); + }); + + it.each(['stacked', 'horizontal'] as const)('reflects the %s layout on all parts', layout => { + render( + + Username + + + Description + + , + ); + + expect(screen.getByTestId('root')).toHaveAttribute('data-layout', layout); + expect(screen.getByText('Username')).toHaveAttribute('data-layout', layout); + expect(screen.getByTestId('content')).toHaveAttribute('data-layout', layout); + expect(screen.getByRole('textbox', { name: 'Username' })).toHaveAttribute('data-layout', layout); + expect(screen.getByText('Description')).toHaveAttribute('data-layout', layout); + }); + + it('adds the error relationship only while the root is invalid', () => { + const { rerender } = render( + + Name + + + Description + Error + + , + ); + + const input = screen.getByRole('textbox', { name: 'Name' }); + expect(input).toHaveAttribute('aria-describedby', 'name-description'); + expect(input).not.toHaveAttribute('aria-invalid'); + + rerender( + + Name + + + Description + Error + + , + ); + + expect(input).toHaveAttribute('aria-describedby', 'name-description name-error'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('hydrates generated relationships without warnings', async () => { + const field = ( + + Email + + + Description + Error + + + ); + const container = document.createElement('div'); + container.innerHTML = renderToString(field); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + let root: ReturnType | undefined; + await act(() => { + root = hydrateRoot(container, field); + }); + + expect(consoleError).not.toHaveBeenCalled(); + const input = container.querySelector('input'); + expect(input?.getAttribute('aria-describedby')).toBe(`${input?.id}-description ${input?.id}-error`); + + await act(() => root?.unmount()); + consoleError.mockRestore(); + }); + + it('keeps error announcements opt-in', () => { + render( + + Email + + + Email is invalid. + + , + ); + + const error = screen.getByText('Email is invalid.').closest('p'); + expect(error).not.toHaveAttribute('role'); + expect(error).not.toHaveAttribute('aria-live'); + expect(error?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('forwards native input props and refs', () => { + const ref = React.createRef(); + render( + + Display name + + + + , + ); + + const input = screen.getByRole('textbox', { name: 'Display name' }); + expect(ref.current).toBe(input); + expect(input).toHaveAttribute('name', 'displayName'); + expect(input).toHaveAttribute('autocomplete', 'name'); + }); + + it('forwards refs from every structural part', () => { + const rootRef = React.createRef(); + const labelRef = React.createRef(); + const contentRef = React.createRef(); + const descriptionRef = React.createRef(); + const errorRef = React.createRef(); + + render( + + Email + + + Description + Error + + , + ); + + expect(rootRef.current).toHaveClass('cl-text-field-root'); + expect(labelRef.current).toBe(screen.getByText('Email')); + expect(contentRef.current).toHaveClass('cl-text-field-content'); + expect(descriptionRef.current).toBe(screen.getByText('Description')); + expect(errorRef.current).toBe(screen.getByText('Error').closest('p')); + }); + + it('lets consumer data attributes override reflected variants', () => { + render( + + Email + + + + , + ); + + expect(screen.getByTestId('root')).toHaveAttribute('data-layout', 'consumer'); + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAttribute('data-size', 'consumer'); + }); + + it('lets consumer classes and inline styles win on every part', () => { + render( + + + Email + + + + + Description + + + Error + + + , + ); + + expect(screen.getByTestId('root')).toHaveClass('cl-text-field-root', 'root'); + expect(screen.getByTestId('root')).toHaveStyle({ width: '40rem' }); + expect(screen.getByText('Email')).toHaveClass('cl-text-field-label', 'label'); + expect(screen.getByText('Email')).toHaveStyle({ fontWeight: 700 }); + expect(screen.getByTestId('content')).toHaveClass('cl-text-field-content', 'content'); + expect(screen.getByTestId('content')).toHaveStyle({ rowGap: 12 }); + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveClass('cl-input', 'cl-text-field-input', 'input'); + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveStyle({ paddingInline: 16 }); + expect(screen.getByText('Description')).toHaveClass('cl-text-field-description', 'description'); + expect(screen.getByText('Description')).toHaveStyle({ opacity: 0.8 }); + expect(screen.getByText('Error').closest('p')).toHaveClass('cl-text-field-error', 'error'); + expect(screen.getByText('Error').closest('p')).toHaveStyle({ fontWeight: 600 }); + }); + + it('supports custom elements through render on every part', () => { + render( +
}> + +
}> + } /> +
}>Description +
}>Error + + , + ); + + const input = screen.getByRole('textbox', { name: 'Biography' }); + expect(input.tagName).toBe('TEXTAREA'); + expect(input.closest('section')?.parentElement?.tagName).toBe('SECTION'); + expect(screen.getByText('Description').tagName).toBe('DIV'); + expect(screen.getByText('Error').closest('div')).toHaveClass('cl-text-field-error'); + }); + + it('fails clearly when a part is rendered outside the root', () => { + expect(() => renderToString()).toThrow( + ' must be rendered inside .', + ); + }); +}); diff --git a/packages/ui/src/mosaic/components/text-field/text-field.tsx b/packages/ui/src/mosaic/components/text-field/text-field.tsx new file mode 100644 index 00000000000..3a4dc05d483 --- /dev/null +++ b/packages/ui/src/mosaic/components/text-field/text-field.tsx @@ -0,0 +1,267 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { Icon } from '../icon'; +import { Input, type InputProps } from '../input'; +import { horizontalLabelSizes, labelSizes, layouts, messageSizes, styles } from './text-field.styles'; + +export type TextFieldLayout = 'stacked' | 'horizontal'; +export type TextFieldSize = NonNullable; + +export interface TextFieldIds { + control?: string; + label?: string; + description?: string; + error?: string; +} + +interface TextFieldContextValue { + controlId: string; + labelId: string; + descriptionId: string; + errorId: string; + layout: TextFieldLayout; + size: TextFieldSize; + invalid: boolean; + disabled: boolean; + required: boolean; +} + +const TextFieldContext = React.createContext(null); + +function useTextFieldContext(part: string): TextFieldContextValue { + const context = React.useContext(TextFieldContext); + if (!context) { + throw new Error(` must be rendered inside .`); + } + return context; +} + +function mergeIds(...values: Array): string | undefined { + const ids = Array.from(new Set(values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? []))); + return ids.length > 0 ? ids.join(' ') : undefined; +} + +function useTextFieldControlProps(ariaDescribedBy?: string) { + const context = useTextFieldContext('Input'); + const { controlId, descriptionId, errorId, size, invalid, disabled, required } = context; + return { + context, + controlProps: { + id: controlId, + size, + disabled, + required, + 'aria-invalid': invalid ? ('true' as const) : undefined, + 'aria-describedby': mergeIds(ariaDescribedBy, descriptionId, invalid ? errorId : undefined), + }, + }; +} + +export interface TextFieldRootProps extends MosaicComponentProps<'div'> { + /** Arrangement of the label and control content. @default 'stacked' */ + layout?: TextFieldLayout; + /** Size shared by the label, input, and messages. @default 'md' */ + size?: TextFieldSize; + /** Marks the field as invalid without performing validation. */ + invalid?: boolean; + /** Disables the field input and dims supporting text. */ + disabled?: boolean; + /** Marks the field input as required. */ + required?: boolean; + /** Stable IDs for integrating with markup outside the compound component. */ + ids?: TextFieldIds; +} + +const Root = React.forwardRef(function MosaicTextFieldRoot( + { + layout = 'stacked', + size = 'md', + invalid = false, + disabled = false, + required = false, + ids, + render, + className, + style, + ...rest + }, + ref, +) { + const generatedId = React.useId(); + const controlId = ids?.control ?? `cl-text-field-${generatedId}`; + const context: TextFieldContextValue = { + controlId, + labelId: ids?.label ?? `${controlId}-label`, + descriptionId: ids?.description ?? `${controlId}-description`, + errorId: ids?.error ?? `${controlId}-error`, + layout, + size, + invalid, + disabled, + required, + }; + + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('text-field-root', { layout, size, invalid, disabled, required }), + stylex.props(styles.root, layouts[layout]), + className, + style, + ), + ...rest, + }, + }); + + return {element}; +}); + +export type TextFieldLabelProps = Omit, 'id' | 'htmlFor'>; + +const Label = React.forwardRef(function MosaicTextFieldLabel( + { render, className, style, ...rest }, + ref, +) { + const { controlId, labelId, layout, size, invalid, disabled, required } = useTextFieldContext('Label'); + return useRender({ + defaultTagName: 'label', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('text-field-label', { layout, size, invalid, disabled, required }), + stylex.props( + styles.label, + labelSizes[size], + layout === 'horizontal' && horizontalLabelSizes[size], + disabled && styles.disabledText, + ), + className, + style, + ), + ...rest, + htmlFor: controlId, + id: labelId, + }, + }); +}); + +export type TextFieldContentProps = MosaicComponentProps<'div'>; + +const Content = React.forwardRef(function MosaicTextFieldContent( + { render, className, style, ...rest }, + ref, +) { + const { layout, size, invalid, disabled, required } = useTextFieldContext('Content'); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('text-field-content', { layout, size, invalid, disabled, required }), + stylex.props(styles.content), + className, + style, + ), + ...rest, + }, + }); +}); + +export type TextFieldInputProps = Omit< + InputProps, + 'id' | 'size' | 'disabled' | 'required' | 'aria-disabled' | 'aria-required' | 'aria-invalid' +>; + +const FieldInput = React.forwardRef(function MosaicTextFieldInput( + { render, className, style, 'aria-describedby': ariaDescribedBy, ...rest }, + ref, +) { + const { context, controlProps } = useTextFieldControlProps(ariaDescribedBy); + const { layout, size, invalid, disabled, required } = context; + return ( + + ); +}); + +export type TextFieldDescriptionProps = Omit, 'id'>; + +const Description = React.forwardRef( + function MosaicTextFieldDescription({ render, className, style, ...rest }, ref) { + const { descriptionId, layout, size, invalid, disabled, required } = useTextFieldContext('Description'); + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('text-field-description', { layout, size, invalid, disabled, required }), + stylex.props(styles.message, styles.description, messageSizes[size], disabled && styles.disabledText), + className, + style, + ), + ...rest, + id: descriptionId, + }, + }); + }, +); + +export type TextFieldErrorProps = Omit, 'id'>; + +const FieldError = React.forwardRef(function MosaicTextFieldError( + { render, className, style, children, ...rest }, + ref, +) { + const { errorId, layout, size, invalid, disabled, required } = useTextFieldContext('Error'); + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('text-field-error', { layout, size, invalid, disabled, required }), + stylex.props(styles.message, styles.error, messageSizes[size], disabled && styles.disabledText), + className, + style, + ), + ...rest, + id: errorId, + children: ( + <> +