"use client"
import * as React from "react"
import { cn } from "@/registry/lib/utils"
import { renderIconProp, type IconProp } from "@/registry/lib/icon-prop"
import { Button, type ButtonProps } from "@/registry/ui/button"
import { buttonGap, buttonIconSidePadding } from "@/registry/ui/button-variants"
type SwapButtonState = {
/** An element (`<IconCopy />`), like every other icon prop in the kit. */
icon?: IconProp
label: React.ReactNode
}
type SwapButtonProps = Omit<ButtonProps, "iconStart" | "iconEnd" | "children"> & {
/** Every face the button can show, keyed by name. All of them are rendered,
* so the button is always as wide as the widest one. */
states: Record<string, SwapButtonState>
/** The key of the face showing now. */
state: string
}
// Every face is an icon-and-label pair in ONE grid cell, centred in it. The
// cell is as wide as the widest pair, so the button never changes width and
// nothing beside it moves, and a narrower face sits in the middle rather than
// against one edge. Hidden faces are `invisible`: they keep their box (that
// is what holds the width) and leave the accessibility tree, so a screen
// reader reads only the face that shows.
//
// Only the INCOMING face fades. The outgoing one drops out at once, so two
// faces are never painted together, which is the crossfade ThemeToggle
// rejects. A transition and not a keyframe, because a face can be swapped
// repeatedly and a keyframe enter restarts from zero each time. Opacity only:
// every Tabler glyph already carries `scale` for the kit's optical correction.
//
// Each pair takes Button's own icon-to-label gap for its size, from the table
// Button itself reads (`buttonGap`). It cannot inherit it: Button wraps its
// children in a `display: contents` span, and `gap: inherit` reads that span's
// gap, which is none, so the icon landed flush against the label.
//
// The icon-side padding trim works the same way. Button only applies it to
// `iconStart`, which SwapButton never passes, so it applies
// `buttonIconSidePadding` itself when EVERY face leads with an icon. A face
// set that mixes icon and text-only faces keeps symmetric padding, since there
// is no single icon side to balance.
const faceBase = "col-start-1 row-start-1 inline-flex items-center justify-center"
const faceShown = "opacity-100 transition-[opacity] duration-150 ease-out"
const faceHidden = "invisible opacity-0"
/**
* A button that changes what it says with a state, at one fixed width: the
* new icon and label fade in where the old ones were. Everything else is a
* normal `Button`, and `onClick` is yours, so the state can mean anything.
*/
// @use-when a button whose icon and label change with a state (Use, then Copy, then Copied) without changing width. For a plain copy button, `CopyLabelButton` is this already wired to the clipboard.
function SwapButton({ states, state, size, className, ...props }: SwapButtonProps) {
const sizeKey = size ?? "default"
const gap = sizeKey in buttonGap ? buttonGap[sizeKey as keyof typeof buttonGap] : buttonGap.xs
const faces = Object.values(states)
const iconPad = faces.length > 0 && faces.every((f) => f.icon) ? buttonIconSidePadding[sizeKey]?.start : undefined
return (
<Button data-slot="swap-button" data-state={state} size={size} className={cn(iconPad, className)} {...props}>
<span className="grid">
{Object.entries(states).map(([key, face]) => (
<span key={key} className={cn(faceBase, gap, key === state ? faceShown : faceHidden)}>
{renderIconProp(face.icon, { "aria-hidden": true })}
{face.label}
</span>
))}
</span>
</Button>
)
}
export { SwapButton }
export type { SwapButtonProps, SwapButtonState }