Skip to content

Commit 7eb9cc4

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
improvement(emcn): share larger chip and field geometry
1 parent 81e3110 commit 7eb9cc4

10 files changed

Lines changed: 169 additions & 27 deletions

File tree

‎.claude/rules/emcn-components.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,7 @@ geometry only; colour, radius and SVG stroke continue to come from the selected
7070
Use `shape='round'` for circular actions, or omit it to retain the current radius.
7171
`size='inline'` is a 20px-high action with caption typography and compact horizontal
7272
padding. Prefer these supported props to size, padding and radius overrides.
73+
74+
### Chip sizing and centered actions
75+
76+
`Chip`, `ChipLink`, and `ChipInput` retain their default 30px height. Use `size="lg"` for the existing auth-scale 36px controls; the larger size follows the root font size. `Chip` and `ChipLink` accept `align="center"` to center their icon/label group, including full-width form actions. Long labels still shrink and retain their overflow treatment. Avoid descendant flex overrides to center chip labels. The default geometry exports remain unchanged for static consumers.

‎.cursor/rules/emcn-components.mdc‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,7 @@ geometry only; colour, radius and SVG stroke continue to come from the selected
7171
Use `shape='round'` for circular actions, or omit it to retain the current radius.
7272
`size='inline'` is a 20px-high action with caption typography and compact horizontal
7373
padding. Prefer these supported props to size, padding and radius overrides.
74+
75+
### Chip sizing and centered actions
76+
77+
`Chip`, `ChipLink`, and `ChipInput` retain their default 30px height. Use `size="lg"` for the existing auth-scale 36px controls; the larger size follows the root font size. `Chip` and `ChipLink` accept `align="center"` to center their icon/label group, including full-width form actions. Long labels still shrink and retain their overflow treatment. Avoid descendant flex overrides to center chip labels. The default geometry exports remain unchanged for static consumers.
Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
'use client'
22

33
import * as React from 'react'
4-
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
5-
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'
4+
import { ChipInput, type ChipInputProps } from '@sim/emcn'
65

7-
/**
8-
* The auth text field — a {@link ChipInput} raised to the auth control height
9-
* ({@link AUTH_CONTROL_HEIGHT}) so every labeled field on the auth and invite
10-
* surfaces shares one slightly-taller geometry. All chip props pass through
11-
* (`error`, `endAdornment`, `icon`, …); only the height is owned here, and a
12-
* caller's `className` (layout only) still composes on top.
13-
*/
14-
export const AuthInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
15-
({ className, ...props }, ref) => (
16-
<ChipInput ref={ref} className={cn(AUTH_CONTROL_HEIGHT, className)} {...props} />
17-
)
6+
/** Auth fields use the larger shared chip size while retaining native input props and refs. */
7+
export const AuthInput = React.forwardRef<HTMLInputElement, Omit<ChipInputProps, 'size'>>(
8+
(props, ref) => <ChipInput {...props} ref={ref} size='lg' />
189
)
1910

2011
AuthInput.displayName = 'AuthInput'

‎apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type LandingCtaSize = 'compact' | 'default' | 'display'
99

1010
export type LandingCtaSection = PostHogEventMap['landing_cta_clicked']['section']
1111

