Skip to content

Commit e89bc65

Browse files
authored
improvement(emcn): refine shared menu and picker styling (#8836)
1 parent 4308631 commit e89bc65

14 files changed

Lines changed: 159 additions & 200 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Never hand-roll the chip pill from raw class strings (they go stale). Compose fr
1717

1818
Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border` border (`chip-chrome.ts` still spells it through the legacy alias `--border-1`; new code writes `--border`).
1919

20-
The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items use `text-small` and `gap-2` (a menu convention, not the chip pill). Keep them distinct.
20+
`MENU_STYLES` in `dropdown-menu/styles.ts` owns the shared dropdown, picker-list, and popover chrome. Its default menu rows are 28px at `text-small`; chip pills remain 30px at `text-sm`. Menus reuse the chip gap, row radius, and search-field surface and typography, with a 12px outer radius, 4px padding, and `shadow-medium`. Compose these tokens instead of overriding chrome at call sites. Within the package, shared initialization-time tokens use private imports to avoid public-barrel cycles; consumers import `MENU_STYLES` from `@sim/emcn`.
2121

2222
## Component catalogue
2323

@@ -26,13 +26,13 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
2626
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
2727
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
2828
- **`ChipDropdown`** — pill that opens a menu. Single OR multi-select via the discriminated `multiple` prop (one component, not two). Owns its trailing chevron — no `rightIcon`.
29-
- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`.
29+
- **`ChipSelect` / `ChipCombobox`** — pickers with search, groups, and multi-select. `ChipSelect` uses `DropdownMenu`; `ChipCombobox` uses `Combobox` for editable input, async options, and virtualized lists. Both inherit the shared menu chrome.
3030
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` (with a `title`) is the escape hatch for controls the field doesn't cover. Every body field is a `ChipModalField`; the gutter rhythm that makes this matter is in `sim-styling.md` → "Form / chip-modal layout rhythm".
3131
- **`ChipSwitch`** — segmented pill control (built from `chipVariants`).
3232
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
3333
- **`ChipDatePicker`** — chip-styled date field.
3434
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
35-
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
35+
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed), styled through `MENU_STYLES`. Reach for it instead of a hand-rolled popover for command/action lists. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
3636
- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
3737
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
3838

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Never hand-roll the chip pill from raw class strings (they go stale). Compose fr
1818

1919
Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border` border (`chip-chrome.ts` still spells it through the legacy alias `--border-1`; new code writes `--border`).
2020

21-
The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items use `text-small` and `gap-2` (a menu convention, not the chip pill). Keep them distinct.
21+
`MENU_STYLES` in `dropdown-menu/styles.ts` owns the shared dropdown, picker-list, and popover chrome. Its default menu rows are 28px at `text-small`; chip pills remain 30px at `text-sm`. Menus reuse the chip gap, row radius, and search-field surface and typography, with a 12px outer radius, 4px padding, and `shadow-medium`. Compose these tokens instead of overriding chrome at call sites. Within the package, shared initialization-time tokens use private imports to avoid public-barrel cycles; consumers import `MENU_STYLES` from `@sim/emcn`.
2222

2323
## Component catalogue
2424

@@ -27,13 +27,13 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
2727
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
2828
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
2929
- **`ChipDropdown`** — pill that opens a menu. Single OR multi-select via the discriminated `multiple` prop (one component, not two). Owns its trailing chevron — no `rightIcon`.
30-
- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`.
30+
- **`ChipSelect` / `ChipCombobox`** — pickers with search, groups, and multi-select. `ChipSelect` uses `DropdownMenu`; `ChipCombobox` uses `Combobox` for editable input, async options, and virtualized lists. Both inherit the shared menu chrome.
3131
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` (with a `title`) is the escape hatch for controls the field doesn't cover. Every body field is a `ChipModalField`; the gutter rhythm that makes this matter is in `sim-styling.md` → "Form / chip-modal layout rhythm".
3232
- **`ChipSwitch`** — segmented pill control (built from `chipVariants`).
3333
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
3434
- **`ChipDatePicker`** — chip-styled date field.
3535
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
36-
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
36+
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed), styled through `MENU_STYLES`. Reach for it instead of a hand-rolled popover for command/action lists. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
3737
- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
3838
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
3939

