Skip to content
Draft
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/brave-bags-listen.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 @@ -48,6 +48,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
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')),
Expand Down
6 changes: 4 additions & 2 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand All @@ -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<Record<string, unknown>>;

if (!StoryComp) {
Expand All @@ -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 (
<div className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'>
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -508,6 +523,7 @@ export const registry: StoryModule[] = [
cardComponentModule,
flowComponentModule,
inputModule,
inputGroupModule,
itemModule,
dialogComponentModule,
headingModule,
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/stories/button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 cell8px 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.

Expand Down
16 changes: 11 additions & 5 deletions packages/swingset/src/stories/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand Down Expand Up @@ -46,6 +46,12 @@ export function Primary(props: Record<string, unknown>) {
export function Sizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Button
{...knobsAsProps(props)}
size='xs'
>
Extra small
</Button>
<Button
{...knobsAsProps(props)}
size='sm'
Expand Down Expand Up @@ -218,7 +224,7 @@ export function Icons(props: Record<string, unknown>) {
export function IconSizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'grid', gap: 12, justifyItems: 'start' }}>
{(['sm', 'md', 'lg'] as const).map(size => (
{(['xs', 'sm', 'md', 'lg'] as const).map(size => (
<div
key={size}
style={{ display: 'flex', gap: 8, alignItems: 'center' }}
Expand All @@ -230,7 +236,7 @@ export function IconSizes(props: Record<string, unknown>) {
<Icon
name='check'
placement='inline-start'
size={size}
size={size === 'xs' ? 'sm' : size}
/>
Approve
</Button>
Expand All @@ -248,7 +254,7 @@ export function IconSizes(props: Record<string, unknown>) {
<Icon
name='chevron-down'
placement='inline-end'
size={size}
size={size === 'xs' ? 'sm' : size}
/>
</Button>
</div>
Expand Down Expand Up @@ -388,7 +394,7 @@ export function SubmitDelay(props: Record<string, unknown>) {
export function SubmitSizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{(['sm', 'md', 'lg'] as const).map(size => (
{(['xs', 'sm', 'md', 'lg'] as const).map(size => (
<SubmitButton
key={size}
{...knobsAsProps(props)}
Expand Down
70 changes: 70 additions & 0 deletions packages/swingset/src/stories/input-group.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as InputGroupStories from './input-group.stories';

# InputGroup

`InputGroup` composes a headless `Input` and supporting content inside one shared field surface.

## Playground

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

## Props

<PropTable meta={InputGroupStories.meta} />

## 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 (
<InputGroup.Root>
<Input variant='headless' type={visible ? 'text' : 'password'} aria-label='Password' />
<InputGroup.Action
type='button'
size='xs'
shape='square'
aria-label={visible ? 'Hide password' : 'Show password'}
onClick={() => setVisible(value => !value)}
>
<Icon name={visible ? 'eye-slash' : 'eye'} size='sm' />
</InputGroup.Action>
</InputGroup.Root>
);
}
```

---

## Examples

### Sizes

<Story
name='Sizes'
storyModule={InputGroupStories}
showCode={false}
/>

### Disabled

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

### Invalid

<Story
name='Invalid'
storyModule={InputGroupStories}
/>
133 changes: 133 additions & 0 deletions packages/swingset/src/stories/input-group.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
return props as unknown as InputGroupRootProps;
}

export function Default(props: Record<string, unknown>) {
const [visible, setVisible] = useState(false);
const groupProps = knobsAsProps(props);

return (
<Field.Root style={{ width: 320 }}>
<Field.Label>Password</Field.Label>
<InputGroup.Root {...groupProps}>
<Input
variant='headless'
type={visible ? 'text' : 'password'}
placeholder='Enter password'
autoComplete='current-password'
/>
<InputGroup.Action
type='button'
size='xs'
shape='square'
aria-label={visible ? 'Hide password' : 'Show password'}
onClick={() => setVisible(value => !value)}
>
<Icon
name={visible ? 'eye-slash' : 'eye'}
size='sm'
/>
</InputGroup.Action>
</InputGroup.Root>
</Field.Root>
);
}

export function Sizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'grid', gap: 8, width: 320 }}>
{(['sm', 'md', 'lg'] as const).map(size => (
<InputGroup.Root
{...knobsAsProps(props)}
key={size}
size={size}
>
<Input
variant='headless'
aria-label={`${size} email username`}
placeholder='username'
/>
<InputGroup.Text>@acme.com</InputGroup.Text>
</InputGroup.Root>
))}
</div>
);
}

export function Disabled(props: Record<string, unknown>) {
return (
<Field.Root
disabled
style={{ width: 320 }}
>
<Field.Label>Email address</Field.Label>
<InputGroup.Root {...knobsAsProps(props)}>
<Input
variant='headless'
defaultValue='austin'
/>
<InputGroup.Text>@acme.com</InputGroup.Text>
</InputGroup.Root>
</Field.Root>
);
}

export function Invalid(props: Record<string, unknown>) {
const [visible, setVisible] = useState(false);
const groupProps = knobsAsProps(props);

return (
<Field.Root
invalid
style={{ width: 320 }}
>
<Field.Label>Password</Field.Label>
<InputGroup.Root {...groupProps}>
<Input
variant='headless'
type={visible ? 'text' : 'password'}
defaultValue='short'
autoComplete='new-password'
/>
<InputGroup.Action
type='button'
size='xs'
shape='square'
aria-label={visible ? 'Hide password' : 'Show password'}
onClick={() => setVisible(value => !value)}
>
<Icon
name={visible ? 'eye-slash' : 'eye'}
size='sm'
/>
</InputGroup.Action>
</InputGroup.Root>
<Field.Error>Password must be at least 8 characters</Field.Error>
</Field.Root>
);
}
18 changes: 16 additions & 2 deletions packages/ui/src/mosaic/components/button/button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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'] },
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/components/button/button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Button size={size}>Hi</Button>);
expect(screen.getByRole('button')).toHaveAttribute('data-size', size);
});
Expand Down
Loading
Loading