Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/blue-otters-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
otp: dynamic(() => import('../stories/otp.component.mdx')),
popover: dynamic(() => import('../stories/popover.component.mdx')),
section: dynamic(() => import('../stories/section.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
Expand Down
6 changes: 4 additions & 2 deletions packages/swingset/src/components/PropTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ interface ExtraProp {
interface PropTableProps {
meta: StoryMeta;
extra?: ExtraProp[];
/** Set for a component that does not forward `className`/`style`. @default true */
styleProps?: boolean;
}

const STYLEX_ROWS: ExtraProp[] = [
{ name: 'className', type: 'string' },
{ name: 'style', type: 'CSSProperties' },
];

export function PropTable({ meta, extra = [] }: PropTableProps) {
export function PropTable({ meta, extra = [], styleProps = true }: PropTableProps) {
const playground = usePlayground();
const variants = meta.styles?._variants ?? {};
const defaults = meta.styles?._defaultVariants ?? {};
Expand All @@ -38,7 +40,7 @@ export function PropTable({ meta, extra = [] }: PropTableProps) {
return { name, type, default: defDisplay };
}),
...extra,
...STYLEX_ROWS,
...(styleProps ? STYLEX_ROWS : []),
];

return (
Expand Down
18 changes: 18 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ import {
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OtpComponentDefault,
Disabled as OtpComponentDisabled,
Error as OtpComponentError,
meta as otpComponentMeta,
Sizes as OtpComponentSizes,
Success as OtpComponentSuccess,
} from '../stories/otp.component.stories';
import { meta as otpMeta } from '../stories/otp.stories';
import {
Alignment as PopoverComponentAlignment,
Expand Down Expand Up @@ -245,6 +253,15 @@ const headingModule: StoryModule = {

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const otpComponentModule: StoryModule = {
meta: otpComponentMeta,
Default: OtpComponentDefault,
Sizes: OtpComponentSizes,
Success: OtpComponentSuccess,
Error: OtpComponentError,
Disabled: OtpComponentDisabled,
};

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };

const fieldModule: StoryModule = {
Expand Down Expand Up @@ -403,6 +420,7 @@ export const registry: StoryModule[] = [
headingModule,
iconModule,
menuComponentModule,
otpComponentModule,
popoverComponentModule,
sectionModule,
textModule,
Expand Down
110 changes: 110 additions & 0 deletions packages/swingset/src/stories/otp.component.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as OtpStories from './otp.component.stories';

# OTP

The Mosaic `Otp` is the verification-code field: one styled box per character, built on the headless [OTP primitive](/primitives/otp). Focus advances as the code is typed, `Backspace` walks back, and a pasted code spreads across the boxes. Each box carries the same border, hover, and focus treatment as `Input`.

## Playground

<Preview
name='Default'
storyModule={OtpStories}
/>

## Props

<PropTable
meta={OtpStories.meta}
styleProps={false}
extra={[
{ name: 'length', type: 'number', default: '6' },
{ name: 'value', type: 'string', default: '—' },
{ name: 'defaultValue', type: 'string', default: "''" },
{ name: 'onValueChange', type: '(value: string) => void', default: '—' },
{ name: 'onComplete', type: '(value: string) => void', default: '—' },
{ name: 'pattern', type: "'numeric' | 'alpha' | 'alphanumeric'", default: "'numeric'" },
{ name: 'mask', type: 'boolean', default: 'false' },
{ name: 'name', type: 'string', default: '—' },
{ name: 'disabled', type: 'boolean', default: 'false' },
]}
/>

Every prop of the headless [OTP primitive](/primitives/otp) passes through. The boxes are styled through their `.cl-otp` and `.cl-otp-slot` classes, so `Otp` takes no `className` or `style`.

## Usage

`length` defaults to `6`. Give the group an accessible name with `aria-label`, or place it in a `Field.Root` with a `Field.Label`.

```tsx
import { Otp } from '@clerk/ui/mosaic/components/otp';

<Otp
aria-label='Verification code'
onComplete={code => verify(code)}
/>;
```

The value is uncontrolled by default. Pass `value` with `onValueChange` to control it. There is no `onChange`: the boxes are separate inputs, so the whole code is reported as one string.

```tsx
const [code, setCode] = useState('');
Comment thread
alexcarpenter marked this conversation as resolved.

<Otp
value={code}
onValueChange={setCode}
aria-label='Verification code'
/>;
```

Inside a `Field.Root`, the field's `disabled` and `invalid` flow into the boxes, and the label and messages are associated with the group:

```tsx
<Field.Root invalid={Boolean(error)}>
<Field.Label>Verification code</Field.Label>
<Otp name='code' />
{error ? <Field.Error>{error}</Field.Error> : <Field.Description>Didn’t receive a code? Resend</Field.Description>}
</Field.Root>
```

`status` overrides that: `'error'` marks every box invalid and colours it negative, `'success'` colours a verified code positive.

---

## Examples

### Sizes

<Story
name='Sizes'
storyModule={OtpStories}
/>

### Success

<Story
name='Success'
storyModule={OtpStories}
/>

### Error

<Story
name='Error'
storyModule={OtpStories}
/>

### Disabled

<Story
name='Disabled'
storyModule={OtpStories}
/>

## Parts

| Part | Stable slot class | Description |
| ---- | ----------------- | ---------------------------------------------- |
| Root | `.cl-otp` | The `role="group"` holding the boxes. |
| Slot | `.cl-otp-slot` | One `input` per character, one box in the row. |

Both carry `data-size` and `data-status`, plus `data-disabled` when disabled. Each slot additionally carries the primitive's `data-active` (holds focus) and `data-filled` (holds a character).
104 changes: 104 additions & 0 deletions packages/swingset/src/stories/otp.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Field } from '@clerk/ui/mosaic/components/field';
import type { OtpProps } from '@clerk/ui/mosaic/components/otp';
import { Otp } from '@clerk/ui/mosaic/components/otp';

import type { StoryMeta } from '@/lib/types';

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './otp.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'OTP',
source: 'packages/ui/src/mosaic/components/otp/otp.tsx',
styles: {
_variants: {
size: { sm: {}, md: {}, lg: {} },
status: { neutral: {}, success: {}, error: {} },
},
_defaultVariants: {
size: 'md',
status: 'neutral',
},
},
};

const stackStyles = {
display: 'grid',
gap: 8,
justifyItems: 'start',
} as const;

function knobsAsProps(props: Record<string, unknown>) {
return props as unknown as OtpProps;
}

export function Default(props: Record<string, unknown>) {
return (
<Otp
{...knobsAsProps(props)}
aria-label='Verification code'
/>
);
}

export function Sizes() {
return (
<div style={{ display: 'grid', gap: 16, justifyItems: 'start' }}>
<Otp
size='sm'
defaultValue='123'
aria-label='Small code'
/>
<Otp
size='md'
defaultValue='123'
aria-label='Medium code'
/>
<Otp
size='lg'
defaultValue='123'
aria-label='Large code'
/>
</div>
);
}

export function Success() {
return (
<Field.Root style={stackStyles}>
<Otp
status='success'
defaultValue='123456'
aria-label='Verification code'
/>
<Field.Description>Success</Field.Description>
</Field.Root>
);
}

export function Error() {
return (
<Field.Root
invalid
style={stackStyles}
>
<Otp
defaultValue='123456'
aria-label='Verification code'
/>
<Field.Error>Incorrect code</Field.Error>
</Field.Root>
);
}

export function Disabled() {
return (
<Otp
disabled
defaultValue='123'
aria-label='Verification code'
/>
);
}
65 changes: 1 addition & 64 deletions packages/ui/src/mosaic/components/input/input.styles.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,11 @@
import * as stylex from '@stylexjs/stylex';

import {
colorVars,
durationVars,
easingVars,
fontFamilyVars,
fontWeightVars,
radiusVars,
space,
typeScaleVars,
} from '../../tokens.stylex';

const disabledBackgroundColor = `color-mix(in oklab, ${colorVars['--cl-color-primary']} 5%, transparent)`;
const hoverBorderColor = 'light-dark(#bebebe, #525252)';
const focusShadow = '0 0 0 3px light-dark(rgb(23 23 23 / 8%), rgb(255 255 255 / 8%))';
const invalidFocusShadow = `0 0 0 3px light-dark(
color-mix(in oklab, ${colorVars['--cl-color-negative']} 12%, transparent),
color-mix(in oklab, ${colorVars['--cl-color-negative']} 15%, transparent)
)`;
import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
base: {
borderColor: {
default: colorVars['--cl-color-border'],
':focus-visible': hoverBorderColor,
':focus-visible:where([aria-invalid="true"])': colorVars['--cl-color-negative'],
':where([aria-invalid="true"])': colorVars['--cl-color-negative'],
'@media (hover: hover)': {
':hover:not([aria-invalid="true"])': hoverBorderColor,
},
},
borderStyle: 'solid',
borderWidth: '1px',
outline: {
default: 'none',
'@media (forced-colors: active)': {
default: null,
':focus-visible': '2px solid CanvasText',
},
},
backgroundColor: colorVars['--cl-color-input'],
boxShadow: {
default: null,
':focus-visible': focusShadow,
':focus-visible:where([aria-invalid="true"])': invalidFocusShadow,
},
display: 'block',
fontFamily: fontFamilyVars['--cl-font-family-sans'],
outlineOffset: {
default: null,
'@media (forced-colors: active)': {
default: null,
':focus-visible': '2px',
},
},
transitionDuration: {
default: durationVars['--cl-duration-base'],
':focus-visible': durationVars['--cl-duration-fast'],
},
transitionProperty: 'color, background-color, border-color, box-shadow',
transitionTimingFunction: {
default: 'linear',
':focus-visible': `linear, linear, linear, ${easingVars['--cl-ease-default']}`,
},
minWidth: 0,
width: '100%',
'::file-selector-button': {
Expand All @@ -80,12 +23,6 @@ export const styles = stylex.create({
color: colorVars['--cl-color-input-placeholder'],
},
},
disabled: {
backgroundColor: disabledBackgroundColor,
cursor: 'not-allowed',
opacity: 0.5,
pointerEvents: 'none',
},
});

export const sizes = stylex.create({
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { useOptionalFieldControlProps } from '../field/field.context';
import { sizes, styles } from './input.styles';
Expand Down Expand Up @@ -52,7 +53,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(function Mos
'aria-describedby': fieldProps?.['aria-describedby'] ?? ariaDescribedBy,
...mergeStyleProps(
themeProps('input', { size, disabled }),
stylex.props(reset.base, styles.base, sizes[size], disabled && styles.disabled),
stylex.props(reset.base, inputStyles.base, styles.base, sizes[size], disabled && inputStyles.disabled),
className,
style,
),
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/otp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Otp } from './otp';
export type { OtpProps, OtpSize, OtpStatus } from './otp';
Loading
Loading