Skip to content

Commit 1d252e5

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
improvement(emcn): share ordinary action geometry in Button (#8149)
* improvement(emcn): share ordinary action geometry in Button * improvement(ui): use inline Button for small text actions (#8177) Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> * fix(emcn): assert responsive icon size map as const --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 13f85d8 commit 1d252e5

10 files changed

Lines changed: 159 additions & 20 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,17 @@ Declare keyboard intent on the action-owning primitive; never add document-level
5656
- Use Radix UI primitives for accessibility. Export the component and its `variants` (when using CVA). Document with TSDoc + a usage example.
5757

5858
Color tokens and icon-size conventions are canonical in `.claude/rules/sim-styling.md` — follow it rather than restating.
59+
60+
61+
## Ordinary Button action geometry
62+
63+
`Button` retains its existing appearance variants. For square actions use `iconSize`:
64+
`compact` (24px on the spacing scale), `compact-fixed` (24px), `regular` (28px),
65+
`roomy` (32px), or `touch` (40px). These values follow the root spacing scale;
66+
only `compact-fixed` stays fixed when root text is enlarged.
67+
Use `{ base: 'touch', sm: 'regular' }` for mobile/desktop targets. These props own
68+
geometry only; colour, radius and SVG stroke continue to come from the selected
69+
`variant` and `size`. `iconPadding` explicitly overrides the zero-padding geometry.
70+
Use `shape='round'` for circular actions, or omit it to retain the current radius.
71+
`size='inline'` is a 20px-high action with caption typography and compact horizontal
72+
padding. Prefer these supported props to size, padding and radius overrides.

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,17 @@ Declare keyboard intent on the action-owning primitive; never add document-level
5757
- Use Radix UI primitives for accessibility. Export the component and its `variants` (when using CVA). Document with TSDoc + a usage example.
5858

5959
Color tokens and icon-size conventions are canonical in `.claude/rules/sim-styling.md` — follow it rather than restating.
60+
61+
62+
## Ordinary Button action geometry
63+
64+
`Button` retains its existing appearance variants. For square actions use `iconSize`:
65+
`compact` (24px on the spacing scale), `compact-fixed` (24px), `regular` (28px),
66+
`roomy` (32px), or `touch` (40px). These values follow the root spacing scale;
67+
only `compact-fixed` stays fixed when root text is enlarged.
68+
Use `{ base: 'touch', sm: 'regular' }` for mobile/desktop targets. These props own
69+
geometry only; colour, radius and SVG stroke continue to come from the selected
70+
`variant` and `size`. `iconPadding` explicitly overrides the zero-padding geometry.
71+
Use `shape='round'` for circular actions, or omit it to retain the current radius.
72+
`size='inline'` is a 20px-high action with caption typography and compact horizontal
73+
padding. Prefer these supported props to size, padding and radius overrides.

‎apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,7 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
106106
{file.type}
107107
{file.version === undefined ? '' : ` · v${file.version}`}
108108
</span>
109-
<Button
110-
variant='ghost'
111-
className='h-[20px]! px-1.5! py-0! text-xs'
112-
onClick={handleDownload}
113-
>
109+
<Button variant='ghost' size='inline' onClick={handleDownload}>
114110
<Download className='mr-1 size-[10px]' />
115111
Download
116112
</Button>

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-options-editor.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
7979
size='sm'
8080
onClick={() => remove(option.id)}
8181
iconPadding='sm'
82-
className='size-7 shrink-0'
82+
iconSize='regular'
83+
className='shrink-0'
8384
aria-label={`Remove ${option.name || 'option'}`}
8485
>
8586
<X className='size-[12px]' />

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,8 @@ const FilterRuleRow = memo(function FilterRuleRow({
383383
size='sm'
384384
onClick={() => onRemove(rule.id)}
385385
iconPadding='sm'
386-
className='size-7 shrink-0'
386+
iconSize='regular'
387+
className='shrink-0'
387388
aria-label='Remove filter'
388389
>
389390
<X className='size-[12px]' />

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,8 @@ export const AddRowButton = React.memo(function AddRowButton({
7272
<Button
7373
type='button'
7474
variant='ghost'
75-
size='sm'
76-
className={cn(
77-
'h-[20px] gap-2 p-0 text-[var(--text-body)]',
78-
blockedReason && 'cursor-not-allowed opacity-50'
79-
)}
75+
size='inline'
76+
className={cn('gap-2', blockedReason && 'cursor-not-allowed opacity-50')}
8077
aria-disabled={blockedReason ? true : undefined}
8178
onClick={blockedReason ? undefined : onClick}
8279
>

‎packages/emcn/src/components/bulk-action-button/bulk-action-button.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const bulkActionButtonVariants = cva(
1919
)
2020

2121
export interface BulkActionButtonProps
22-
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize'> {
22+
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize' | 'shape'> {
2323
/** Accessible name for the icon action; tooltip content is supplied separately. */
2424
'aria-label': string
2525
/**

‎packages/emcn/src/components/button/button.test.tsx‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
/** @vitest-environment node */
1+
/** @vitest-environment jsdom */
22
/** biome-ignore assist/source/organizeImports: Preserve the documented core/external/UI import order. */
3+
import { act, createRef } from 'react'
4+
import { createRoot } from 'react-dom/client'
35
import { renderToStaticMarkup } from 'react-dom/server'
46
import { describe, expect, it } from 'vitest'
57
import { Button } from '@sim/emcn'
@@ -22,6 +24,9 @@ describe('Button iconSize', () => {
2224
for (const [iconSize, previousClass] of [
2325
['compact', 'size-6 p-0'],
2426
['compact-fixed', 'size-[24px] p-0'],
27+
['regular', 'size-7 p-0'],
28+
['roomy', 'size-8 p-0'],
29+
['touch', 'size-10 p-0'],
2530
] as const) {
2631
it.each(TREATMENTS)(
2732
`preserves the ${iconSize} treatment with size=$size and variant=$variant`,
@@ -65,3 +70,82 @@ describe('Button iconSize', () => {
6570
expect(markup).not.toContain('iconPadding')
6671
})
6772
})
73+
74+
describe('Button shared action geometry', () => {
75+
it('composes responsive geometry, explicit padding and round shape without changing icon treatment', () => {
76+
const markup = renderToStaticMarkup(
77+
<Button
78+
variant='ghost'
79+
size='icon'
80+
iconSize={{ base: 'touch', sm: 'regular' }}
81+
iconPadding='sm'
82+
shape='round'
83+
aria-label='Edit'
84+
>
85+
<svg strokeWidth={1.55} />
86+
</Button>
87+
)
88+
expect(markup).toContain('size-10')
89+
expect(markup).toContain('sm:size-7')
90+
expect(markup).not.toContain('size-[20px]')
91+
expect(markup).toContain('p-1')
92+
expect(markup).not.toContain('p-0')
93+
expect(markup).toContain('rounded-full')
94+
expect(markup).not.toContain('rounded-sm')
95+
expect(markup).toContain('[stroke-width:1.25]')
96+
expect(markup).toContain('text-[var(--text-icon-muted)]')
97+
expect(markup).not.toMatch(/(?:iconSize|iconPadding|shape)=/)
98+
})
99+
100+
it('retains an inline caption size and supports a base-only responsive value', () => {
101+
const inline = renderToStaticMarkup(<Button size='inline'>Generate</Button>)
102+
expect(inline).toContain('h-[20px]')
103+
expect(inline).toContain('text-caption')
104+
expect(inline).toContain('px-1.5 py-0')
105+
const baseOnly = renderToStaticMarkup(<Button iconSize={{ base: 'roomy' }} aria-label='Run' />)
106+
expect(baseOnly).toContain('size-8')
107+
expect(baseOnly).not.toContain('sm:size')
108+
})
109+
110+
it('forwards refs and native focus, submission and disabled behavior with responsive sizing', () => {
111+
const container = document.createElement('div')
112+
document.body.appendChild(container)
113+
const root = createRoot(container)
114+
const ref = createRef<HTMLButtonElement>()
115+
let submissions = 0
116+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
117+
const render = (disabled: boolean) =>
118+
act(() =>
119+
root.render(
120+
<form
121+
onSubmit={(event) => {
122+
event.preventDefault()
123+
submissions++
124+
}}
125+
>
126+
<Button
127+
ref={ref}
128+
type='submit'
129+
iconSize={{ base: 'touch', sm: 'regular' }}
130+
disabled={disabled}
131+
aria-label='Apply'
132+
/>
133+
</form>
134+
)
135+
)
136+
try {
137+
render(false)
138+
ref.current?.focus()
139+
expect(document.activeElement).toBe(ref.current)
140+
act(() => ref.current?.click())
141+
expect(submissions).toBe(1)
142+
render(true)
143+
act(() => ref.current?.click())
144+
expect(submissions).toBe(1)
145+
expect(ref.current?.disabled).toBe(true)
146+
} finally {
147+
act(() => root.unmount())
148+
container.remove()
149+
}
150+
})
151+
})

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,17 @@ const buttonVariants = cva(
4949
sm: 'px-1.5 py-1 text-[length:11px]',
5050
md: 'px-2 py-1.5 text-[length:12px]',
5151
icon: 'size-[20px] rounded-sm p-0 [&_svg]:[stroke-width:1.25]',
52+
inline: 'h-[20px] px-1.5 py-0 text-caption',
5253
},
5354
iconSize: {
5455
compact: 'size-6 p-0',
5556
'compact-fixed': 'size-[24px] p-0',
57+
regular: 'size-7 p-0',
58+
roomy: 'size-8 p-0',
59+
touch: 'size-10 p-0',
60+
},
61+
shape: {
62+
round: 'rounded-full',
5663
},
5764
iconPadding: {
5865
sm: 'p-1',
@@ -76,18 +83,31 @@ const buttonVariants = cva(
7683
}
7784
)
7885

86+
type ButtonIconSize = NonNullable<VariantProps<typeof buttonVariants>['iconSize']>
87+
88+
const responsiveIconSizes = {
89+
compact: 'sm:size-6',
90+
'compact-fixed': 'sm:size-[24px]',
91+
regular: 'sm:size-7',
92+
roomy: 'sm:size-8',
93+
touch: 'sm:size-10',
94+
} as const satisfies Record<ButtonIconSize, string>
95+
7996
export interface ButtonProps
8097
extends ButtonHTMLAttributes<HTMLButtonElement>,
81-
VariantProps<typeof buttonVariants> {
98+
Omit<VariantProps<typeof buttonVariants>, 'iconSize'> {
8299
/**
83100
* Square icon-action geometry without changing the selected size's typography,
84101
* corner radius, icon stroke or color. `compact` follows the spacing scale
85102
* (24px at the default root font size); `compact-fixed` stays at 24px.
86-
* Both remove padding; an explicit iconPadding or className can override it.
103+
* Regular, roomy and touch follow the spacing scale (28px, 32px and 40px
104+
* at the default root font size). A responsive
105+
* value changes geometry at the standard sm breakpoint. All remove padding;
106+
* explicit iconPadding or className can override it.
87107
* Omit to retain the selected size's geometry.
88108
* @example <Button variant='ghost' iconSize='compact' aria-label='Remove'><X /></Button>
89109
*/
90-
iconSize?: VariantProps<typeof buttonVariants>['iconSize']
110+
iconSize?: ButtonIconSize | { base: ButtonIconSize; sm?: ButtonIconSize } | null
91111
/**
92112
* Symmetric padding for icon actions whose content or layout determines their size.
93113
* Preserves the selected size's typography, corner radius and icon stroke.
@@ -98,11 +118,23 @@ export interface ButtonProps
98118
}
99119

100120
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
101-
({ className, variant, size, iconSize, iconPadding, ...props }, ref) => {
121+
({ className, variant, size, iconSize, iconPadding, shape, ...props }, ref) => {
122+
const baseIconSize = typeof iconSize === 'object' ? iconSize?.base : iconSize
123+
const smIconSize = typeof iconSize === 'object' ? iconSize?.sm : undefined
102124
return (
103125
<button
104126
ref={ref}
105-
className={cn(buttonVariants({ variant, size, iconSize, iconPadding }), className)}
127+
className={cn(
128+
buttonVariants({
129+
variant,
130+
size,
131+
iconSize: baseIconSize,
132+
iconPadding,
133+
shape,
134+
}),
135+
smIconSize && responsiveIconSizes[smIconSize],
136+
className
137+
)}
106138
{...props}
107139
/>
108140
)

‎packages/emcn/src/components/composer-action-button/composer-action-button.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const composerActionButtonVariants = cva('rounded-full p-0', {
2727
})
2828

2929
export interface ComposerActionButtonProps
30-
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize'> {
30+
extends Omit<ButtonProps, 'variant' | 'size' | 'iconPadding' | 'iconSize' | 'shape'> {
3131
/** Accessible name for the caller's icon action. */
3232
'aria-label': string
3333
/** 28px by default; `sm` retains compact chat's 22px geometry and hover treatment. */

0 commit comments

Comments
 (0)