diff --git a/libs/ui/docs/icon-button-unification.md b/libs/ui/docs/icon-button-unification.md new file mode 100644 index 000000000..6a0722788 --- /dev/null +++ b/libs/ui/docs/icon-button-unification.md @@ -0,0 +1,117 @@ +# Icon sub-button unification — research & plan + +> Status: implemented & shipped (PR #467). Code complete; Figma `ActionIcon` +> component + frame migration is the remaining follow-up. +> Decisions locked: glyph scale **16 / 20 / 24**; hover **shared bg-pill** +> (light+dark via `--color-fill-hover`/`--color-fill-active`). Clear ✕ uses the +> **neutral** tone (subtle gray pill) — NOT red. `danger` tone is reserved for +> genuinely destructive actions. Hit area is a compact inset square (24/32/40), +> not full control height. + +## Scope +Small icon-only interactive elements embedded in larger controls: dropdown +**chevrons**, **clear (✕)**, **increment/decrement**, **search**, **close (✕)**, +**prev/next**, expand **chevrons**, step **indicators**. + +## Root cause — three competing sizing mechanisms +Icons render as a glyph font sized by `font-size` (`Icon` `size` → `text-icon-*`). +Three different mechanisms currently decide that size: + +| # | Mechanism | How size is chosen | Behavior | +|---|-----------|--------------------|----------| +| A | Explicit keyword | JS ternary → `text-icon-{xs,sm,md}` | Fixed px, breakpoints differ per component | +| B | `current` / inherited | Icon inherits container `text-*` | Fluid (clamp), tracks viewport | +| C | Component CSS token | `text--icon-{sm,md,lg}` | Fixed, each component defines its own | + +Active glyph scale (`tokens/figma/variables.css`): xs=12 · sm=14 · md=20 · lg=24 · xl=30 · 2xl=40 px. + +## Current state (glyph px per component × size) + +| Component | Sub-button | Mech | sm | md | lg | Hover | +|-----------|-----------|:--:|:--:|:--:|:--:|-------| +| NumericInput | inc / dec | A | 12 | 14 | 20 | bg-pill + fg | +| Combobox | chevron | A | 14 | 20 | 20 ⚠ | bg-pill + fg | +| Combobox | clear ✕ | B | 24 | 24 | 24 ⚠⚠ | bg-pill + fg | +| Select | chevron | A | 14 | 20 | 20 ⚠ | color only, no bg ⚠ | +| Select | clear ✕ | B | 14 | 20 | 24 ⚠ | bg-pill + danger | +| Select | (xs size) | A | chevron→20, no xs branch ⚠ | | | | +| SearchForm | clear ✕ | B | 14 | 20 | 24 | unstyled → no hover bg ⚠ | +| SearchForm | search icon | B | inherits button text | | | button hover | +| Accordion | chevron | B | inherits header text | | | rotate | +| Tree-view | node icon | C | 14 | 20 | 24 | scale-125 ⚠ | +| Tree-view | branch indicator | C | 20 | 24 | 30 | scale-125 | +| Breadcrumb | separator / ellipsis | C | 12 | 14 | 20 | n/a | +| Steps | indicator icon | C | 14 | 20 | 24 | in box | +| Tabs | trigger icon | B | 14 | 20 | 24 | bg-pill | +| Carousel | prev / next / autoplay | C | 20 | 20 | 20 ⚠ | bg-pill + fg | +| Pagination | prev / next / ellipsis | B | inherits button text | | | button hover | +| Dialog / Toast / Popover | close ✕ | B | inherits | | | color only | + +## Problems +1. No agreement on glyph size for the same role (clear ✕ = 24/24/24 vs 14/20/24). +2. Mismatch within one component (Combobox md: chevron 20 vs clear 24). +3. `lg` doesn't grow (chevrons stay 20). +4. Fluid (B) vs fixed (A) mix → icons drift relative to each other across widths. +5. Select `xs` has no chevron branch → falls to 20. +6. Hover wildly inconsistent: bg-pill / color-only / scale-125 / none. +7. Hit-area/padding tokens per-component, no shared standard. + +## Proposed direction +One shared icon-button standard with 3 sizes (mapped to form-control sm/md/lg), +covering glyph size + hit-area/padding + unified hover (light+dark). A small +nested **helper atom** owns this so it is defined **once** (Figma + code), with +per-component override still allowed. + +### Token layer — `tokens/components/_icon-button.css` (temporary bridge) + +These mirror the Figma `icon-control` collection and move into +`tokens/figma/variables.css` on the next Figma re-export (this file is deleted then): + +```css +@theme static { + /* Glyph size (sm/md/lg) */ + --text-icon-control-sm: var(--dimension-16); /* 16px */ + --text-icon-control-md: var(--text-icon-md); /* 20px */ + --text-icon-control-lg: var(--text-icon-lg); /* 24px */ + + /* Hit area — compact inset square (8-pt grid 24/32/40) */ + --size-icon-control-sm: var(--dimension-24); + --size-icon-control-md: var(--dimension-32); + --size-icon-control-lg: var(--dimension-40); + --radius-icon-control: var(--radius-button-sm); + + /* Neutral tone — full-contrast glyph; bg pill is the hover/active affordance */ + --color-icon-control-fg: var(--color-fg-primary); + --color-icon-control-bg-hover: var(--color-fill-hover); + --color-icon-control-bg-active: var(--color-fill-active); + --color-icon-control-fg-disabled: var(--color-fg-disabled); + --color-icon-control-ring: var(--color-ring); + + /* Danger tone — destructive actions only */ + --color-icon-control-fg-danger-hover: var(--color-danger-fg); + --color-icon-control-bg-danger-hover: var(--color-danger-light); + --color-icon-control-bg-danger-active: var(--color-danger-light-active); +} +``` + +The interactive button (hit-area, glyph, hover/active pill) lives in the +`ActionIcon` atom (`src/atoms/action-icon.tsx`); chevrons read the +`--text-icon-control-*` glyph tokens directly. + +### Migration (per component) +NumericInput, Combobox, Select, SearchForm, Accordion, Dialog, Toast, Popover, +Tabs, Pagination, Carousel, Tree-view, Breadcrumb, Steps, Phone-input — replace +bespoke ternaries / `current` / per-component CSS with the shared helper. +`xs` form sizes map to `sm` (16). + +Out of scope: radio-card mark, checkbox/switch/slider marks, status-text icons. + +### Validation +`pnpm validate:tokens`; `bunx biome check --write `; `bunx nx run ui-kit:build`; +Storybook visual pass (sm/md/lg, light + dark) via browser agent. + +### Figma +Mirror the helper as a nested component with a `size` variant; bind component +tokens `text/icon-control/{sm,md,lg}`, `color/icon-control/bg/hover[/danger]` +through semantic→core; explicit scopes + CSS code syntax. Validate every frame +by screenshot so the existing Figma file stays unbroken. diff --git a/libs/ui/src/atoms/action-icon.tsx b/libs/ui/src/atoms/action-icon.tsx new file mode 100644 index 000000000..1ad4eac37 --- /dev/null +++ b/libs/ui/src/atoms/action-icon.tsx @@ -0,0 +1,80 @@ +import type { ButtonHTMLAttributes, Ref } from "react" +import type { VariantProps } from "tailwind-variants" +import { tv } from "../utils" +import { Icon, type IconType } from "./icon" + +/* + * ActionIcon — the single icon-only "sub-button" used inside larger controls + * (clear ✕, increment/decrement, close ✕, prev/next, search). Glyph size, + * hit area, radius and the hover/active pill all come from the shared + * `--*-icon-control-*` tokens in tokens/components/_icon-button.css, so every + * icon button across the system stays consistent across sm/md/lg and light/dark. + * + * Decorative chevrons are NOT this component — they live inside a trigger + * + ) +} + +ActionIcon.displayName = "ActionIcon" diff --git a/libs/ui/src/atoms/numeric-input.tsx b/libs/ui/src/atoms/numeric-input.tsx index 9a153838e..2926a7141 100644 --- a/libs/ui/src/atoms/numeric-input.tsx +++ b/libs/ui/src/atoms/numeric-input.tsx @@ -46,17 +46,23 @@ const numericInputVariants = tv({ "focus-visible:outline-none", "duration-0 data-invalid:focus:border-input-border-danger-focus", ], + // Subtle divider from the input instead of a gray fill block; the gap-px + // shows the field behind it as a hairline between the two arrows. triggerContainer: [ - "flex flex-col gap-px self-stretch bg-numeric-input-trigger-container-bg", + "flex flex-col gap-px self-stretch", + "border-numeric-input-border border-s", ], + // Unified neutral icon-control treatment: transparent base (matches the + // field, no "disabled" gray), neutral arrows, subtle neutral hover pill — + // no blue arrow-on-gray. Glyph size is kept per NumericInput's own scale. trigger: [ "flex flex-1 place-items-center", "px-numeric-input-trigger-x py-numeric-input-trigger-y", - "bg-numeric-input-trigger-bg hover:bg-numeric-input-trigger-bg-hover", - "text-numeric-input-trigger-fg hover:text-numeric-input-trigger-fg-hover", + "bg-transparent hover:bg-icon-control-bg-hover active:bg-icon-control-bg-active", + "text-icon-control-fg", "cursor-pointer", "transition-colors duration-200 motion-reduce:transition-none", - "disabled:cursor-not-allowed", + "disabled:cursor-not-allowed disabled:text-icon-control-fg-disabled", ], scrubber: "absolute inset-0 cursor-ew-resize", }, diff --git a/libs/ui/src/molecules/accordion.tsx b/libs/ui/src/molecules/accordion.tsx index 414f17028..1b3ecb244 100644 --- a/libs/ui/src/molecules/accordion.tsx +++ b/libs/ui/src/molecules/accordion.tsx @@ -60,16 +60,19 @@ const accordionVariants = tv({ title: "p-accordion-title-sm text-accordion-title-sm", content: "px-accordion-content-x-sm text-accordion-content-sm", subtitle: "text-accordion-subtitle-sm", + icon: "text-icon-control-sm", }, md: { title: "p-accordion-title-md text-accordion-title-md", content: "p-accordion-content-md text-accordion-content-md", subtitle: "text-accordion-subtitle-md", + icon: "text-icon-control-md", }, lg: { title: "p-accordion-title-lg text-accordion-title-lg", content: "p-accordion-content-lg text-accordion-content-lg", subtitle: "text-accordion-subtitle-lg", + icon: "text-icon-control-lg", }, }, }, diff --git a/libs/ui/src/molecules/combobox.tsx b/libs/ui/src/molecules/combobox.tsx index ba6adacf2..f4ea6dfd9 100644 --- a/libs/ui/src/molecules/combobox.tsx +++ b/libs/ui/src/molecules/combobox.tsx @@ -6,6 +6,7 @@ import { import { normalizeProps, Portal, useMachine } from "@zag-js/react" import { useEffect, useId, useState } from "react" import type { VariantProps } from "tailwind-variants" +import { ActionIcon } from "../atoms/action-icon" import { Button } from "../atoms/button" import { Icon, type IconProps, type IconType } from "../atoms/icon" import { Input } from "../atoms/input" @@ -35,16 +36,14 @@ const comboboxVariants = tv({ "data-[validation=warning]:border-combobox-border-warning", ], input: [ - "relative h-full w-full border-none bg-combobox-input-bg-base", + "relative h-full min-w-0 flex-1 border-none bg-combobox-input-bg-base", "hover:bg-combobox-input-bg-hover focus-visible:outline-none", "focus:bg-combobox-input-bg-focus", "placeholder:text-combobox-fg-placeholder", "data-disabled:text-combobox-fg-disabled", "data-disabled:bg-combobox-bg-disabled", ], - clearTrigger: [ - "absolute right-combobox-clear-right h-full p-combobox-trigger", - ], + // Trailing actions (clear + chevron) sit side by side with NO gap. trigger: [ "group flex h-full shrink-0 items-center justify-center", "font-normal", @@ -88,12 +87,12 @@ const comboboxVariants = tv({ }, compoundSlots: [ { - slots: ["clearTrigger", "trigger"], + slots: ["trigger"], class: [ "focus-visible:outline-(style:--default-ring-style) focus-visible:outline-(length:--default-ring-width)", "focus-visible:outline-combobox-ring", "focus-visible:outline-offset-(length:--default-ring-offset)", - "text-combobox-trigger-fg-base text-combobox-trigger", + "text-combobox-trigger text-combobox-trigger-fg-base", "hover:text-combobox-trigger-fg-hover", "motion-safe:transition-colors motion-safe:duration-200 motion-reduce:transition-none", "hover:bg-combobox-trigger-bg-hover", @@ -110,6 +109,7 @@ const comboboxVariants = tv({ emptyState: "p-combobox-item-sm text-combobox-item-sm", input: "p-combobox-input-sm", content: "text-combobox-sm", + triggerIndicator: "text-icon-control-sm", }, md: { root: "gap-combobox-md", @@ -118,6 +118,7 @@ const comboboxVariants = tv({ emptyState: "p-combobox-item-md text-combobox-item-md", input: "p-combobox-input-md", content: "text-combobox-md", + triggerIndicator: "text-icon-control-md", }, lg: { root: "gap-combobox-lg", @@ -126,6 +127,7 @@ const comboboxVariants = tv({ emptyState: "p-combobox-item-lg text-combobox-item-lg", input: "p-combobox-input-lg", content: "text-combobox-lg", + triggerIndicator: "text-icon-control-lg", }, }, }, @@ -169,7 +171,6 @@ export interface ComboboxProps triggerIcon?: IconType triggerIconSize?: IconProps["size"] clearIcon?: IconType - clearIconSize?: IconProps["size"] onChange?: (value: string | string[]) => void onInputValueChange?: (value: string) => void onOpenChange?: (open: boolean) => void @@ -203,14 +204,11 @@ export function Combobox({ triggerIcon = "token-icon-combobox-chevron", triggerIconSize, clearIcon = "token-icon-combobox-clear", - clearIconSize, inputBehavior = "autocomplete", onChange, onInputValueChange, onOpenChange, }: ComboboxProps) { - const resolvedChevronIconSize = size === "sm" ? "sm" : "md" - const generatedId = useId() const uniqueId = id || generatedId @@ -276,7 +274,6 @@ export function Combobox({ positioner, content, list, - clearTrigger, item: itemSlot, emptyState, triggerIndicator, @@ -312,14 +309,12 @@ export function Combobox({ /> {clearable && api.value.length > 0 && ( - + /> )} diff --git a/libs/ui/src/molecules/dialog.tsx b/libs/ui/src/molecules/dialog.tsx index e6e5e879d..2c106763e 100644 --- a/libs/ui/src/molecules/dialog.tsx +++ b/libs/ui/src/molecules/dialog.tsx @@ -2,6 +2,7 @@ import * as dialog from "@zag-js/dialog" import { normalizeProps, Portal, useMachine } from "@zag-js/react" import { type ReactNode, useId } from "react" import { tv, type VariantProps } from "tailwind-variants" +import { ActionIcon } from "../atoms/action-icon" import { Button } from "../atoms/button" const dialogVariants = tv({ @@ -18,17 +19,13 @@ const dialogVariants = tv({ "focus-visible:outline-dialog-ring", "focus-visible:outline-offset-(length:--default-ring-offset)", ], - title: ["font-dialog-title text-dialog-title-fg text-dialog-title"], - description: ["text-dialog-description-fg text-dialog-description"], + title: ["font-dialog-title text-dialog-title text-dialog-title-fg"], + description: ["text-dialog-description text-dialog-description-fg"], trigger: [], + // Positioning only — the close button is an ActionIcon that owns its size, + // glyph and neutral hover pill. closeTrigger: [ "absolute top-dialog-close-trigger-offset right-dialog-close-trigger-offset", - "flex items-center justify-center", - "rounded-dialog-close-trigger p-dialog-close-trigger", - "text-dialog-close-trigger-fg", - "focus-visible:outline-(style:--default-ring-style) focus-visible:outline-(length:--default-ring-width)", - "focus-visible:outline-dialog-ring", - "focus-visible:outline-offset-(length:--default-ring-offset)", ], actions: "mt-auto flex shrink-0 justify-end gap-dialog-actions pt-dialog-actions-top", @@ -252,11 +249,13 @@ export function Dialog({
{!hideCloseButton && ( - ) @@ -398,13 +399,11 @@ Select.ValueText = function SelectValueText({ } type SelectClearTriggerProps = ComponentPropsWithoutRef<"button"> & { - iconSize?: IconProps["size"] ref?: Ref } Select.ClearTrigger = function SelectClearTrigger({ className, - iconSize, ref, ...props }: SelectClearTriggerProps) { @@ -412,15 +411,14 @@ Select.ClearTrigger = function SelectClearTrigger({ const styles = selectVariants({ size }) return ( -