diff --git a/.changeset/brave-bags-listen.md b/.changeset/brave-bags-listen.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/brave-bags-listen.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index a8238ca4d90..739d1d2ae84 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -48,6 +48,7 @@ const docModules: Record> = { button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), input: dynamic(() => import('../stories/input.mdx')), + 'input-group': dynamic(() => import('../stories/input-group.mdx')), item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), diff --git a/packages/swingset/src/components/StoryEmbed.tsx b/packages/swingset/src/components/StoryEmbed.tsx index 92d7e2c1cec..d093f01d243 100644 --- a/packages/swingset/src/components/StoryEmbed.tsx +++ b/packages/swingset/src/components/StoryEmbed.tsx @@ -18,6 +18,8 @@ import { CompositionPanel } from './Composition'; interface StoryEmbedProps { name: string; storyModule: StoryModule; + /** Whether to show the story's source footer. */ + showCode?: boolean; /** When provided, a collapsible "Composition" footer is attached to the example card. */ composition?: CompositionPiece[]; } @@ -44,7 +46,7 @@ function CompositionFooter({ composition }: { composition: CompositionPiece[] }) ); } -export function StoryEmbed({ name, storyModule, composition }: StoryEmbedProps) { +export function StoryEmbed({ name, storyModule, showCode = true, composition }: StoryEmbedProps) { const StoryComp = storyModule[name] as React.ComponentType>; if (!StoryComp) { @@ -56,7 +58,7 @@ export function StoryEmbed({ name, storyModule, composition }: StoryEmbedProps) // Present only for modules that expose `__source` (see `StoryModule.__source`). The raw // story function is a knob harness, so reduce it to a clean usage snippet for the footer. const rawSource = extractStorySource(storyModule.__source, name); - const source = rawSource ? toUsageSnippet(rawSource) : null; + const source = showCode && rawSource ? toUsageSnippet(rawSource) : null; return (
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dd765d1559d..f0cd41eeba8 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -70,6 +70,13 @@ import { meta as inputMeta, Sizes as InputSizes, } from '../stories/input.stories'; +import { + Default as InputGroupDefault, + Disabled as InputGroupDisabled, + Invalid as InputGroupInvalid, + meta as inputGroupMeta, + Sizes as InputGroupSizes, +} from '../stories/input-group.stories'; import { Default as ItemDefault, Group as ItemGroup, @@ -271,6 +278,14 @@ const inputModule: StoryModule = { Headless: InputHeadless, }; +const inputGroupModule: StoryModule = { + meta: inputGroupMeta, + Default: InputGroupDefault, + Sizes: InputGroupSizes, + Disabled: InputGroupDisabled, + Invalid: InputGroupInvalid, +}; + const popoverComponentModule: StoryModule = { meta: popoverComponentMeta, Default: PopoverComponentDefault, @@ -508,6 +523,7 @@ export const registry: StoryModule[] = [ cardComponentModule, flowComponentModule, inputModule, + inputGroupModule, itemModule, dialogComponentModule, headingModule, diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx index 8139bf1ccd1..b86a410f48b 100644 --- a/packages/swingset/src/stories/button.mdx +++ b/packages/swingset/src/stories/button.mdx @@ -90,7 +90,7 @@ both, with nothing to declare on the button itself. The tightened value isn't chosen by eye. An icon is centered in the button, so it already has `(height − icon size) / 2` of space above and below it; matching the inline side to that puts it -in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`. +in a square cell. Text padding steps from 8px at `xs`, to 10px at `sm`, and 12px at `md` and `lg`. The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off. The middle button in each row below carries no icon, for comparison. diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index 28ce109c3ac..a61c309b88f 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -17,7 +17,7 @@ export const meta: StoryMeta = { _variants: { color: { primary: {}, neutral: {}, negative: {} }, variant: { filled: {}, outline: {}, ghost: {}, link: {} }, - size: { sm: {}, md: {}, lg: {} }, + size: { xs: {}, sm: {}, md: {}, lg: {} }, shape: { default: {}, square: {}, circle: {} }, fullWidth: { true: {}, false: {} }, touchTarget: { true: {}, false: {} }, @@ -46,6 +46,12 @@ export function Primary(props: Record) { export function Sizes(props: Record) { return (
+ @@ -248,7 +254,7 @@ export function IconSizes(props: Record) {
@@ -388,7 +394,7 @@ export function SubmitDelay(props: Record) { export function SubmitSizes(props: Record) { return (
- {(['sm', 'md', 'lg'] as const).map(size => ( + {(['xs', 'sm', 'md', 'lg'] as const).map(size => ( + +## Props + + + +## Usage + +```tsx +import { InputGroup } from '@clerk/ui/mosaic/components/input-group'; +import { Input } from '@clerk/ui/mosaic/components/input'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { useState } from 'react'; + +function PasswordField() { + const [visible, setVisible] = useState(false); + + return ( + + + setVisible(value => !value)} + > + + + + ); +} +``` + +--- + +## Examples + +### Sizes + + + +### Disabled + + + +### Invalid + + diff --git a/packages/swingset/src/stories/input-group.stories.tsx b/packages/swingset/src/stories/input-group.stories.tsx new file mode 100644 index 00000000000..b5a891d84f0 --- /dev/null +++ b/packages/swingset/src/stories/input-group.stories.tsx @@ -0,0 +1,133 @@ +import { Field } from '@clerk/ui/mosaic/components/field'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Input } from '@clerk/ui/mosaic/components/input'; +import type { InputGroupRootProps } from '@clerk/ui/mosaic/components/input-group'; +import { InputGroup } from '@clerk/ui/mosaic/components/input-group'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './input-group.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'InputGroup', + source: 'packages/ui/src/mosaic/components/input-group/input-group.tsx', + styles: { + _variants: { + size: { sm: {}, md: {}, lg: {} }, + }, + _defaultVariants: { + size: 'md', + }, + }, +}; + +function knobsAsProps(props: Record) { + return props as unknown as InputGroupRootProps; +} + +export function Default(props: Record) { + const [visible, setVisible] = useState(false); + const groupProps = knobsAsProps(props); + + return ( + + Password + + + setVisible(value => !value)} + > + + + + + ); +} + +export function Sizes(props: Record) { + return ( +
+ {(['sm', 'md', 'lg'] as const).map(size => ( + + + @acme.com + + ))} +
+ ); +} + +export function Disabled(props: Record) { + return ( + + Email address + + + @acme.com + + + ); +} + +export function Invalid(props: Record) { + const [visible, setVisible] = useState(false); + const groupProps = knobsAsProps(props); + + return ( + + Password + + + setVisible(value => !value)} + > + + + + Password must be at least 8 characters + + ); +} diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index f096e1e2e57..70b30f74dc7 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -431,12 +431,25 @@ export const variants = stylex.create({ }); // size — height-driven; padding sets only the inline axis. An icon's side tightens to the inset -// it already has above and below, `(height - icon) / 2`, so it sits in a square cell: 8px at md -// and lg exactly, and at sm too, where the ideal 7px is off the 4px scale. +// it already has above and below, `(height - icon) / 2`, so it sits in a square cell. // /* eslint-disable @stylexjs/no-lookahead-selectors -- every browser this package builds for supports `:has()` (`tsdown.mosaic.config.mts`); an older one keeps the untightened padding. */ export const sizes = stylex.create({ + xs: { + gap: space['1'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + paddingInlineEnd: { + default: space['2'], + [stylex.when.descendant("[data-icon='inline-end']", iconScope)]: space['1.5'], + }, + paddingInlineStart: { + default: space['2'], + [stylex.when.descendant("[data-icon='inline-start']", iconScope)]: space['1.5'], + }, + height: space['6'], + }, sm: { // sm runs a step tighter than md on every inline measure, gap included gap: space['1.5'], @@ -484,6 +497,7 @@ export const sizes = stylex.create({ /* eslint-enable @stylexjs/no-lookahead-selectors */ export const iconSizes = stylex.create({ + xs: { width: space['6'] }, sm: { width: space['7'] }, md: { width: space['8'] }, lg: { width: space['9'] }, diff --git a/packages/ui/src/mosaic/components/button/button.test.tsx b/packages/ui/src/mosaic/components/button/button.test.tsx index fe9c6be3433..503b1c50d8c 100644 --- a/packages/ui/src/mosaic/components/button/button.test.tsx +++ b/packages/ui/src/mosaic/components/button/button.test.tsx @@ -94,7 +94,7 @@ describe('Mosaic Button', () => { expect(screen.getByRole('button')).toHaveAttribute('data-variant', variant); }); - it.each(['sm', 'md', 'lg'] as const)('reflects the %s size', size => { + it.each(['xs', 'sm', 'md', 'lg'] as const)('reflects the %s size', size => { render(); expect(screen.getByRole('button')).toHaveAttribute('data-size', size); }); diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index d856f99aa3e..b4b4b05d59c 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -12,7 +12,7 @@ import { iconSizes, sizes, styles, variants } from './button.styles'; export interface ButtonProps extends MosaicElementProps<'button'> { color?: 'primary' | 'neutral' | 'negative'; variant?: 'filled' | 'outline' | 'ghost' | 'link'; - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; shape?: 'default' | 'square' | 'circle'; fullWidth?: boolean; /** diff --git a/packages/ui/src/mosaic/components/button/submit-button.tsx b/packages/ui/src/mosaic/components/button/submit-button.tsx index 4f4039a4703..29f0b6996ad 100644 --- a/packages/ui/src/mosaic/components/button/submit-button.tsx +++ b/packages/ui/src/mosaic/components/button/submit-button.tsx @@ -32,9 +32,9 @@ export interface SubmitButtonProps extends ButtonProps { spinDelay?: SpinDelayOptions; } -// The spinner scale stops at `md`, and a `lg` button's label is only one step up, so both take -// the larger ring rather than `lg` asking for one the spinner cannot render. -const spinnerSizes = { sm: 'sm', md: 'md', lg: 'md' } as const; +// The spinner scale stops at `md`, so `xs` and `sm` share the small ring while `md` and `lg` +// share the larger one. +const spinnerSizes = { xs: 'sm', sm: 'sm', md: 'md', lg: 'md' } as const; // Long enough that a request served from cache or a local mutation never draws a spinner, short // enough that a press which is going to take a while doesn't sit there looking ignored. Set here diff --git a/packages/ui/src/mosaic/components/input-group/index.ts b/packages/ui/src/mosaic/components/input-group/index.ts new file mode 100644 index 00000000000..a9af06367c0 --- /dev/null +++ b/packages/ui/src/mosaic/components/input-group/index.ts @@ -0,0 +1,2 @@ +export { InputGroup } from './input-group'; +export type { InputGroupActionProps, InputGroupRootProps, InputGroupTextProps } from './input-group'; diff --git a/packages/ui/src/mosaic/components/input-group/input-group.context.ts b/packages/ui/src/mosaic/components/input-group/input-group.context.ts new file mode 100644 index 00000000000..ac591b774d9 --- /dev/null +++ b/packages/ui/src/mosaic/components/input-group/input-group.context.ts @@ -0,0 +1,27 @@ +'use client'; + +import React from 'react'; + +export type InputGroupSize = 'sm' | 'md' | 'lg'; + +interface InputGroupContextValue { + disabled: boolean; + focusInput: () => void; + invalid: boolean; + setInput: (node: HTMLInputElement | null) => void; + size: InputGroupSize; +} + +export const InputGroupContext = React.createContext(null); + +export function useInputGroupContext(): InputGroupContextValue { + const context = React.useContext(InputGroupContext); + if (!context) { + throw new Error('InputGroup parts must be rendered inside .'); + } + return context; +} + +export function useOptionalInputGroupContext(): InputGroupContextValue | null { + return React.useContext(InputGroupContext); +} diff --git a/packages/ui/src/mosaic/components/input-group/input-group.styles.ts b/packages/ui/src/mosaic/components/input-group/input-group.styles.ts new file mode 100644 index 00000000000..bad9ee018f5 --- /dev/null +++ b/packages/ui/src/mosaic/components/input-group/input-group.styles.ts @@ -0,0 +1,82 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, radiusVars, space, targetVars, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + overflow: 'hidden', + alignItems: 'center', + display: 'flex', + minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, + width: '100%', + }, + text: { + alignItems: 'center', + color: colorVars['--cl-color-neutral-faded'], + display: 'flex', + flexShrink: 0, + fontFamily: fontFamilyVars['--cl-font-family-sans'], + paddingInlineEnd: { + default: 0, + ':last-child': space['3'], + }, + paddingInlineStart: { + default: 0, + ':first-child': space['3'], + }, + }, +}); + +export const sizes = stylex.create({ + sm: { + borderRadius: radiusVars['--cl-radius-md'], + height: space['7'], + }, + md: { + borderRadius: radiusVars['--cl-radius-md'], + height: space['8'], + }, + lg: { + borderRadius: radiusVars['--cl-radius-lg'], + height: space['9'], + }, +}); + +export const textSizes = stylex.create({ + sm: { + fontSize: { + default: typeScaleVars['--cl-text-xs-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-xs-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + md: { + fontSize: { + default: typeScaleVars['--cl-text-sm-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-sm-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + lg: { + fontSize: { + default: typeScaleVars['--cl-text-base-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-base-size']})`, + }, + lineHeight: 1.375, + }, +}); + +export const compactActionInsets = stylex.create({ + sm: { + marginInlineEnd: { default: 0, ':last-child': space['0.5'] }, + marginInlineStart: { default: 0, ':first-child': space['0.5'] }, + }, + md: { + marginInlineEnd: { default: 0, ':last-child': space['1'] }, + marginInlineStart: { default: 0, ':first-child': space['1'] }, + }, + lg: { + marginInlineEnd: { default: 0, ':last-child': space['1.5'] }, + marginInlineStart: { default: 0, ':first-child': space['1.5'] }, + }, +}); diff --git a/packages/ui/src/mosaic/components/input-group/input-group.test.tsx b/packages/ui/src/mosaic/components/input-group/input-group.test.tsx new file mode 100644 index 00000000000..5a26f7f7a7f --- /dev/null +++ b/packages/ui/src/mosaic/components/input-group/input-group.test.tsx @@ -0,0 +1,148 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Field } from '../field'; +import { Input } from '../input'; +import { InputGroup } from './input-group'; + +describe('Mosaic InputGroup', () => { + it('composes text and a headless input inside one control', () => { + render( + + https:// + + .com + , + ); + + const group = document.querySelector('.cl-input-group'); + const input = screen.getByRole('textbox', { name: 'Domain' }); + expect(group).toHaveAttribute('data-size', 'md'); + expect(input).toHaveClass('cl-input'); + expect(input).toHaveAttribute('data-variant', 'headless'); + expect(screen.getByText('https://')).toHaveClass('cl-input-group-text'); + expect(screen.getByText('.com')).toHaveClass('cl-input-group-text'); + }); + + it.each(['sm', 'md', 'lg'] as const)('shares the %s size with its parts', size => { + render( + + Prefix + + , + ); + + expect(document.querySelector('.cl-input-group')).toHaveAttribute('data-size', size); + expect(screen.getByRole('textbox', { name: 'Value' })).toHaveAttribute('data-size', size); + expect(screen.getByText('Prefix')).toHaveAttribute('data-size', size); + }); + + it.each(['sm', 'md', 'lg'] as const)('shares the %s size and disabled state with an action', size => { + render( + + + + , + ); + + const action = screen.getByRole('button', { name: 'Show options' }); + expect(action).toHaveClass('cl-button', 'cl-input-group-action'); + expect(action).toHaveAttribute('data-size', size); + expect(action).toHaveAttribute('data-variant', 'ghost'); + expect(action).toHaveAttribute('data-color', 'neutral'); + expect(action).toBeDisabled(); + }); + + it('allows an action to use a more compact button size', () => { + render( + + + + , + ); + + expect(screen.getByRole('button', { name: 'Show options' })).toHaveAttribute('data-size', 'xs'); + }); + + it('inherits Field state and associates its label and messages with the input', () => { + render( + + Website + + https:// + + + Enter a valid website + , + ); + + const group = document.querySelector('.cl-input-group'); + const input = screen.getByRole('textbox', { name: 'Website' }); + expect(group).toHaveAttribute('data-disabled', ''); + expect(group).toHaveAttribute('data-invalid', ''); + expect(input).toBeDisabled(); + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription('Enter a valid website'); + }); + + it('lets text focus the grouped input without changing its value', async () => { + const user = userEvent.setup(); + render( + + + @acme.com + , + ); + + await user.click(screen.getByText('@acme.com')); + + expect(screen.getByRole('textbox', { name: 'Email username' })).toHaveFocus(); + }); + + it('forwards native input props and the input ref', () => { + const ref = React.createRef(); + render( + + + , + ); + + const input = screen.getByPlaceholderText('example'); + expect(ref.current).toBe(input); + expect(input).toHaveAttribute('name', 'domain'); + }); +}); diff --git a/packages/ui/src/mosaic/components/input-group/input-group.tsx b/packages/ui/src/mosaic/components/input-group/input-group.tsx new file mode 100644 index 00000000000..d03ac0dfba7 --- /dev/null +++ b/packages/ui/src/mosaic/components/input-group/input-group.tsx @@ -0,0 +1,116 @@ +'use client'; + +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 { inputStyles } from '../../utils/input.styles'; +import { reset } from '../../utils/reset.styles'; +import { Button, type ButtonProps } from '../button'; +import { useOptionalFieldContext } from '../field/field.context'; +import type { InputGroupSize } from './input-group.context'; +import { InputGroupContext, useInputGroupContext } from './input-group.context'; +import { compactActionInsets, sizes, styles, textSizes } from './input-group.styles'; + +export interface InputGroupRootProps extends MosaicComponentProps<'div'> { + disabled?: boolean; + invalid?: boolean; + size?: InputGroupSize; +} + +const Root = React.forwardRef(function MosaicInputGroupRoot( + { render, className, style, disabled: disabledProp, invalid: invalidProp, size = 'md', ...otherProps }, + ref, +) { + const field = useOptionalFieldContext(); + const disabled = disabledProp ?? field?.disabled ?? false; + const invalid = invalidProp ?? field?.invalid ?? false; + const inputRef = React.useRef(null); + const focusInput = React.useCallback(() => inputRef.current?.focus(), []); + const setInput = React.useCallback((node: HTMLInputElement | null) => { + inputRef.current = node; + }, []); + const context = React.useMemo( + () => ({ disabled, focusInput, invalid, setInput, size }), + [disabled, focusInput, invalid, setInput, size], + ); + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('input-group', { size, disabled, invalid }), + stylex.props(reset.base, inputStyles.group, styles.root, sizes[size], disabled && inputStyles.disabled), + className, + style, + ), + ...otherProps, + }, + }); + + return {element}; +}); + +export type InputGroupTextProps = MosaicComponentProps<'span'>; + +const Text = React.forwardRef(function MosaicInputGroupText( + { render, className, style, onPointerDown, ...otherProps }, + ref, +) { + const group = useInputGroupContext(); + + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('input-group-text', { size: group.size, disabled: group.disabled }), + stylex.props(reset.base, styles.text, textSizes[group.size]), + className, + style, + ), + onPointerDown: (event: React.PointerEvent) => { + onPointerDown?.(event); + if (!event.defaultPrevented) { + event.preventDefault(); + group.focusInput(); + } + }, + ...otherProps, + }, + }); +}); + +export type InputGroupActionProps = ButtonProps; + +const Action = React.forwardRef(function MosaicInputGroupAction( + { color = 'neutral', variant = 'ghost', size: sizeProp, disabled: disabledProp, className, style, ...otherProps }, + ref, +) { + const group = useInputGroupContext(); + const disabled = group.disabled || disabledProp || false; + const size = sizeProp ?? group.size; + + return ( +