-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): unify icon sub-buttons via ActionIcon + icon-control tokens #467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
3d3e514
feat(ui): unify icon sub-buttons via ActionIcon + icon-control tokens
Luko248 8923778
fix(ui): address CodeRabbit review on icon-button PR
Luko248 b463234
fix(ui): address Greptile review on icon-button PR
Luko248 52b49a2
fix(ui): preserve Select clear aria-label over Zag spread
Luko248 a0b9d78
fix(ui): square the trailing edge of phone-input country trigger
Luko248 2af4857
refactor(ui): align icon-control tokens to the Figma collection + mar…
Luko248 6ec75a5
docs(icon-button): blank lines around CSS fence + resync token example
Luko248 fdecac0
fix(ui): drop dangling text-icon-control-fg-hover ref in NumericInput
Luko248 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-<component>-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); | ||
| } | ||
| ``` | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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 <files>`; `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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| * <button> and read the `--text-icon-control-*` glyph tokens directly. | ||
| */ | ||
| const actionIconVariants = tv({ | ||
| base: [ | ||
| "inline-flex shrink-0 cursor-pointer items-center justify-center", | ||
| "rounded-icon-control text-icon-control-fg", | ||
| "transition-colors duration-200 motion-reduce:transition-none", | ||
| "focus-visible:outline-(style:--default-ring-style) focus-visible:outline-(length:--default-ring-width)", | ||
| "focus-visible:outline-offset-(length:--default-ring-offset) focus-visible:outline-icon-control-ring", | ||
| "disabled:cursor-not-allowed disabled:text-icon-control-fg-disabled", | ||
| ], | ||
| variants: { | ||
| size: { | ||
| sm: "size-icon-control-sm text-icon-control-sm", | ||
| md: "size-icon-control-md text-icon-control-md", | ||
| lg: "size-icon-control-lg text-icon-control-lg", | ||
| }, | ||
| tone: { | ||
| neutral: [ | ||
| "hover:bg-icon-control-bg-hover", | ||
| "active:bg-icon-control-bg-active", | ||
| ], | ||
| danger: [ | ||
| "hover:bg-icon-control-bg-danger-hover hover:text-icon-control-fg-danger-hover", | ||
| "active:bg-icon-control-bg-danger-active", | ||
| ], | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| }, | ||
| }, | ||
| defaultVariants: { | ||
| size: "md", | ||
| tone: "neutral", | ||
| }, | ||
| }) | ||
|
|
||
| type ActionIconVariants = VariantProps<typeof actionIconVariants> | ||
|
|
||
| export type ActionIconProps = Omit< | ||
| ButtonHTMLAttributes<HTMLButtonElement>, | ||
| "children" | ||
| > & | ||
| ActionIconVariants & { | ||
| icon: IconType | ||
| ref?: Ref<HTMLButtonElement> | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export function ActionIcon({ | ||
| icon, | ||
| size, | ||
| tone, | ||
| type = "button", | ||
| className, | ||
| ref, | ||
| ...props | ||
| }: ActionIconProps) { | ||
| return ( | ||
| <button | ||
| className={actionIconVariants({ size, tone, className })} | ||
| ref={ref} | ||
| type={type} | ||
| {...props} | ||
| > | ||
| <Icon icon={icon} size="current" /> | ||
| </button> | ||
| ) | ||
| } | ||
|
|
||
| ActionIcon.displayName = "ActionIcon" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.