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
4 changes: 4 additions & 0 deletions .claude/rules/emcn-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,7 @@ geometry only; colour, radius and SVG stroke continue to come from the selected
Use `shape='round'` for circular actions, or omit it to retain the current radius.
`size='inline'` is a 20px-high action with caption typography and compact horizontal
padding. Prefer these supported props to size, padding and radius overrides.

### Chip sizing and centered actions

`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.
4 changes: 4 additions & 0 deletions .cursor/rules/emcn-components.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,7 @@ geometry only; colour, radius and SVG stroke continue to come from the selected
Use `shape='round'` for circular actions, or omit it to retain the current radius.
`size='inline'` is a 20px-high action with caption typography and compact horizontal
padding. Prefer these supported props to size, padding and radius overrides.

### Chip sizing and centered actions

`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.
17 changes: 4 additions & 13 deletions apps/sim/app/(auth)/components/auth-input.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
'use client'

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

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

AuthInput.displayName = 'AuthInput'
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type LandingCtaSize = 'compact' | 'default' | 'display'

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

interface LandingCtaLinkProps extends Omit<ChipLinkProps, 'variant'> {
interface LandingCtaLinkProps extends Omit<ChipLinkProps, 'variant' | 'size'> {
size?: LandingCtaSize
variant?: 'primary' | 'outline'
/** Adds the animated chevron used by demo actions. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const VIEWER_MASK_LENGTH = 10

type SecretValueFieldProps = Omit<
ComponentProps<'input'>,
'type' | 'value' | 'onChange' | 'readOnly' | 'style'
'type' | 'value' | 'onChange' | 'readOnly' | 'style' | 'size'
> & {
/** The chip owns field styling; callers use className for layout. */
style?: never
Expand Down
17 changes: 15 additions & 2 deletions packages/emcn/src/components/chip-input/chip-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ afterEach(() => {
})

describe('ChipInput', () => {
it.each([undefined, 'lg'] as const)(
'emits a single height for size %s without forwarding it to the native field',
(size) => {
const input = mount(<ChipInput size={size} disabled aria-label='Search' />)
const heights = input.parentElement?.className
.split(' ')
.filter((token) => token.startsWith('h-'))
expect(heights).toEqual([size === 'lg' ? 'h-9' : 'h-[30px]'])
expect(input.hasAttribute('size')).toBe(false)
expect(input.disabled).toBe(true)
}
)

it('keeps the focused input mounted when custom leading content changes', () => {
const input = mount()
const render = (color: string) => (
Expand Down Expand Up @@ -76,7 +89,7 @@ describe('chip form controls', () => {
error
aria-invalid
aria-describedby='error'
className='h-[34px]'
size='lg'
/>
<p id='error'>Enter a work email</p>
<ChipTextarea
Expand All @@ -93,7 +106,7 @@ describe('chip form controls', () => {
expect(input.labels?.[0].textContent).toBe('Work email')
expect(input.getAttribute('aria-describedby')).toBe('error')
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(input.parentElement?.className).toContain('h-[34px]')
expect(input.parentElement?.className).toContain('h-9')
expect(input.parentElement?.className).toContain('border-[var(--text-error)]')
expect(textareaRef.current?.rows).toBe(3)
expect(textareaRef.current?.className).toContain('min-h-[80px]')
Expand Down
15 changes: 13 additions & 2 deletions packages/emcn/src/components/chip-input/chip-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,19 @@
*/
import * as React from 'react'
import { cn } from '../../lib/cn'
import { chipFieldSurfaceClass, chipFieldTextClass, chipGeometryClass } from '../chip/chip-chrome'
import {
chipContentGeometryClass,
chipFieldSurfaceClass,
chipFieldTextClass,
chipRadiusClass,
chipSizeClasses,
} from '../chip/chip-chrome'

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

export interface ChipInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
/** Control height: 30px by default, or the larger 36px auth spacing scale. */
size?: keyof typeof chipSizeClasses
/** Leading icon component (e.g. `Search` from `@sim/emcn/icons`). Rendered at 14px in `--text-icon`, with the chip's 1.5 gap. */
icon?: ChipInputIcon
/** Custom leading content, such as a color swatch. Takes precedence over `icon`. */
Expand Down Expand Up @@ -62,14 +70,17 @@ export const ChipInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
error,
disabled,
type = 'text',
size = 'md',
...props
},
ref
) => (
<div
className={cn(
'flex w-full',
chipGeometryClass,
chipContentGeometryClass,
chipRadiusClass,
chipSizeClasses[size],
chipFieldSurfaceClass,
error && 'border-[var(--text-error)]',
disabled && 'opacity-50',
Expand Down
6 changes: 5 additions & 1 deletion packages/emcn/src/components/chip/chip-chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,18 @@ export const chipContentGap = 'gap-1.5'

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

/**
* Chip pill geometry minus its corner radius — height, centering, gap, padding,
* text size. `chipVariants` composes this with its `shape` variant so a raw
* (non-`cn`) consumer never emits two competing radii; everything else reads
* {@link chipGeometryClass}, which adds the default radius back.
*/
export const chipGeometryUnroundedClass = `${chipHeightClass} items-center ${chipContentGap} px-2 text-left text-sm`
export const chipGeometryUnroundedClass = `${chipHeightClass} ${chipContentGeometryClass}`
/**
* Chip pill geometry — height, centering, gap, radius, padding, text size — with
* NO interactivity (no `cursor-pointer`, no hover). `chipVariants` composes this
Expand Down
93 changes: 93 additions & 0 deletions packages/emcn/src/components/chip/chip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/** @vitest-environment jsdom */
import { act, createRef } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { Chip, ChipLink, chipVariants } from './chip'
import { chipGeometryClass, chipGeometryUnroundedClass } from './chip-chrome'

describe('Chip geometry', () => {
it.each([undefined, 'lg'] as const)(
'uses one height for raw variants, buttons and links at size %s',
(size) => {
const height = size === 'lg' ? 'h-9' : 'h-[30px]'
expect(
chipVariants({ size })
.split(' ')
.filter((token) => token.startsWith('h-'))
).toEqual([height])
for (const node of [
<Chip key='button' size={size}>
Continue
</Chip>,
<ChipLink key='link' size={size} href='/workspace'>
Continue
</ChipLink>,
]) {
const markup = renderToStaticMarkup(node)
expect(markup).toContain(height)
expect(markup).not.toContain(size === 'lg' ? 'h-[30px]' : 'h-9')
expect(markup).not.toMatch(/ size=/)
}
expect(chipGeometryUnroundedClass).toContain('h-[30px]')
expect(chipGeometryClass).toContain('h-[30px]')
expect(chipGeometryClass).toContain('rounded-lg')
}
)

it('centers the icon and label without preventing long text from shrinking', () => {
const markup = renderToStaticMarkup(
<Chip fullWidth align='center' leftAdornment={<svg aria-hidden />}>
Continue with your identity provider
</Chip>
)
expect(markup).toContain('justify-center')
expect(markup).toContain('flex-initial')
expect(markup).toContain('min-w-0')
expect(markup).not.toContain('flex-none')
expect(markup).not.toMatch(/ align=/)
})

it('preserves native refs, focus, submission, disabled actions and link navigation', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const button = createRef<HTMLButtonElement>()
const link = createRef<HTMLAnchorElement>()
let submissions = 0
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const render = (disabled: boolean) =>
act(() =>
root.render(
<form
onSubmit={(event) => {
event.preventDefault()
submissions++
}}
>
<Chip ref={button} size='lg' align='center' fullWidth type='submit' disabled={disabled}>
<strong>Continue</strong>
</Chip>
<ChipLink ref={link} size='lg' align='center' href='/workspace'>
<strong>Workspace</strong>
</ChipLink>
</form>
)
)
try {
render(false)
button.current?.focus()
expect(document.activeElement).toBe(button.current)
act(() => button.current?.click())
expect(submissions).toBe(1)
expect(link.current?.getAttribute('href')).toBe('/workspace')
render(true)
act(() => button.current?.click())
expect(submissions).toBe(1)
expect(button.current?.disabled).toBe(true)
} finally {
act(() => root.unmount())
container.remove()
}
})
})
36 changes: 29 additions & 7 deletions packages/emcn/src/components/chip/chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-t
import {
chipActiveSurfaceClass,
chipBorderShadowRing,
chipContentGeometryClass,
chipContentIconClass,
chipContentLabelClass,
chipFilledFillTokens,
chipGeometryUnroundedClass,
chipHoverSurfaceClass,
chipPrimaryFillTokens,
chipRadiusClass,
chipSizeClasses,
} from './chip-chrome'

/**
* 30px pill — the platform's most common chrome pattern.
* 30px pill (36px with `size="lg"` at the default root font size) — the platform's most common chrome pattern.
*
* Render targets:
* - {@link Chip} → `<button>`
Expand All @@ -41,6 +42,7 @@ import {
* `border` (the `border-shadow` shadow ring on a transparent surface — an outline drawn purely via box-shadow,
* no CSS border, no fill); outline (a true border with no shadow or hover fill).
* `active` renders the default/filled chip in its selected state — `--surface-active`, held through hover.
* `align="center"` centers the complete icon/label group while allowing long labels to shrink.
* `fullWidth` swaps `inline-flex` for block-level `flex`.
* `shape` picks the corner radius: the implicit `default` is the `rounded-lg` pill; `round` is fully round
* (`rounded-full`) for a chip sitting in a row of round controls. The radius lives in this variant rather than
Expand All @@ -60,7 +62,7 @@ import {
* {@link chipHoverSurfaceClass}.
*/
const chipVariants = cva(
`group cursor-pointer ${chipGeometryUnroundedClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
`group cursor-pointer ${chipContentGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
{
variants: {
variant: {
Expand All @@ -74,6 +76,8 @@ const chipVariants = cva(
outline:
'border border-[var(--border)] bg-transparent hover-hover:border-[color-mix(in_srgb,var(--border)_80%,var(--text-secondary))]',
},
size: chipSizeClasses,
align: { start: '', center: 'justify-center' },
shape: { default: chipRadiusClass, round: 'rounded-full' },
active: { true: '', false: '' },
fullWidth: { true: 'flex w-full', false: 'inline-flex' },
Expand All @@ -82,7 +86,14 @@ const chipVariants = cva(
{ variant: ['default', 'filled'], active: false, className: chipHoverSurfaceClass },
{ variant: ['default', 'filled'], active: true, className: chipActiveSurfaceClass },
],
defaultVariants: { variant: 'default', shape: 'default', active: false, fullWidth: false },
defaultVariants: {
variant: 'default',
size: 'md',
align: 'start',
shape: 'default',
active: false,
fullWidth: false,
},
}
)

Expand Down Expand Up @@ -116,6 +127,7 @@ interface ChipBaseProps extends Omit<VariantProps<typeof chipVariants>, 'variant
*/
function ChipContent({
variant,
align,
leftIcon: LeftIcon,
leftAdornment,
rightIcon: RightIcon,
Expand All @@ -124,7 +136,11 @@ function ChipContent({
}: ChipBaseProps) {
const isInverse = variant === 'primary' || variant === 'destructive'
const iconClass = cn(chipContentIconClass, isInverse && 'text-current')
const labelClass = cn(chipContentLabelClass, 'flex-1', isInverse && 'text-current')
const labelClass = cn(
chipContentLabelClass,
align === 'center' ? 'flex-initial' : 'flex-1',
isInverse && 'text-current'
)
const textLabel =
typeof children === 'string' || typeof children === 'number' ? String(children) : null
return (
Expand All @@ -151,6 +167,8 @@ const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
{
className,
variant,
size,
align,
shape,
active,
fullWidth,
Expand All @@ -168,11 +186,12 @@ const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
<button
ref={ref}
type={type ?? 'button'}
className={cn(chipVariants({ variant, shape, active, fullWidth }), className)}
className={cn(chipVariants({ variant, size, align, shape, active, fullWidth }), className)}
{...props}
>
<ChipContent
variant={variant}
align={align}
leftIcon={leftIcon}
leftAdornment={leftAdornment}
rightIcon={rightIcon}
Expand All @@ -196,6 +215,8 @@ const ChipLink = forwardRef<HTMLAnchorElement, ChipLinkProps>(function ChipLink(
{
className,
variant,
size,
align,
shape,
active,
fullWidth,
Expand All @@ -211,11 +232,12 @@ const ChipLink = forwardRef<HTMLAnchorElement, ChipLinkProps>(function ChipLink(
return (
<Link
ref={ref}
className={cn(chipVariants({ variant, shape, active, fullWidth }), className)}
className={cn(chipVariants({ variant, size, align, shape, active, fullWidth }), className)}
{...props}
>
<ChipContent
variant={variant}
align={align}
leftIcon={leftIcon}
leftAdornment={leftAdornment}
rightIcon={rightIcon}
Expand Down
Loading