From 599880aee72945c0a6a0109b7522712d2271817a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 22:04:26 -0700 Subject: [PATCH] improvement(emcn): refine shared menu and picker styling --- .claude/rules/emcn-components.md | 6 +- .cursor/rules/emcn-components.mdc | 6 +- .../resource-header/resource-header.tsx | 20 ++- .../components/views-menu/views-menu.tsx | 27 ++--- packages/emcn/package.json | 5 + .../chip-date-picker/chip-date-picker.tsx | 4 +- .../chip-dropdown/chip-dropdown.tsx | 9 +- .../components/chip-select/chip-select.tsx | 6 +- .../emcn/src/components/combobox/combobox.tsx | 61 ++++++---- .../dropdown-menu/dropdown-menu.tsx | 114 ++++-------------- .../src/components/dropdown-menu/styles.ts | 24 ++++ packages/emcn/src/components/index.ts | 1 + .../emcn/src/components/popover/popover.tsx | 69 +++++------ .../components/time-picker/time-picker.tsx | 7 +- 14 files changed, 159 insertions(+), 200 deletions(-) create mode 100644 packages/emcn/src/components/dropdown-menu/styles.ts diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index f302a07fa85..873bb3defae 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -17,7 +17,7 @@ Never hand-roll the chip pill from raw class strings (they go stale). Compose fr 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`). -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. +`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`. ## Component catalogue @@ -26,13 +26,13 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`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. - **`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`). - **`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`. -- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`. +- **`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. - **`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". - **`ChipSwitch`** — segmented pill control (built from `chipVariants`). - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. - **`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. -- **`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. +- **`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. - **`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. - **`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. diff --git a/.cursor/rules/emcn-components.mdc b/.cursor/rules/emcn-components.mdc index 3ed219f6479..2a64fa95d4f 100644 --- a/.cursor/rules/emcn-components.mdc +++ b/.cursor/rules/emcn-components.mdc @@ -18,7 +18,7 @@ Never hand-roll the chip pill from raw class strings (they go stale). Compose fr 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`). -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. +`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`. ## Component catalogue @@ -27,13 +27,13 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`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. - **`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`). - **`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`. -- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`. +- **`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. - **`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". - **`ChipSwitch`** — segmented pill control (built from `chipVariants`). - **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger. - **`ChipDatePicker`** — chip-styled date field. - **`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. -- **`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. +- **`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. - **`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. - **`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. diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 301cc2e7dba..531d34ad68d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -24,6 +24,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, FloatingTooltip, + MENU_STYLES, OverflowText, overflowTextClipClass, overflowTextFadeClass, @@ -552,14 +553,11 @@ function BreadcrumbLocationPopover({ maxWidth={300} maxHeight={420} border - className={cn( - POPOVER_ANIMATION_CLASSES, - 'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-xs' - )} + className={POPOVER_ANIMATION_CLASSES} onMouseEnter={openPopover} onMouseLeave={scheduleClose} > - + Path / @@ -656,7 +654,7 @@ function BreadcrumbLocationItem({ <> {Icon ? ( - + ) : ( )} @@ -667,11 +665,7 @@ function BreadcrumbLocationItem({ if (onClick) { return ( - + {labelContent} ) @@ -680,7 +674,9 @@ function BreadcrumbLocationItem({ return (
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx index b2215ccd221..1e15672de0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverAnchor, PopoverContent, + PopoverDivider, PopoverItem, PopoverSection, Tooltip, @@ -135,17 +136,12 @@ export const ViewsMenu = memo(function ViewsMenu({ maxWidth={320} maxHeight={420} border - className={cn( - POPOVER_ANIMATION_CLASSES, - 'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-xs' - )} + className={POPOVER_ANIMATION_CLASSES} onMouseEnter={openPopover} onMouseLeave={scheduleClose} onFocusCapture={cancelScheduledClose} > - - Views - + Views
{!hasDefaultView && ( {canEdit && ( <> -
- runAndClose(onNewView)} - className='h-7 items-center gap-1.5 px-1.5 py-0 text-xs' - > + + runAndClose(onNewView)}> - + @@ -265,13 +258,9 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr return (
- + - {isActive && } + {isActive && } {actionCount > 0 && ( diff --git a/packages/emcn/package.json b/packages/emcn/package.json index 39f7f470033..cd10b2e08ab 100644 --- a/packages/emcn/package.json +++ b/packages/emcn/package.json @@ -12,6 +12,11 @@ "bun": ">=1.4.2", "node": ">=20.0.0" }, + "imports": { + "#chip-chrome": "./src/components/chip/chip-chrome.ts", + "#cn": "./src/lib/cn.ts", + "#menu-styles": "./src/components/dropdown-menu/styles.ts" + }, "exports": { ".": { "types": "./src/index.ts", diff --git a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx index 74b6a45b7f3..64076ec57af 100644 --- a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx +++ b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx @@ -2,6 +2,7 @@ import { forwardRef, useContext, useState } from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' +import { MENU_STYLES } from '#menu-styles' import { ChevronDown } from '../../icons' import { cn } from '../../lib/cn' import { Calendar, formatDateLabel, formatDateRangeLabel } from '../calendar/calendar' @@ -149,7 +150,8 @@ const ChipDatePicker = forwardRef( data-native-surface-overlay='' className={cn( POPOVER_ANIMATION_CLASSES, - 'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-xs' + MENU_STYLES.surface, + 'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin]' )} > {props.mode === 'range' ? ( diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index ce0c5bcd524..2be51f61f07 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -11,6 +11,7 @@ import { useState, } from 'react' import type { VariantProps } from 'class-variance-authority' +import { MENU_STYLES } from '#menu-styles' import { Check, ChevronDown } from '../../icons' import { cn } from '../../lib/cn' import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip' @@ -346,7 +347,9 @@ const ChipDropdown = forwardRef( ) : ( {option.label} )} - {showSelectedCheck && isSelected ? : null} + {showSelectedCheck && isSelected ? ( + + ) : null} ) } @@ -422,7 +425,9 @@ const ChipDropdown = forwardRef( }} > - {selectedValues.length === 0 ? : null} + {selectedValues.length === 0 ? ( + + ) : null} )} {filteredOptions.map(renderItem)} diff --git a/packages/emcn/src/components/chip-select/chip-select.tsx b/packages/emcn/src/components/chip-select/chip-select.tsx index 739960e7c5f..c507da8d3fa 100644 --- a/packages/emcn/src/components/chip-select/chip-select.tsx +++ b/packages/emcn/src/components/chip-select/chip-select.tsx @@ -1,7 +1,8 @@ 'use client' import * as React from 'react' -import { ChevronDown } from '../../icons' +import { Check, ChevronDown } from '@sim/emcn/icons' +import { MENU_STYLES } from '#menu-styles' import { cn } from '../../lib/cn' import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip' import { chipIconSlotClass } from '../chip/chip-chrome' @@ -232,7 +233,7 @@ export function ChipSelect({ toggleValue(opt.value) }} > - {Icon ? : null} + {Icon ? : null} {opt.label} ) @@ -245,6 +246,7 @@ export function ChipSelect({ > {Icon ? : null} + {value === opt.value ? : null} ) } diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index fcffa3492ac..b42c8797868 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -16,6 +16,7 @@ import { } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { cva, type VariantProps } from 'class-variance-authority' +import { MENU_STYLES } from '#menu-styles' import { Check, ChevronDown, Loader, Search } from '../../icons' import { cn } from '../../lib/cn' import { Button } from '../button/button' @@ -436,7 +437,7 @@ const Combobox = memo( const optionVirtualizer = useVirtualizer({ count: virtualizeOptions ? filteredOptions.length : 0, getScrollElement: () => scrollArea, - estimateSize: () => (size === 'sm' ? 28 : 34), + estimateSize: () => MENU_STYLES.sizes[size ?? 'md'].height + 2, overscan: 8, }) const hasActiveSearch = searchActive || (searchable && searchQuery.trim().length > 0) @@ -763,20 +764,21 @@ const Combobox = memo( }} onMouseEnter={() => !option.disabled && setHighlightedIndex(index)} className={cn( - 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans', - size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', - (isHighlighted || isSelected) && chipActiveSurfaceClass, + MENU_STYLES.rowLayout, + 'relative cursor-pointer select-none text-[var(--text-body)]', + MENU_STYLES.sizes[size ?? 'md'].className, + isSelected ? chipActiveSurfaceClass : isHighlighted && 'bg-[var(--surface-hover)]', option.disabled && 'cursor-not-allowed opacity-50' )} > {option.iconElement ? option.iconElement - : OptionIcon && } - + : OptionIcon && ( + + )} + {option.suffixElement} - {multiSelect && isSelected && ( - - )} + {isSelected && }
) } @@ -906,7 +908,7 @@ const Combobox = memo( align={align} sideOffset={4} className={cn( - 'rounded-md border border-[var(--border-1)] p-0', + 'p-0', dropdownWidth === 'trigger' && 'w-[var(--radix-popover-trigger-width)]' )} style={ @@ -933,11 +935,11 @@ const Combobox = memo( }} > {searchable && ( -
- +
+ updateSearchQuery(e.target.value)} @@ -1009,9 +1011,7 @@ const Combobox = memo( {group.sectionElement ? group.sectionElement : group.section && ( -
- {group.section} -
+
{group.section}
)} {group.items.map((option) => { const isSelected = @@ -1043,8 +1043,9 @@ const Combobox = memo( !option.disabled && setHighlightedIndex(globalIndex) } className={cn( - 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans', - size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', + MENU_STYLES.rowLayout, + 'relative cursor-pointer select-none text-[var(--text-body)]', + MENU_STYLES.sizes[size ?? 'md'].className, /* No CSS `:hover` here — `isHighlighted` is the single source of truth for the cursor, because @@ -1055,20 +1056,24 @@ const Combobox = memo( pointer the two disagree and the row that looks selected is not the one Enter would choose. */ - (isHighlighted || isSelected) && chipActiveSurfaceClass, + isSelected + ? chipActiveSurfaceClass + : isHighlighted && 'bg-[var(--surface-hover)]', option.disabled && 'cursor-not-allowed opacity-50' )} > {option.iconElement ? option.iconElement - : OptionIcon && } + : OptionIcon && ( + + )} {option.suffixElement} - {multiSelect && isSelected && ( - + {isSelected && ( + )}
) @@ -1091,8 +1096,9 @@ const Combobox = memo( }} onMouseEnter={() => setHighlightedIndex(-1)} className={cn( - 'relative flex cursor-pointer select-none items-center rounded-sm px-1.5 font-sans', - size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', + MENU_STYLES.rowLayout, + 'relative cursor-pointer select-none text-[var(--text-body)]', + MENU_STYLES.sizes[size ?? 'md'].className, // Clears the highlight rather than taking it, so unlike option rows it hovers. !multiSelectValues?.length ? chipActiveSurfaceClass @@ -1101,8 +1107,11 @@ const Combobox = memo( > + {!multiSelectValues?.length && ( + + )}
)} {virtualizeOptions ? ( diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index 8b763065770..9e3114d4023 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -22,33 +22,15 @@ import * as React from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' -import { RowActions, rowActionsGroupClass } from '@sim/emcn' +import { POPOVER_ANIMATION_CLASSES, RowActions, rowActionsGroupClass } from '@sim/emcn' import { cva, type VariantProps } from 'class-variance-authority' +import { chipGeometryClass } from '#chip-chrome' +import { MENU_STYLES } from '#menu-styles' import { Check, ChevronRight, Circle, Search } from '../../icons' import { cn } from '../../lib/cn' -import { chipContentGap, chipFieldSurfaceClass, chipGeometryClass } from '../chip/chip-chrome' import { InsideModalContext } from '../modal/modal' import { OverflowText, type OverflowTextProps } from '../overflow-text/overflow-text' -const ANIMATION_CLASSES = - 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none' - -/** - * Menu row geometry. Rows sit 2px flatter than the 30px chip pill — the menu is a - * dense list, not a stack of pills — but keep the shared 8px control corner (see - * the surface note below). Every row (item, checkbox, radio, submenu trigger, - * search field) composes these, so the rhythm cannot drift the way it did when - * each row hardcoded its own height and radius. - * - * The icon↔label gap is {@link chipContentGap}, not a local literal. It was `gap-2` - * (8px) against the platform's 6px, and on the menu's 14px icons — narrower than the - * chip's 16px — the extra 2px read as the row's content drifting apart rather than as - * one icon/label pair. Importing the token is also what keeps a menu row and the - * sidebar chip it opens over from spacing their content differently. - */ -const MENU_ROW_HEIGHT_CLASS = 'h-[28px]' -const MENU_ROW_RADIUS_CLASS = 'rounded-lg' - /** * Rows settle instantly, matching the sidebar (`[&_.group.cursor-pointer]:duration-0` * on its `aside`). A menu is walked, not read: at the default 150ms the fill lags a @@ -136,42 +118,14 @@ function withOverflowLabel(children: React.ReactNode): React.ReactNode { return rebuilt } -/** - * A menu is capped so a long data-driven list — every workflow, every folder — - * scrolls instead of running the height of the screen. The cap has to clear the - * tallest hand-authored action menu though, or an ordinary right-click menu - * scrolls for the sake of a few pixels: the knowledge-base row menu is 231px - * (7 rows x 28px + 3 separators x 9px + 8px padding), which a flat 240px cap - * once clipped while the shorter Files row menu next to it did not. 420px clears - * every action menu in the app with room for a couple more rows. - * - * `min()` with Radix's measured space then keeps the menu inside the viewport - * when it opens near an edge. The popper var (rather than the - * `--radix-dropdown-menu-content-*` alias) because submenu content portals - * outside the root menu and only inherits the popper one; the fallback covers - * the case where collision detection is off and no space is measured at all. - */ +/** Caps long lists to the viewport while leaving ordinary action menus unscrolled. */ const MENU_MAX_HEIGHT_CLASS = 'max-h-[min(420px,var(--radix-popper-available-height,420px))]' /** - * Surface corner, shared by the root menu and submenus — they previously - * disagreed, at 12px and 8px. - * - * `rounded-xl`/`rounded-lg` here are the platform's two-tier radius convention, - * not a value tuned for this menu: every floating surface takes the 12px corner - * ({@link Modal}, {@link ChipModal}, {@link Popover} content) and every row or - * control inside one takes 8px (the chip, `Popover` items, `ChipModal` fields, - * {@link Tooltip}). A menu that picks its own pair reads as a different family of - * object next to the surfaces it opens over, so match the convention rather than - * making the two corners strictly concentric. - * - * The 4px surface padding then makes them concentric anyway — 8px row + 4px pad is - * exactly the 12px surface corner, so a first or last row's rounding now tracks the - * corner it sits in instead of cutting across it. It was 6px, which both broke that - * and gave the menu a wider gutter than its own 8px row padding; two consumers had - * already overridden it back down to 4px by hand. + * Root and submenu surfaces share the platform's 12px floating corner, a 4px + * gutter around the chip-radius rows, and the standard medium elevation. */ -const CONTENT_BASE_CLASSES = `z-[var(--z-popover)] ${MENU_MAX_HEIGHT_CLASS} min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden overscroll-none rounded-xl border border-[var(--border)] bg-[var(--bg)] p-1 text-[var(--text-body)] shadow-xs` +const CONTENT_BASE_CLASSES = `z-[var(--z-popover)] ${MENU_MAX_HEIGHT_CLASS} min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden overscroll-none ${MENU_STYLES.surface} ${MENU_STYLES.padding}` /** * Menu root. Inside a `ModalContent` (Radix modal dialog) the menu is forced @@ -234,7 +188,7 @@ const DropdownMenuSubTrigger = React.forwardRef< /* An open submenu keeps its trigger on the selected surface — including while the pointer is on it, so walking into the submenu doesn't drop the trigger back to the hover fill. */ - `flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-default select-none items-center ${chipContentGap} ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-hidden ${MENU_ROW_TRANSITION_CLASS} ${MENU_ROW_HIGHLIGHT_CLASS} data-[state=open]:bg-[var(--surface-active)] data-[state=open]:focus:bg-[var(--surface-active)] ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`, + `${MENU_STYLES.rowLayout} ${MENU_STYLES.sizes.md.className} cursor-default select-none text-[var(--text-body)] outline-hidden ${MENU_ROW_TRANSITION_CLASS} ${MENU_ROW_HIGHLIGHT_CLASS} data-[state=open]:bg-[var(--surface-active)] data-[state=open]:focus:bg-[var(--surface-active)] ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`, inset && 'pl-7', className )} @@ -254,7 +208,7 @@ const DropdownMenuSubContent = React.forwardRef< @@ -288,7 +242,7 @@ const DropdownMenuContent = React.forwardRef< @@ -307,7 +261,7 @@ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName * is how the `@`-mention list drifted to its own gap, radius, height and text size. * Compose this instead of restating the literals. */ -export const dropdownMenuRowClass = `relative flex ${MENU_ROW_HEIGHT_CLASS} min-w-0 cursor-pointer select-none items-center ${chipContentGap} ${MENU_ROW_RADIUS_CLASS} px-2 text-[var(--text-body)] text-small outline-hidden ${MENU_ROW_TRANSITION_CLASS} data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]` +export const dropdownMenuRowClass = `relative ${MENU_STYLES.rowLayout} ${MENU_STYLES.sizes.md.className} cursor-pointer select-none text-[var(--text-body)] outline-hidden ${MENU_ROW_TRANSITION_CLASS} data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${MENU_ROW_SINGLE_LINE_CLASS} [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]` /** Large rows match the sidebar's chip geometry without changing menu behavior. */ const dropdownMenuItemVariants = cva(dropdownMenuRowClass, { @@ -476,7 +430,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< , React.ComponentPropsWithoutRef & { @@ -538,11 +477,7 @@ const DropdownMenuLabel = React.forwardRef< >(({ className, inset, ...props }, ref) => ( )) @@ -554,7 +489,7 @@ const DropdownMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -587,12 +522,7 @@ const DropdownMenuSearchInput = React.forwardRef< * separates it from the first row. */ return ( -
+
@@ -614,7 +541,10 @@ DropdownMenuSearchInput.displayName = 'DropdownMenuSearchInput' const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { return ( ) diff --git a/packages/emcn/src/components/dropdown-menu/styles.ts b/packages/emcn/src/components/dropdown-menu/styles.ts new file mode 100644 index 00000000000..631858db6c7 --- /dev/null +++ b/packages/emcn/src/components/dropdown-menu/styles.ts @@ -0,0 +1,24 @@ +import { + chipContentGap, + chipFieldSurfaceClass, + chipFieldTextClass, + chipRadiusClass, +} from '#chip-chrome' +import { cn } from '#cn' + +/** Shared chrome for action menus, picker lists, and their floating surfaces. */ +export const MENU_STYLES = { + surface: + 'rounded-xl border border-[var(--border)] bg-[var(--bg)] text-[var(--text-body)] shadow-medium', + padding: 'p-1', + rowLayout: `flex min-w-0 items-center ${chipContentGap} ${chipRadiusClass} px-2`, + sizes: { + sm: { height: 24, className: 'h-[24px] text-caption' }, + md: { height: 28, className: 'h-[28px] text-small' }, + }, + heading: 'flex h-[28px] items-center px-2 text-[var(--text-muted)] text-caption', + selectionIcon: 'ml-auto size-[14px] shrink-0 text-[var(--text-icon)]', + separator: 'mx-2 my-1 border-[var(--border)] border-t', + search: `${chipFieldSurfaceClass} my-0.5 flex h-[28px] shrink-0 items-center ${chipContentGap} px-2`, + searchInput: cn(chipFieldTextClass, 'size-full min-w-0 bg-transparent text-small'), +} as const diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index da21429fe6d..2bb4363c267 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -102,6 +102,7 @@ export { DropdownMenuTrigger, dropdownMenuRowClass, } from './dropdown-menu/dropdown-menu' +export { MENU_STYLES } from './dropdown-menu/styles' export { Expandable, ExpandableContent } from './expandable/expandable' export { DashedDividerLine, FieldDivider } from './field-divider/field-divider' export { Info } from './info/info' diff --git a/packages/emcn/src/components/popover/popover.tsx b/packages/emcn/src/components/popover/popover.tsx index a0024cc5885..46358a1b1e1 100644 --- a/packages/emcn/src/components/popover/popover.tsx +++ b/packages/emcn/src/components/popover/popover.tsx @@ -53,6 +53,7 @@ import * as React from 'react' import { DismissableLayerBranch } from '@radix-ui/react-dismissable-layer' import * as PopoverPrimitive from '@radix-ui/react-popover' import { createPortal } from 'react-dom' +import { MENU_STYLES } from '#menu-styles' import { Check, ChevronLeft, ChevronRight, Search } from '../../icons' import { cn } from '../../lib/cn' import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome' @@ -68,19 +69,18 @@ type PopoverColorScheme = 'default' | 'inverted' */ const STYLES = { /** Base classes shared by all interactive items */ - itemBase: - 'flex min-w-0 cursor-pointer items-center gap-2 rounded-lg px-2 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed', + itemBase: `${MENU_STYLES.rowLayout} cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed`, /** Content container */ - content: 'px-1.5 py-1.5 rounded-xl', + content: `${MENU_STYLES.surface} ${MENU_STYLES.padding}`, /** Size variants */ size: { - sm: { item: 'h-[22px] text-xs', icon: 'size-3', section: 'px-1.5 py-1 text-xs' }, + sm: { item: MENU_STYLES.sizes.sm.className, icon: 'size-3', section: MENU_STYLES.heading }, md: { - item: 'h-[30px] text-sm', + item: MENU_STYLES.sizes.md.className, icon: 'size-[14px]', - section: 'px-1.5 py-1 text-xs', + section: MENU_STYLES.heading, }, } satisfies Record, @@ -88,11 +88,11 @@ const STYLES = { colorScheme: { default: { text: 'text-[var(--text-body)] [&_svg]:text-[var(--text-icon)]', - section: 'text-[var(--text-tertiary)]', + section: 'text-[var(--text-muted)]', search: 'text-[var(--text-muted)]', - searchInput: 'text-[var(--text-primary)] placeholder:text-[var(--text-muted)]', - content: 'bg-[var(--surface-5)] text-foreground dark:bg-[var(--surface-3)]', - divider: 'border-[var(--border-1)]', + searchInput: 'text-[var(--text-body)] placeholder:text-[var(--text-muted)]', + content: '', + divider: 'border-[var(--border)]', }, inverted: { text: 'text-white dark:text-[var(--text-primary)]', @@ -100,7 +100,8 @@ const STYLES = { search: 'text-[var(--text-muted-inverse)] dark:text-[var(--text-muted)]', searchInput: 'text-white placeholder:text-[var(--text-muted-inverse)] dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-muted)]', - content: 'bg-[var(--surface-inverted)] text-white dark:text-foreground', + content: + 'border-[var(--border-inverted)] bg-[var(--surface-inverted)] text-white dark:text-foreground', divider: 'border-[var(--border-inverted)]', }, } satisfies Record< @@ -187,7 +188,7 @@ const usePopoverContext = () => { interface PopoverProps extends PopoverPrimitive.PopoverProps { /** * Size variant of the popover - * - sm: 11px text, compact spacing (for logs, notifications, context menus) + * - sm: 12px text, compact spacing (for logs, notifications, context menus) * - md: 13px text, default spacing * @default 'md' */ @@ -394,7 +395,7 @@ interface PopoverContentProps collisionPadding?: number /** * Adds border to content - * @default false + * @default true */ border?: boolean /** @@ -441,7 +442,7 @@ const PopoverContent = React.forwardRef< align = 'start', sideOffset, collisionPadding = 8, - border = false, + border = true, appearance = 'default', avoidCollisions = true, showArrow = false, @@ -611,12 +612,12 @@ const PopoverContent = React.forwardRef< className={cn( 'z-[var(--z-popover)] flex flex-col outline-hidden', showArrow ? 'overflow-visible' : 'overflow-auto', - STYLES.colorScheme[colorScheme].content, STYLES.content, - appearance === 'tooltip' && TOOLTIP_SURFACE_CLASS, + STYLES.colorScheme[colorScheme].content, + appearance === 'tooltip' && cn(TOOLTIP_SURFACE_CLASS, 'p-1.5'), hasUserWidthConstraint && '[&_.flex-1:not([data-popover-scroll])]:truncate [&_[data-popover-section]]:truncate', - border && 'border border-[var(--border-1)]', + !border && appearance !== 'tooltip' && 'border-0', className )} style={{ @@ -655,7 +656,7 @@ const PopoverContent = React.forwardRef< cn( colorScheme === 'inverted' ? 'fill-[var(--surface-inverted)] stroke-[var(--border-inverted)]' - : 'fill-[var(--surface-3)] stroke-[var(--border-1)] dark:fill-[var(--surface-3)]' + : 'fill-[var(--bg)] stroke-[var(--border)]' ) } > @@ -840,8 +841,8 @@ const PopoverSection = React.forwardRef(
( className={cn( 'fixed z-[calc(var(--z-popover)+1)] min-w-[120px]', STYLES.content, - STYLES.colorScheme[colorScheme].content, - 'shadow-lg' + STYLES.colorScheme[colorScheme].content )} style={{ top: submenuPosition.top, @@ -1115,7 +1115,7 @@ const PopoverBackButton = React.forwardRef )} {folderTitle && !onFolderSelect && ( -
+
{folderTitle}
)} @@ -1157,21 +1157,22 @@ const PopoverSearch = React.forwardRef( }, [setSearchQuery, onValueChange]) return ( -
+
( return (
diff --git a/packages/emcn/src/components/time-picker/time-picker.tsx b/packages/emcn/src/components/time-picker/time-picker.tsx index 2494a3d707e..94400229ebc 100644 --- a/packages/emcn/src/components/time-picker/time-picker.tsx +++ b/packages/emcn/src/components/time-picker/time-picker.tsx @@ -247,12 +247,7 @@ const TimePicker = React.forwardRef(
- +