import type { ReactNode } from "react"; import { tv } from "../../core/tv"; import type { AccentScopeProps } from "../containers/AccentScope"; import { AccentScope } from "../containers/AccentScope"; import { Icon, type IconProps, type SVGIconElement } from "./Icon"; import { View } from "./View"; // Two stacked glyphs cross-fading on opacity, the same material as the // SegmentedItem chip: the swap has to be CSS, driven by the `group` on the // enclosing pressable, because a JS hover state would re-render the whole row. // The opacity classes live on wrapping Views and never on Icon — Icon drops its // className on native and only resolves the `text-*` tint from it. Focus // counts on `focus-visible` only: a mouse click leaves the focus behind, and // the swapped glyph would otherwise stay until the next click elsewhere. const interactiveIconVariants = tv({ slots: { frame: "relative shrink-0", rest: "transition-opacity duration-fast ease-in group-hover:opacity-0 group-focus-visible:opacity-0 group-active:opacity-0", active: "absolute inset-0 opacity-0 transition-opacity duration-fast ease-in group-hover:opacity-100 group-focus-visible:opacity-100 group-active:opacity-100", }, variants: { active: { true: { rest: "opacity-0", active: "opacity-100" }, false: {}, }, }, defaultVariants: { active: false }, }); export interface InteractiveIconProps extends IconProps { /** * Rendered in place of `icon` while the enclosing pressable group is hovered, * focused or pressed, and permanently when `active`. Typically the duotone * twin of `icon`. */ activeIcon?: SVGIconElement; /** * Accent tinting `activeIcon`, so the glyph changes color as well as weight. * `neutral` resets to the base mode's accent under an accented ancestor. */ activeAccent?: AccentScopeProps["accent"]; /** Persistent active state: the current page, the selected segment. */ active?: boolean; /** A disabled control gives no affordance, so it never swaps. */ disabled?: boolean; } /** * Icon that swaps to `activeIcon` on the enclosing pressable's * hover/focus/press state. The pressable must carry the `group` class — * `PressableBox` and `SegmentedItem` both do. */ export function InteractiveIcon({ icon, activeIcon, activeAccent, active = false, disabled = false, size = 20, className = "text-sharp", }: InteractiveIconProps): ReactNode { if (activeIcon === undefined || disabled) { return ( ); } const styles = interactiveIconVariants({ active }); // An accent only reaches a glyph through `text-accent`: the resting tints // (`text-sharp`, `text-muted`) are grayscale-only tokens that an accent theme // never redeclares, so scoping alone would be inert. Same pairing as Button's // terminal icon. const activeClassName = activeAccent ? "text-accent" : className; return ( ); }