{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "icon-transition",
  "title": "Icon Transition",
  "type": "registry:ui",
  "description": "Animated icon swap for click acknowledgements — copy → check, bookmark → bookmark-filled, follow → follower. Pass two icon components and an async `action`; clicking runs the action then springs the active icon in. Auto-reverts after `resetAfter` ms (default 1500), or pass `0` to keep the active icon and call the exposed `reset()` method to flip it back. Renders as a `<button>` by default; use `as=\"span\"` plus the `active` prop when you want an externally controlled, non-interactive icon swap inside another control. Honors `prefers-reduced-motion`.",
  "categories": [
    "feedback"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/icon-transition/icon-transition.ts",
      "content": "import { LitElement, css, html, nothing, type TemplateResult } from 'lit'\nimport { keyed } from 'lit/directives/keyed.js'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport {\n  Bookmark,\n  BookmarkCheck,\n  Check,\n  Copy,\n  Heart,\n  Link2,\n  Plus,\n  Share2,\n  Star,\n  ThumbsUp,\n  UserCheck,\n  UserPlus,\n  X,\n  type IconNode,\n} from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\ninterface IconEntry {\n  node: IconNode\n  name: string\n}\n\n/**\n * Named icons for `default-icon` / `active-icon` (kebab-case lucide names).\n * React takes Lucide components; `slot=\"default-icon\"` / `slot=\"active-icon\"`\n * accept any other <svg>.\n */\nconst namedIcons: Record<string, IconEntry> = {\n  copy: { node: Copy, name: 'copy' },\n  check: { node: Check, name: 'check' },\n  bookmark: { node: Bookmark, name: 'bookmark' },\n  'bookmark-check': { node: BookmarkCheck, name: 'bookmark-check' },\n  heart: { node: Heart, name: 'heart' },\n  share: { node: Share2, name: 'share-2' },\n  'share-2': { node: Share2, name: 'share-2' },\n  'user-plus': { node: UserPlus, name: 'user-plus' },\n  'user-check': { node: UserCheck, name: 'user-check' },\n  star: { node: Star, name: 'star' },\n  'thumbs-up': { node: ThumbsUp, name: 'thumbs-up' },\n  plus: { node: Plus, name: 'plus' },\n  link: { node: Link2, name: 'link-2' },\n  'link-2': { node: Link2, name: 'link-2' },\n  x: { node: X, name: 'x' },\n}\n\n// Tailwind safelist (candidates are scanned from source text): `active-class`\n// / `icon-class` values are consumer strings applied inside the shadow root,\n// so the utilities they use must appear here to be compiled into the shadow\n// sheet. Covers the component default plus the demo gallery values.\n// text-success text-rose-500 text-info text-amber-500 text-emerald-500 fill-current size-3 size-4 size-5\n\nexport type IconTransitionAction = () => boolean | void | Promise<boolean | void>\n\n/**\n * <uip-icon-transition> — the registry IconTransition as a web component.\n *\n *   <uip-icon-transition default-icon=\"copy\" active-icon=\"check\" label=\"Copy URL\" active-label=\"Copied\"></uip-icon-transition>\n *\n * Click runs the optional async `action` (return `false` to skip the flip),\n * then swaps `default-icon` to `active-icon` with a spring pop; by default it\n * auto-reverts after `reset-after` ms (1500 — `0` stays active until\n * `reset()`). Two modes, like React: self-managed (default) or externally\n * controlled via `active`.\n *\n * Renders a <button> by default; `as=\"span\"` is purely visual (the parent\n * drives `trigger()` / `active`). The pop/fade keyframes are React's injected\n * styles verbatim in the static styles — the one allowed CSS exception for\n * React-injected keyframes.\n *\n * Icons: `default-icon` / `active-icon` kebab names (see `namedIcons`), or\n * slot any <svg> into `slot=\"default-icon\"` / `slot=\"active-icon\">`.\n * `icon-class` sizes the icons (default `size-4`); `active-class` tints the\n * root while active (default `text-success` — values must be utilities in the\n * shadow sheet, see the safelist above). The inner control fills the host, so\n * sizing/border/hover classes (React's `className`) go on the host as usual.\n *\n * Events (bubble, composed): `activate`, `reset`. Methods: `trigger()`, `reset()`.\n * Parts: `base` (the button/span), `icon-active`, `icon-default`.\n */\nexport class UipIconTransition extends LitElement {\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [\n    tailwind,\n    css`:host { display: inline-flex; }`,\n    // React's injected styles, verbatim (shadow-scoped: `.icon-transition` is\n    // the inner control).\n    css`\n      .icon-transition .icon-transition-slot {\n        grid-area: 1 / 1;\n        display: inline-flex;\n        align-items: center;\n        justify-content: center;\n      }\n      .icon-transition .icon-transition-enter-active,\n      .icon-transition .icon-transition-leave-active {\n        transform-origin: center;\n      }\n      .icon-transition .icon-transition-enter-active {\n        animation: icon-transition-pop var(--it-duration, 240ms) cubic-bezier(0.34, 1.56, 0.64, 1) both;\n      }\n      .icon-transition .icon-transition-leave-active {\n        animation: icon-transition-fade calc(var(--it-duration, 240ms) * 0.66) ease-in both;\n      }\n      @keyframes icon-transition-pop {\n        0% {\n          opacity: 0;\n          transform: scale(0.5) rotate(-12deg);\n        }\n        60% {\n          opacity: 1;\n          transform: scale(1.18) rotate(2deg);\n        }\n        100% {\n          opacity: 1;\n          transform: scale(1) rotate(0deg);\n        }\n      }\n      @keyframes icon-transition-fade {\n        to {\n          opacity: 0;\n          transform: scale(0.85);\n        }\n      }\n      @media (prefers-reduced-motion: reduce) {\n        .icon-transition .icon-transition-enter-active,\n        .icon-transition .icon-transition-leave-active {\n          animation-duration: 0ms !important;\n        }\n      }\n    `,\n  ]\n\n  static properties = {\n    defaultIcon: { attribute: 'default-icon' },\n    activeIcon: { attribute: 'active-icon' },\n    iconClass: { attribute: 'icon-class' },\n    action: { attribute: false },\n    resetAfter: {\n      attribute: 'reset-after',\n      converter: {\n        fromAttribute: (v: string | null) => (v === null ? 1500 : v === 'null' ? null : Number(v)),\n        toAttribute: (v: unknown) => String(v),\n      },\n    },\n    duration: { type: Number },\n    label: {},\n    activeLabel: { attribute: 'active-label' },\n    activeClass: { attribute: 'active-class' },\n    as: { reflect: true },\n    active: {\n      converter: {\n        fromAttribute: (v: string | null) => (v === null ? undefined : v !== 'false'),\n        toAttribute: (v: unknown) => (v == null ? null : String(v)),\n      },\n    },\n    internalActive: { state: true },\n    prevActive: { state: true },\n  }\n\n  defaultIcon = 'copy'\n  activeIcon = 'check'\n  iconClass = 'size-4'\n  action?: IconTransitionAction\n  resetAfter: number | null = 1500\n  duration = 240\n  label?: string\n  activeLabel?: string\n  activeClass = 'text-success'\n  as: 'button' | 'span' = 'button'\n  /** External control. When set (even false), it wins over internal state. */\n  active?: boolean\n  private internalActive = false\n  private prevActive: boolean | null = null\n  private prevIsActive = false\n  private timer?: ReturnType<typeof setTimeout>\n  private leaveTimer?: ReturnType<typeof setTimeout>\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'icon-transition')\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    clearTimeout(this.timer)\n    clearTimeout(this.leaveTimer)\n  }\n\n  private get isActive() {\n    return this.active !== undefined ? this.active : this.internalActive\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    // Hold the prior icon as the leaving slot for the cross-fade (React holds\n    // prevActive in state; Vue's <Transition> does it for free).\n    if (changed.has('active') || changed.has('internalActive')) {\n      const isActive = this.isActive\n      if (this.prevIsActive !== isActive) {\n        this.prevActive = this.prevIsActive\n        this.prevIsActive = isActive\n        clearTimeout(this.leaveTimer)\n        this.leaveTimer = setTimeout(() => (this.prevActive = null), this.duration)\n      }\n    }\n  }\n\n  /** Run the action (if any) and flip to the active icon. */\n  async trigger() {\n    if (this.action) {\n      const result = await this.action()\n      if (result === false) return\n    }\n    this.internalActive = true\n    this.dispatchEvent(new CustomEvent('activate', { bubbles: true, composed: true }))\n    if (this.timer) clearTimeout(this.timer)\n    if (this.resetAfter && this.resetAfter > 0) {\n      this.timer = setTimeout(() => this.reset(), this.resetAfter as number)\n    }\n  }\n\n  /** Revert to the default icon. */\n  reset() {\n    this.internalActive = false\n    this.dispatchEvent(new CustomEvent('reset', { bubbles: true, composed: true }))\n    if (this.timer) {\n      clearTimeout(this.timer)\n      this.timer = undefined\n    }\n  }\n\n  private renderIcon(slotActive: boolean): TemplateResult {\n    const key = slotActive ? this.activeIcon : this.defaultIcon\n    const entry = namedIcons[key]\n    const slotName = slotActive ? 'active-icon' : 'default-icon'\n    return html`<slot name=${slotName}>${entry ? icon(entry.node, entry.name, this.iconClass) : nothing}</slot>`\n  }\n\n  private renderSlot(slotActive: boolean, phase: 'enter' | 'leave') {\n    const phaseClass = phase === 'enter' ? 'icon-transition-enter-active' : 'icon-transition-leave-active'\n    // part on the slot's fallback icon is unreachable (icon() takes no attrs);\n    // expose the slot wrapper instead.\n    return html`<span part=${slotActive ? 'icon-active' : 'icon-default'} class=${cn('icon-transition-slot', phaseClass)}>\n      ${this.renderIcon(slotActive)}\n    </span>`\n  }\n\n  render() {\n    const isActive = this.isActive\n    const classes = cn(\n      'icon-transition focus-visible:ring-ring inline-grid size-full place-items-center transition-colors focus-visible:ring-2 focus-visible:outline-none',\n      isActive ? this.activeClass : '',\n    )\n    // `keyed` (React's `key=`): remount the entering slot on every flip so the\n    // pop animation restarts; the leaving slot mounts fresh each time by itself.\n    const body = html`\n      ${this.prevActive !== null && this.prevActive !== isActive ? this.renderSlot(this.prevActive, 'leave') : nothing}\n      ${keyed(`enter-${isActive}`, this.renderSlot(isActive, 'enter'))}\n    `\n    if (this.as === 'span') {\n      return html`<span\n        part=\"base\"\n        data-uipkge=\"\"\n        data-slot=\"icon-transition\"\n        aria-label=${isActive ? (this.activeLabel ?? this.label ?? nothing) : (this.label ?? nothing)}\n        aria-live=${isActive ? 'polite' : nothing}\n        class=${classes}\n        style=${styleMap({ '--it-duration': `${this.duration}ms` })}\n        >${body}</span\n      >`\n    }\n    return html`<button\n      part=\"base\"\n      data-uipkge=\"\"\n      data-slot=\"icon-transition\"\n      type=\"button\"\n      aria-label=${isActive ? (this.activeLabel ?? this.label ?? nothing) : (this.label ?? nothing)}\n      aria-live=${isActive ? 'polite' : nothing}\n      class=${classes}\n      style=${styleMap({ '--it-duration': `${this.duration}ms` })}\n      @click=${() => void this.trigger()}\n    >\n      ${body}\n    </button>`\n  }\n}\n\ncustomElements.get('uip-icon-transition') || customElements.define('uip-icon-transition', UipIconTransition)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-icon-transition': UipIconTransition\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/icon-transition/icon-transition.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
