Skip to content

Commit e8b8cd6

Browse files
feat(ui): add input group
1 parent ad2e78b commit e8b8cd6

22 files changed

Lines changed: 732 additions & 21 deletions

‎.changeset/brave-bags-listen.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/swingset/src/components/DocsViewer.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
4848
button: dynamic(() => import('../stories/button.mdx')),
4949
card: dynamic(() => import('../stories/card.component.mdx')),
5050
input: dynamic(() => import('../stories/input.mdx')),
51+
'input-group': dynamic(() => import('../stories/input-group.mdx')),
5152
item: dynamic(() => import('../stories/item.mdx')),
5253
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
5354
heading: dynamic(() => import('../stories/heading.mdx')),

‎packages/swingset/src/components/StoryEmbed.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { CompositionPanel } from './Composition';
1818
interface StoryEmbedProps {
1919
name: string;
2020
storyModule: StoryModule;
21+
/** Whether to show the story's source footer. */
22+
showCode?: boolean;
2123
/** When provided, a collapsible "Composition" footer is attached to the example card. */
2224
composition?: CompositionPiece[];
2325
}
@@ -44,7 +46,7 @@ function CompositionFooter({ composition }: { composition: CompositionPiece[] })
4446
);
4547
}
4648

47-
export function StoryEmbed({ name, storyModule, composition }: StoryEmbedProps) {
49+
export function StoryEmbed({ name, storyModule, showCode = true, composition }: StoryEmbedProps) {
4850
const StoryComp = storyModule[name] as React.ComponentType<Record<string, unknown>>;
4951

5052
if (!StoryComp) {
@@ -56,7 +58,7 @@ export function StoryEmbed({ name, storyModule, composition }: StoryEmbedProps)
5658
// Present only for modules that expose `__source` (see `StoryModule.__source`). The raw
5759
// story function is a knob harness, so reduce it to a clean usage snippet for the footer.
5860
const rawSource = extractStorySource(storyModule.__source, name);
59-
const source = rawSource ? toUsageSnippet(rawSource) : null;
61+
const source = showCode && rawSource ? toUsageSnippet(rawSource) : null;
6062

6163
return (
6264
<div className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'>

‎packages/swingset/src/lib/registry.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ import {
7070
meta as inputMeta,
7171
Sizes as InputSizes,
7272
} from '../stories/input.stories';
73+
import {
74+
Default as InputGroupDefault,
75+
Disabled as InputGroupDisabled,
76+
Invalid as InputGroupInvalid,
77+
meta as inputGroupMeta,
78+
Sizes as InputGroupSizes,
79+
} from '../stories/input-group.stories';
7380
import {
7481
Default as ItemDefault,
7582
Group as ItemGroup,
@@ -271,6 +278,14 @@ const inputModule: StoryModule = {
271278
Headless: InputHeadless,
272279
};
273280

281+
const inputGroupModule: StoryModule = {
282+
meta: inputGroupMeta,
283+
Default: InputGroupDefault,
284+
Sizes: InputGroupSizes,
285+
Disabled: InputGroupDisabled,
286+
Invalid: InputGroupInvalid,
287+
};
288+
274289
const popoverComponentModule: StoryModule = {
275290
meta: popoverComponentMeta,
276291
Default: PopoverComponentDefault,
@@ -508,6 +523,7 @@ export const registry: StoryModule[] = [
508523
cardComponentModule,
509524
flowComponentModule,
510525
inputModule,
526+
inputGroupModule,
511527
itemModule,
512528
dialogComponentModule,
513529
headingModule,

‎packages/swingset/src/stories/button.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ both, with nothing to declare on the button itself.
9090

9191
The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
9292
`(height − icon size) / 2` of space above and below it; matching the inline side to that puts it
93-
in a square cell — 8px at every size, against 12px of text padding at `md` and `lg`, 10px at `sm`.
93+
in a square cell. Text padding steps from 8px at `xs`, to 10px at `sm`, and 12px at `md` and `lg`.
9494
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
9595
The middle button in each row below carries no icon, for comparison.
9696

‎packages/swingset/src/stories/button.stories.tsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const meta: StoryMeta = {
1717
_variants: {
1818
color: { primary: {}, neutral: {}, negative: {} },
1919
variant: { filled: {}, outline: {}, ghost: {}, link: {} },
20-
size: { sm: {}, md: {}, lg: {} },
20+
size: { xs: {}, sm: {}, md: {}, lg: {} },
2121
shape: { default: {}, square: {}, circle: {} },
2222
fullWidth: { true: {}, false: {} },
2323
touchTarget: { true: {}, false: {} },
@@ -46,6 +46,12 @@ export function Primary(props: Record<string, unknown>) {
4646
export function Sizes(props: Record<string, unknown>) {
4747
return (
4848
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
49+
<Button
50+
{...knobsAsProps(props)}
51+
size='xs'
52+
>
53+
Extra small
54+
</Button>
4955
<Button
5056
{...knobsAsProps(props)}
5157
size='sm'
@@ -218,7 +224,7 @@ export function Icons(props: Record<string, unknown>) {
218224
export function IconSizes(props: Record<string, unknown>) {
219225
return (
220226
<div style={{ display: 'grid', gap: 12, justifyItems: 'start' }}>
221-
{(['sm', 'md', 'lg'] as const).map(size => (
227+
{(['xs', 'sm', 'md', 'lg'] as const).map(size => (
222228
<div
223229
key={size}
224230
style={{ display: 'flex', gap: 8, alignItems: 'center' }}
@@ -230,7 +236,7 @@ export function IconSizes(props: Record<string, unknown>) {
230236
<Icon
231237
name='check'
232238
placement='inline-start'
233-
size={size}
239+
size={size === 'xs' ? 'sm' : size}
234240
/>
235241
Approve
236242
</Button>
@@ -248,7 +254,7 @@ export function IconSizes(props: Record<string, unknown>) {
248254
<Icon
249255
name='chevron-down'
250256
placement='inline-end'
251-
size={size}
257+
size={size === 'xs' ? 'sm' : size}
252258
/>
253259
</Button>
254260
</div>
@@ -388,7 +394,7 @@ export function SubmitDelay(props: Record<string, unknown>) {
388394
export function SubmitSizes(props: Record<string, unknown>) {
389395
return (
390396
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
391-
{(['sm', 'md', 'lg'] as const).map(size => (
397+
{(['xs', 'sm', 'md', 'lg'] as const).map(size => (
392398
<SubmitButton
393399
key={size}
394400
{...knobsAsProps(props)}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as InputGroupStories from './input-group.stories';
2+
3+
# InputGroup
4+
5+
`InputGroup` composes a ghost `Input` and supporting content inside one shared field surface.
6+
7+
## Playground
8+
9+
<Preview
10+
name='Default'
11+
storyModule={InputGroupStories}
12+
/>
13+
14+
## Props
15+
16+
<PropTable meta={InputGroupStories.meta} />
17+
18+
## Usage
19+
20+
```tsx
21+
import { InputGroup } from '@clerk/ui/mosaic/components/input-group';
22+
import { Input } from '@clerk/ui/mosaic/components/input';
23+
import { Icon } from '@clerk/ui/mosaic/components/icon';
24+
import { useState } from 'react';
25+
26+
function PasswordField() {
27+
const [visible, setVisible] = useState(false);
28+
29+
return (
30+
<InputGroup.Root>
31+
<Input variant='ghost' type={visible ? 'text' : 'password'} aria-label='Password' />
32+
<InputGroup.Action
33+
type='button'
34+
size='xs'
35+
shape='square'
36+
aria-label={visible ? 'Hide password' : 'Show password'}
37+
onClick={() => setVisible(value => !value)}
38+
>
39+
<Icon name={visible ? 'eye-slash' : 'eye'} size='sm' />
40+
</InputGroup.Action>
41+
</InputGroup.Root>
42+
);
43+
}
44+
```
45+
46+
---
47+
48+
## Examples
49+
50+
### Sizes
51+
52+
<Story
53+
name='Sizes'
54+
storyModule={InputGroupStories}
55+
showCode={false}
56+
/>
57+
58+
### Disabled
59+
60+
<Story
61+
name='Disabled'
62+
storyModule={InputGroupStories}
63+
/>
64+
65+
### Invalid
66+
67+
<Story
68+
name='Invalid'
69+
storyModule={InputGroupStories}
70+
/>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Field } from '@clerk/ui/mosaic/components/field';
2+
import { Icon } from '@clerk/ui/mosaic/components/icon';
3+
import { Input } from '@clerk/ui/mosaic/components/input';
4+
import type { InputGroupRootProps } from '@clerk/ui/mosaic/components/input-group';
5+
import { InputGroup } from '@clerk/ui/mosaic/components/input-group';
6+
import { useState } from 'react';
7+
8+
import type { StoryMeta } from '@/lib/types';
9+
10+
export { default as __source } from './input-group.stories?raw';
11+
12+
export const meta: StoryMeta = {
13+
group: 'Components',
14+
title: 'InputGroup',
15+
source: 'packages/ui/src/mosaic/components/input-group/input-group.tsx',
16+
styles: {
17+
_variants: {
18+
size: { sm: {}, md: {}, lg: {} },
19+
},
20+
_defaultVariants: {
21+
size: 'md',
22+
},
23+
},
24+
};
25+
26+
function knobsAsProps(props: Record<string, unknown>) {
27+
return props as unknown as InputGroupRootProps;
28+
}
29+
30+
export function Default(props: Record<string, unknown>) {
31+
const [visible, setVisible] = useState(false);
32+
const groupProps = knobsAsProps(props);
33+
34+
return (
35+
<Field.Root style={{ width: 320 }}>
36+
<Field.Label>Password</Field.Label>
37+
<InputGroup.Root {...groupProps}>
38+
<Input
39+
variant='ghost'
40+
type={visible ? 'text' : 'password'}
41+
placeholder='Enter password'
42+
autoComplete='current-password'
43+
/>
44+
<InputGroup.Action
45+
type='button'
46+
size='xs'
47+
shape='square'
48+
aria-label={visible ? 'Hide password' : 'Show password'}
49+
onClick={() => setVisible(value => !value)}
50+
>
51+
<Icon
52+
name={visible ? 'eye-slash' : 'eye'}
53+
size='sm'
54+
/>
55+
</InputGroup.Action>
56+
</InputGroup.Root>
57+
</Field.Root>
58+
);
59+
}
60+
61+
export function Sizes(props: Record<string, unknown>) {
62+
return (
63+
<div style={{ display: 'grid', gap: 8, width: 320 }}>
64+
{(['sm', 'md', 'lg'] as const).map(size => (
65+
<InputGroup.Root
66+
{...knobsAsProps(props)}
67+
key={size}
68+
size={size}
69+
>
70+
<Input
71+
variant='ghost'
72+
aria-label={`${size} email username`}
73+
placeholder='username'
74+
/>
75+
<InputGroup.Text>@acme.com</InputGroup.Text>
76+
</InputGroup.Root>
77+
))}
78+
</div>
79+
);
80+
}
81+
82+
export function Disabled(props: Record<string, unknown>) {
83+
return (
84+
<Field.Root
85+
disabled
86+
style={{ width: 320 }}
87+
>
88+
<Field.Label>Email address</Field.Label>
89+
<InputGroup.Root {...knobsAsProps(props)}>
90+
<Input
91+
variant='ghost'
92+
defaultValue='austin'
93+
/>
94+
<InputGroup.Text>@acme.com</InputGroup.Text>
95+
</InputGroup.Root>
96+
</Field.Root>
97+
);
98+
}
99+
100+
export function Invalid(props: Record<string, unknown>) {
101+
const [visible, setVisible] = useState(false);
102+
const groupProps = knobsAsProps(props);
103+
104+
return (
105+
<Field.Root
106+
invalid
107+
style={{ width: 320 }}
108+
>
109+
<Field.Label>Password</Field.Label>
110+
<InputGroup.Root {...groupProps}>
111+
<Input
112+
variant='ghost'
113+
type={visible ? 'text' : 'password'}
114+
defaultValue='short'
115+
autoComplete='new-password'
116+
/>
117+
<InputGroup.Action
118+
type='button'
119+
size='xs'
120+
shape='square'
121+
aria-label={visible ? 'Hide password' : 'Show password'}
122+
onClick={() => setVisible(value => !value)}
123+
>
124+
<Icon
125+
name={visible ? 'eye-slash' : 'eye'}
126+
size='sm'
127+
/>
128+
</InputGroup.Action>
129+
</InputGroup.Root>
130+
<Field.Error>Password must be at least 8 characters</Field.Error>
131+
</Field.Root>
132+
);
133+
}

‎packages/ui/src/mosaic/components/button/button.styles.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,12 +431,25 @@ export const variants = stylex.create({
431431
});
432432

433433
// size — height-driven; padding sets only the inline axis. An icon's side tightens to the inset
434-
// it already has above and below, `(height - icon) / 2`, so it sits in a square cell: 8px at md
435-
// and lg exactly, and at sm too, where the ideal 7px is off the 4px scale.
434+
// it already has above and below, `(height - icon) / 2`, so it sits in a square cell.
436435
//
437436
/* eslint-disable @stylexjs/no-lookahead-selectors -- every browser this package builds for
438437
supports `:has()` (`tsdown.mosaic.config.mts`); an older one keeps the untightened padding. */
439438
export const sizes = stylex.create({
439+
xs: {
440+
gap: space['1'],
441+
fontSize: typeScaleVars['--cl-text-xs-size'],
442+
lineHeight: typeScaleVars['--cl-text-xs-leading'],
443+
paddingInlineEnd: {
444+
default: space['2'],
445+
[stylex.when.descendant("[data-icon='inline-end']", iconScope)]: space['1.5'],
446+
},
447+
paddingInlineStart: {
448+
default: space['2'],
449+
[stylex.when.descendant("[data-icon='inline-start']", iconScope)]: space['1.5'],
450+
},
451+
height: space['6'],
452+
},
440453
sm: {
441454
// sm runs a step tighter than md on every inline measure, gap included
442455
gap: space['1.5'],
@@ -484,6 +497,7 @@ export const sizes = stylex.create({
484497
/* eslint-enable @stylexjs/no-lookahead-selectors */
485498

486499
export const iconSizes = stylex.create({
500+
xs: { width: space['6'] },
487501
sm: { width: space['7'] },
488502
md: { width: space['8'] },
489503
lg: { width: space['9'] },

‎packages/ui/src/mosaic/components/button/button.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe('Mosaic Button', () => {
9494
expect(screen.getByRole('button')).toHaveAttribute('data-variant', variant);
9595
});
9696

97-
it.each(['sm', 'md', 'lg'] as const)('reflects the %s size', size => {
97+
it.each(['xs', 'sm', 'md', 'lg'] as const)('reflects the %s size', size => {
9898
render(<Button size={size}>Hi</Button>);
9999
expect(screen.getByRole('button')).toHaveAttribute('data-size', size);
100100
});

0 commit comments

Comments
 (0)