12-
interface LandingCtaLinkProps extends Omit<ChipLinkProps, 'variant'> {
12+
interface LandingCtaLinkProps extends Omit<ChipLinkProps, 'variant' | 'size'> {
1313
size?: LandingCtaSize
1414
variant?: 'primary' | 'outline'
1515
/** Adds the animated chevron used by demo actions. */

‎apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const VIEWER_MASK_LENGTH = 10
1111

1212
type SecretValueFieldProps = Omit<
1313
ComponentProps<'input'>,
14-
'type' | 'value' | 'onChange' | 'readOnly' | 'style'
14+
'type' | 'value' | 'onChange' | 'readOnly' | 'style' | 'size'
1515
> & {
1616
/** The chip owns field styling; callers use className for layout. */
1717
style?: never

‎packages/emcn/src/components/chip-input/chip-input.test.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ afterEach(() => {
3030
})
3131

3232
describe('ChipInput', () => {
33+
it.each([undefined, 'lg'] as const)(
34+
'emits a single height for size %s without forwarding it to the native field',
35+
(size) => {
36+
const input = mount(<ChipInput size={size} disabled aria-label='Search' />)
37+
const heights = input.parentElement?.className
38+
.split(' ')
39+
.filter((token) => token.startsWith('h-'))
40+
expect(heights).toEqual([size === 'lg' ? 'h-9' : 'h-[30px]'])
41+
expect(input.hasAttribute('size')).toBe(false)
42+
expect(input.disabled).toBe(true)
43+
}
44+
)
45+
3346
it('keeps the focused input mounted when custom leading content changes', () => {
3447
const input = mount()
3548
const render = (color: string) => (
@@ -76,7 +89,7 @@ describe('chip form controls', () => {
7689
error
7790
aria-invalid
7891
aria-describedby='error'
79-
className='h-[34px]'
92+
size='lg'
8093
/>
8194
<p id='error'>Enter a work email</p>
8295
<ChipTextarea
@@ -93,7 +106,7 @@ describe('chip form controls', () => {
93106
expect(input.labels?.[0].textContent).toBe('Work email')
94107
expect(input.getAttribute('aria-describedby')).toBe('error')
95108
expect(input.getAttribute('aria-invalid')).toBe('true')
96-
expect(input.parentElement?.className).toContain('h-[34px]')
109+
expect(input.parentElement?.className).toContain('h-9')
97110
expect(input.parentElement?.className).toContain('border-[var(--text-error)]')
98111
expect(textareaRef.current?.rows).toBe(3)
99112
expect(textareaRef.current?.className).toContain('min-h-[80px]')

‎packages/emcn/src/components/chip-input/chip-input.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,19 @@
2828
*/
2929
import * as React from 'react'
3030
import { cn } from '../../lib/cn'
31-
import { chipFieldSurfaceClass, chipFieldTextClass, chipGeometryClass } from '../chip/chip-chrome'
31+
import {
32+
chipContentGeometryClass,
33+
chipFieldSurfaceClass,
34+
chipFieldTextClass,
35+
chipRadiusClass,
36+
chipSizeClasses,
37+
} from '../chip/chip-chrome'
3238

3339
type ChipInputIcon = React.ComponentType<{ className?: string }>
3440

3541
export interface ChipInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
42+
/** Control height: 30px by default, or the larger 36px auth spacing scale. */
43+
size?: keyof typeof chipSizeClasses
3644
/** Leading icon component (e.g. `Search` from `@sim/emcn/icons`). Rendered at 14px in `--text-icon`, with the chip's 1.5 gap. */
3745
icon?: ChipInputIcon
3846
/** Custom leading content, such as a color swatch. Takes precedence over `icon`. */
@@ -62,14 +70,17 @@ export const ChipInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
6270
error,
6371
disabled,
6472
type = 'text',
73+
size = 'md',
6574
...props
6675
},
6776
ref
6877
) => (
6978
<div
7079
className={cn(
7180
'flex w-full',
72-
chipGeometryClass,
81+
chipContentGeometryClass,
82+
chipRadiusClass,
83+
chipSizeClasses[size],
7384
chipFieldSurfaceClass,
7485
error && 'border-[var(--text-error)]',
7586
disabled && 'opacity-50',

‎packages/emcn/src/components/chip/chip-chrome.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,18 @@ export const chipContentGap = 'gap-1.5'
5151

5252
/** Standard chip height, also shared by combobox fields. */
5353
export const chipHeightClass = 'h-[30px]'
54+
/** Shared control heights. Large controls follow the auth spacing scale. */
55+
export const chipSizeClasses = { md: chipHeightClass, lg: 'h-9' } as const
56+
/** Chip content geometry without height or radius, for sized controls. */
57+
export const chipContentGeometryClass = `items-center ${chipContentGap} px-2 text-left text-sm`
5458

5559
/**
5660
* Chip pill geometry minus its corner radius — height, centering, gap, padding,
5761
* text size. `chipVariants` composes this with its `shape` variant so a raw
5862
* (non-`cn`) consumer never emits two competing radii; everything else reads
5963
* {@link chipGeometryClass}, which adds the default radius back.
6064
*/
61-
export const chipGeometryUnroundedClass = `${chipHeightClass} items-center ${chipContentGap} px-2 text-left text-sm`
65+
export const chipGeometryUnroundedClass = `${chipHeightClass} ${chipContentGeometryClass}`
6266
/**
6367
* Chip pill geometry — height, centering, gap, radius, padding, text size — with
6468
* NO interactivity (no `cursor-pointer`, no hover). `chipVariants` composes this
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/** @vitest-environment jsdom */
2+
import { act, createRef } from 'react'
3+
import { createRoot } from 'react-dom/client'
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
import { describe, expect, it } from 'vitest'
6+
import { Chip, ChipLink, chipVariants } from './chip'
7+
import { chipGeometryClass, chipGeometryUnroundedClass } from './chip-chrome'
8+
9+
describe('Chip geometry', () => {
10+
it.each([undefined, 'lg'] as const)(
11+
'uses one height for raw variants, buttons and links at size %s',
12+
(size) => {
13+
const height = size === 'lg' ? 'h-9' : 'h-[30px]'
14+
expect(
15+
chipVariants({ size })
16+
.split(' ')
17+
.filter((token) => token.startsWith('h-'))
18+
).toEqual([height])
19+
for (const node of [
20+
<Chip key='button' size={size}>
21+
Continue
22+
</Chip>,
23+
<ChipLink key='link' size={size} href='/workspace'>
24+
Continue
25+
</ChipLink>,
26+
]) {
27+
const markup = renderToStaticMarkup(node)
28+
expect(markup).toContain(height)
29+
expect(markup).not.toContain(size === 'lg' ? 'h-[30px]' : 'h-9')
30+
expect(markup).not.toMatch(/ size=/)
31+
}
32+
expect(chipGeometryUnroundedClass).toContain('h-[30px]')
33+
expect(chipGeometryClass).toContain('h-[30px]')
34+
expect(chipGeometryClass).toContain('rounded-lg')
35+
}
36+
)
37+
38+
it('centers the icon and label without preventing long text from shrinking', () => {
39+
const markup = renderToStaticMarkup(
40+
<Chip fullWidth align='center' leftAdornment={<svg aria-hidden />}>
41+
Continue with your identity provider
42+
</Chip>
43+
)
44+
expect(markup).toContain('justify-center')
45+
expect(markup).toContain('flex-initial')
46+
expect(markup).toContain('min-w-0')
47+
expect(markup).not.toContain('flex-none')
48+
expect(markup).not.toMatch(/ align=/)
49+
})
50+
51+
it('preserves native refs, focus, submission, disabled actions and link navigation', () => {
52+
const container = document.createElement('div')
53+
document.body.appendChild(container)
54+
const root = createRoot(container)
55+
const button = createRef<HTMLButtonElement>()
56+
const link = createRef<HTMLAnchorElement>()
57+
let submissions = 0
58+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
59+
const render = (disabled: boolean) =>
60+
act(() =>
61+
root.render(
62+
<form
63+
onSubmit={(event) => {
64+
event.preventDefault()
65+
submissions++
66+
}}
67+
>
68+
<Chip ref={button} size='lg' align='center' fullWidth type='submit' disabled={disabled}>
69+
<strong>Continue</strong>
70+
</Chip>
71+
<ChipLink ref={link} size='lg' align='center' href='/workspace'>
72+
<strong>Workspace</strong>
73+
</ChipLink>
74+
</form>
75+
)
76+
)
77+
try {
78+
render(false)
79+
button.current?.focus()
80+
expect(document.activeElement).toBe(button.current)
81+
act(() => button.current?.click())
82+
expect(submissions).toBe(1)
83+
expect(link.current?.getAttribute('href')).toBe('/workspace')
84+
render(true)
85+
act(() => button.current?.click())
86+
expect(submissions).toBe(1)
87+
expect(button.current?.disabled).toBe(true)
88+
} finally {
89+
act(() => root.unmount())
90+
container.remove()
91+
}
92+
})
93+
})

‎packages/emcn/src/components/chip/chip.tsx‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,18 @@ import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-t
1414
import {
1515
chipActiveSurfaceClass,
1616
chipBorderShadowRing,
17+
chipContentGeometryClass,
1718
chipContentIconClass,
1819
chipContentLabelClass,
1920
chipFilledFillTokens,
20-
chipGeometryUnroundedClass,
2121
chipHoverSurfaceClass,
2222
chipPrimaryFillTokens,
2323
chipRadiusClass,
24+
chipSizeClasses,
2425
} from './chip-chrome'
2526

2627
/**
27-
* 30px pill — the platform's most common chrome pattern.
28+
* 30px pill (36px with `size="lg"` at the default root font size) — the platform's most common chrome pattern.
2829
*
2930
* Render targets:
3031
* - {@link Chip} → `<button>`
@@ -41,6 +42,7 @@ import {
4142
* `border` (the `border-shadow` shadow ring on a transparent surface — an outline drawn purely via box-shadow,
4243
* no CSS border, no fill); outline (a true border with no shadow or hover fill).
4344
* `active` renders the default/filled chip in its selected state — `--surface-active`, held through hover.
45+
* `align="center"` centers the complete icon/label group while allowing long labels to shrink.
4446
* `fullWidth` swaps `inline-flex` for block-level `flex`.
4547
* `shape` picks the corner radius: the implicit `default` is the `rounded-lg` pill; `round` is fully round
4648
* (`rounded-full`) for a chip sitting in a row of round controls. The radius lives in this variant rather than
@@ -60,7 +62,7 @@ import {
6062
* {@link chipHoverSurfaceClass}.
6163
*/
6264
const chipVariants = cva(
63-
`group cursor-pointer ${chipGeometryUnroundedClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
65+
`group cursor-pointer ${chipContentGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
6466
{
6567
variants: {
6668
variant: {
@@ -74,6 +76,8 @@ const chipVariants = cva(
7476
outline:
7577
'border border-[var(--border)] bg-transparent hover-hover:border-[color-mix(in_srgb,var(--border)_80%,var(--text-secondary))]',
7678
},
79+
size: chipSizeClasses,
80+
align: { start: '', center: 'justify-center' },
7781
shape: { default: chipRadiusClass, round: 'rounded-full' },
7882
active: { true: '', false: '' },
7983
fullWidth: { true: 'flex w-full', false: 'inline-flex' },
@@ -82,7 +86,14 @@ const chipVariants = cva(
8286
{ variant: ['default', 'filled'], active: false, className: chipHoverSurfaceClass },
8387
{ variant: ['default', 'filled'], active: true, className: chipActiveSurfaceClass },
8488
],
85-
defaultVariants: { variant: 'default', shape: 'default', active: false, fullWidth: false },
89+
defaultVariants: {
90+
variant: 'default',
91+
size: 'md',
92+
align: 'start',
93+
shape: 'default',
94+
active: false,
95+
fullWidth: false,
96+
},
8697
}
8798
)
8899

@@ -116,6 +127,7 @@ interface ChipBaseProps extends Omit<VariantProps<typeof chipVariants>, 'variant
116127
*/
117128
function ChipContent({
118129
variant,
130+
align,
119131
leftIcon: LeftIcon,
120132
leftAdornment,
121133
rightIcon: RightIcon,
@@ -124,7 +136,11 @@ function ChipContent({
124136
}: ChipBaseProps) {
125137
const isInverse = variant === 'primary' || variant === 'destructive'
126138
const iconClass = cn(chipContentIconClass, isInverse && 'text-current')
127-
const labelClass = cn(chipContentLabelClass, 'flex-1', isInverse && 'text-current')
139+
const labelClass = cn(
140+
chipContentLabelClass,
141+
align === 'center' ? 'flex-initial' : 'flex-1',
142+
isInverse && 'text-current'
143+
)
128144
const textLabel =
129145
typeof children === 'string' || typeof children === 'number' ? String(children) : null
130146
return (
@@ -151,6 +167,8 @@ const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
151167
{
152168
className,
153169
variant,
170+
size,
171+
align,
154172
shape,
155173
active,
156174
fullWidth,
@@ -168,11 +186,12 @@ const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
168186
<button
169187
ref={ref}
170188
type={type ?? 'button'}
171-
className={cn(chipVariants({ variant, shape, active, fullWidth }), className)}
189+
className={cn(chipVariants({ variant, size, align, shape, active, fullWidth }), className)}
172190
{...props}
173191
>
174192
<ChipContent
175193
variant={variant}
194+
align={align}
176195
leftIcon={leftIcon}
177196
leftAdornment={leftAdornment}
178197
rightIcon={rightIcon}
@@ -196,6 +215,8 @@ const ChipLink = forwardRef<HTMLAnchorElement, ChipLinkProps>(function ChipLink(
196215
{
197216
className,
198217
variant,
218+
size,
219+
align,
199220
shape,
200221
active,
201222
fullWidth,
@@ -211,11 +232,12 @@ const ChipLink = forwardRef<HTMLAnchorElement, ChipLinkProps>(function ChipLink(
211232
return (
212233
<Link
213234
ref={ref}
214-
className={cn(chipVariants({ variant, shape, active, fullWidth }), className)}
235+
className={cn(chipVariants({ variant, size, align, shape, active, fullWidth }), className)}
215236
{...props}
216237
>
217238
<ChipContent
218239
variant={variant}
240+
align={align}
219241
leftIcon={leftIcon}
220242
leftAdornment={leftAdornment}
221243
rightIcon={rightIcon}

0 commit comments

Comments
 (0)