‎apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
DropdownMenuItem,
2525
DropdownMenuTrigger,
2626
FloatingTooltip,
27+
MENU_STYLES,
2728
OverflowText,
2829
overflowTextClipClass,
2930
overflowTextFadeClass,
@@ -552,14 +553,11 @@ function BreadcrumbLocationPopover({
552553
maxWidth={300}
553554
maxHeight={420}
554555
border
555-
className={cn(
556-
POPOVER_ANIMATION_CLASSES,
557-
'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-xs'
558-
)}
556+
className={POPOVER_ANIMATION_CLASSES}
559557
onMouseEnter={openPopover}
560558
onMouseLeave={scheduleClose}
561559
>
562-
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
560+
<PopoverSection>
563561
<span className='inline-flex items-center gap-1'>
564562
<span>Path</span>
565563
<span className='opacity-70'>/</span>
@@ -656,7 +654,7 @@ function BreadcrumbLocationItem({
656654
<>
657655
<span className='flex size-[18px] shrink-0 items-center justify-center'>
658656
{Icon ? (
659-
<Icon className='size-3 text-[var(--text-icon)]' />
657+
<Icon className='size-[14px] text-[var(--text-icon)]' />
660658
) : (
661659
<span className='size-1.5 rounded-full bg-[var(--text-muted)]' />
662660
)}
@@ -667,11 +665,7 @@ function BreadcrumbLocationItem({
667665

668666
if (onClick) {
669667
return (
670-
<PopoverItem
671-
active={active}
672-
onClick={onClick}
673-
className='h-7 items-center gap-1.5 px-1.5 py-0 text-xs'
674-
>
668+
<PopoverItem active={active} onClick={onClick}>
675669
{labelContent}
676670
</PopoverItem>
677671
)
@@ -680,7 +674,9 @@ function BreadcrumbLocationItem({
680674
return (
681675
<div
682676
className={cn(
683-
'flex h-7 min-w-0 items-center gap-1.5 rounded-lg px-1.5 text-[var(--text-body)] text-xs',
677+
MENU_STYLES.rowLayout,
678+
MENU_STYLES.sizes.md.className,
679+
'text-[var(--text-body)]',
684680
active && 'bg-[var(--surface-active)]'
685681
)}
686682
>

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx‎

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Popover,
1414
PopoverAnchor,
1515
PopoverContent,
16+
PopoverDivider,
1617
PopoverItem,
1718
PopoverSection,
1819
Tooltip,
@@ -135,17 +136,12 @@ export const ViewsMenu = memo(function ViewsMenu({
135136
maxWidth={320}
136137
maxHeight={420}
137138
border
138-
className={cn(
139-
POPOVER_ANIMATION_CLASSES,
140-
'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-xs'
141-
)}
139+
className={POPOVER_ANIMATION_CLASSES}
142140
onMouseEnter={openPopover}
143141
onMouseLeave={scheduleClose}
144142
onFocusCapture={cancelScheduledClose}
145143
>
146-
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
147-
Views
148-
</PopoverSection>
144+
<PopoverSection>Views</PopoverSection>
149145
<div className='flex flex-col gap-0.5'>
150146
{!hasDefaultView && (
151147
<ViewRow
@@ -194,13 +190,10 @@ export const ViewsMenu = memo(function ViewsMenu({
194190
</div>
195191
{canEdit && (
196192
<>
197-
<div className='my-1 h-px bg-[var(--border)]' />
198-
<PopoverItem
199-
onClick={() => runAndClose(onNewView)}
200-
className='h-7 items-center gap-1.5 px-1.5 py-0 text-xs'
201-
>
193+
<PopoverDivider />
194+
<PopoverItem onClick={() => runAndClose(onNewView)}>
202195
<span className='flex size-[14px] shrink-0 items-center justify-center'>
203-
<Plus className='size-3 text-[var(--text-icon)]' />
196+
<Plus className='size-[14px] text-[var(--text-icon)]' />
204197
</span>
205198
<OverflowText label='New view' className='flex-1 text-left' />
206199
</PopoverItem>
@@ -265,13 +258,9 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr
265258

266259
return (
267260
<div className='group/view relative flex items-center'>
268-
<PopoverItem
269-
active={isActive}
270-
onClick={onSelect}
271-
className='h-7 min-w-0 flex-1 items-center gap-1.5 px-1.5 py-0 text-xs'
272-
>
261+
<PopoverItem active={isActive} onClick={onSelect} className='flex-1'>
273262
<span className='flex size-[14px] shrink-0 items-center justify-center'>
274-
{isActive && <Check className='size-3 text-[var(--text-icon)]' />}
263+
{isActive && <Check className='size-[14px] text-[var(--text-icon)]' />}
275264
</span>
276265
<OverflowText label={label} className='flex-1 text-left' />
277266
{actionCount > 0 && (

‎packages/emcn/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
"bun": ">=1.4.2",
1313
"node": ">=20.0.0"
1414
},
15+
"imports": {
16+
"#chip-chrome": "./src/components/chip/chip-chrome.ts",
17+
"#cn": "./src/lib/cn.ts",
18+
"#menu-styles": "./src/components/dropdown-menu/styles.ts"
19+
},
1520
"exports": {
1621
".": {
1722
"types": "./src/index.ts",

‎packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { forwardRef, useContext, useState } from 'react'
44
import * as PopoverPrimitive from '@radix-ui/react-popover'
5+
import { MENU_STYLES } from '#menu-styles'
56
import { ChevronDown } from '../../icons'
67
import { cn } from '../../lib/cn'
78
import { Calendar, formatDateLabel, formatDateRangeLabel } from '../calendar/calendar'
@@ -149,7 +150,8 @@ const ChipDatePicker = forwardRef<HTMLButtonElement, ChipDatePickerProps>(
149150
data-native-surface-overlay=''
150151
className={cn(
151152
POPOVER_ANIMATION_CLASSES,
152-
'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-xs'
153+
MENU_STYLES.surface,
154+
'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin]'
153155
)}
154156
>
155157
{props.mode === 'range' ? (

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useState,
1212
} from 'react'
1313
import type { VariantProps } from 'class-variance-authority'
14+
import { MENU_STYLES } from '#menu-styles'
1415
import { Check, ChevronDown } from '../../icons'
1516
import { cn } from '../../lib/cn'
1617
import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip'
@@ -346,7 +347,9 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
346347
) : (
347348
<span className={cn(overflowTextClipClass, 'flex-1')}>{option.label}</span>
348349
)}
349-
{showSelectedCheck && isSelected ? <Check className='ml-auto! size-[16px]!' /> : null}
350+
{showSelectedCheck && isSelected ? (
351+
<Check aria-hidden className={MENU_STYLES.selectionIcon} />
352+
) : null}
350353
</DropdownMenuItem>
351354
)
352355
}
@@ -422,7 +425,9 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
422425
}}
423426
>
424427
<DropdownMenuItemLabel label={allLabel} />
425-
{selectedValues.length === 0 ? <Check className='ml-auto! size-[16px]!' /> : null}
428+
{selectedValues.length === 0 ? (
429+
<Check aria-hidden className={MENU_STYLES.selectionIcon} />
430+
) : null}
426431
</DropdownMenuItem>
427432
)}
428433
{filteredOptions.map(renderItem)}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

33
import * as React from 'react'
4-
import { ChevronDown } from '../../icons'
4+
import { Check, ChevronDown } from '@sim/emcn/icons'
5+
import { MENU_STYLES } from '#menu-styles'
56
import { cn } from '../../lib/cn'
67
import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip'
78
import { chipIconSlotClass } from '../chip/chip-chrome'
@@ -232,7 +233,7 @@ export function ChipSelect({
232233
toggleValue(opt.value)
233234
}}
234235
>
235-
{Icon ? <Icon className='mr-2 size-[14px] text-[var(--text-icon)]' /> : null}
236+
{Icon ? <Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' /> : null}
236237
{opt.label}
237238
</DropdownMenuCheckboxItem>
238239
)
@@ -245,6 +246,7 @@ export function ChipSelect({
245246
>
246247
{Icon ? <Icon /> : null}
247248
<DropdownMenuItemLabel label={opt.label} />
249+
{value === opt.value ? <Check aria-hidden className={MENU_STYLES.selectionIcon} /> : null}
248250
</DropdownMenuItem>
249251
)
250252
}

0 commit comments

Comments
 (0)