{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "appearance-toggle",
  "title": "Appearance Toggle",
  "description": "Light and dark segmented toggle for an appearance row in a menu or settings sheet.",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "@pestoui/style",
    "@pestoui/toggle-group",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/components/appearance-toggle.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Moon, Sun } from \"lucide-react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\"\n\n/* The light/dark pair as it appears on an \"Appearance\" row — inside a profile\n   menu, a settings sheet, a topbar — rather than as a standalone page control.\n *\n * It reports intent and nothing else: no `documentElement.classList` write, no\n * storage, no `prefers-color-scheme` read. The caller owns the theme. A control\n * that mutates global state on click can only exist once per app — it cannot be\n * rendered twice on a page, cannot be previewed inside docs, and cannot be\n * driven by a store it has never heard of.\n *\n * Behaviour is `ToggleGroup type=\"single\"` rather than two buttons carrying an\n * `aria-pressed` each, because the group already *is* a real radio group: Radix\n * puts `role=\"radiogroup\"` on the root and `role=\"radio\"` + `aria-checked` on\n * each segment, and roving focus collapses the pair into one tab stop with the\n * arrow keys moving between them. That is the right model for a choice of\n * exactly one, and the keyboard half is the expensive half to rebuild. Only the\n * skin is replaced below. */\n\ntype AppearanceToggleValue = \"light\" | \"dark\"\n\n/* The raised pill is the entire affordance, so the selected fill has to sit a\n   step *lighter* than the track — and no single token spells that in both\n   modes. `--card` is the lightest surface in light mode but falls below\n   `--secondary` in dark, where `--accent` is the step above it; painting both\n   from `--card` would render the selected segment as a recess on a dark canvas.\n   The elevation shadow is no help there either, since every `--shadow-*` ink is\n   a warm near-black at 5%, so the lightness step is what has to carry it.\n\n   The track keeps the group's own radius rather than restating it, so the\n   segment radius is calibrated against it: an 11px groove insetting its\n   contents by 2px wants a 9px inner corner, or the pill reads as pressing\n   against the wall. `rounded-sm` is 9.6px, and it covers `sm` too — the group\n   already hands that size a 9px item, which is within a hair of the same\n   answer, so one token serves both instead of two.\n\n   The paired hover rules exist because `toggleVariants` ships `hover:bg-accent`\n   at the same specificity as `data-[state=on]:bg-card` — a tie is settled by\n   stylesheet order, so the selected pill pins its own hover fill rather than\n   leaving the winner to chance. */\nconst appearanceToggleSegmentVariants = cva(\n  \"rounded-sm px-0 text-muted-foreground data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-card dark:data-[state=on]:bg-accent dark:data-[state=on]:hover:bg-accent\",\n  {\n    variants: {\n      /* Wider than tall, so two of them read as one control instead of as two\n         icon buttons sitting next to each other. Both heights undercut the\n         group's own — 40px and 36px are page-control sizes, and a menu line\n         cannot spend either. */\n      size: {\n        sm: \"h-7 w-9 min-w-9\",\n        default: \"h-8 w-10 min-w-10\",\n      },\n    },\n    defaultVariants: {\n      size: \"default\",\n    },\n  }\n)\n\ntype AppearanceToggleSize = NonNullable<\n  VariantProps<typeof appearanceToggleSegmentVariants>[\"size\"]\n>\n\n/* `dir` is dropped because the group narrows it to `\"ltr\" | \"rtl\"` while the\n   div attribute is a bare string; direction comes from Radix's\n   `DirectionProvider` or an ancestor instead. */\ntype AppearanceToggleProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"dir\" | \"onChange\"\n> &\n  VariantProps<typeof appearanceToggleSegmentVariants> & {\n    /** Controlled selection. */\n    value?: AppearanceToggleValue\n    /** Uncontrolled starting selection. */\n    defaultValue?: AppearanceToggleValue\n    onValueChange?: (value: AppearanceToggleValue) => void\n    /** Accessible name for the radio group itself. */\n    srLabel?: string\n    /** Accessible name for the light segment, which renders icon-only. */\n    lightSrLabel?: string\n    /** Accessible name for the dark segment, which renders icon-only. */\n    darkSrLabel?: string\n  }\n\n/* The Switch's travel, for the pill. At rest the pill IS the selected\n   segment's own fill, and nothing below exists. During a change made here, a\n   stand-in pill is drawn on the track's `::before`, the segments' own fills\n   are hidden, and the stand-in runs `travel-slide` — overshoot, settle and the\n   arrival squish — from the old segment to the new one. On animationend the\n   flag drops and the real fill is back under it on the same pixel.\n\n   Every class is gated on `data-travelling`. An ungated `before:` utility\n   emits `content`, which would create the pseudo-element on every instance at\n   rest.\n\n   Nothing about the journey is assumed; handleValueChange measures it (see\n   measureTravel). The stand-in is anchored on the light segment, so its rest\n   is `--appearance-x`: the destination segment's offset from that one, read\n   off the laid-out segments. That follows the segment width, the gap and any\n   `className` override of either, and it carries its own sign — in RTL the\n   dark segment lays out to the left of light and the offset comes back\n   negative — so no direction selector exists here at all. Both candidates\n   were wrong: the production build rewrites `:dir()` into a `:lang()` list\n   that ignores `dir=\"rtl\"` on an English page, and an ancestor `[dir=rtl]`\n   misses twice over, because Radix writes the resolved `dir` onto the group\n   itself — `ltr` under an RTL page with no DirectionProvider, so the row lays\n   out left to right while the ancestor selector flips the sign, and `rtl`\n   from a provider with no ancestor attribute to match. Measured, the layout\n   is the only source that is right in both.\n\n   The start is wherever the pill visibly is: the fill, including any press\n   lean it is still carrying, or the stand-in itself when the change reverses\n   a journey half run.\n\n   `translate-y-0` looks redundant and is not. `travel-slide` adds to\n   --tw-translate-y, whose registered initial value is a unitless 0, and\n   `calc(0 + 0px)` is invalid — measured, the whole `translate` computed to\n   `none` and the pill never left its slot. Placing it on both axes hands the\n   keyframes a length. */\nconst appearanceToggleTravel = [\n  \"data-travelling:relative\",\n  \"data-travelling:before:absolute data-travelling:before:start-0.5 data-travelling:before:top-0.5 data-travelling:before:h-8 data-travelling:before:w-10 data-travelling:before:rounded-sm data-travelling:before:bg-card data-travelling:before:shadow-xs data-travelling:data-[size=sm]:before:h-7 data-travelling:data-[size=sm]:before:w-9 dark:data-travelling:before:bg-accent\",\n  \"data-travelling:before:translate-y-0 data-travelling:before:translate-x-(--appearance-x) data-travelling:before:animate-travel-slide\",\n].join(\" \")\n\n/* Written inline on the root for one journey and removed when it ends. */\nconst TRAVEL_PROPERTIES = [\"--appearance-x\", \"--travel-dx\", \"--travel-dir-x\"] as const\n\n/* The x of a computed translate, in px. Off a pseudo-element that does not\n   exist, or an element at rest, it reads `none`. */\nfunction translateX(style: CSSStyleDeclaration) {\n  return parseFloat(style.translate.split(\" \")[0]) || 0\n}\n\n/* The inputs `travel-slide` needs to carry the pill from where it visibly is\n   to `to`, as tabs measures its indicator. Positions are box centres — a\n   centred press squeeze moves an edge but never the centre — with each\n   segment's own translate backed out where a rest is wanted, and left in\n   where the visible fill is. The keyframes read the rest back off the\n   stand-in's translate, so --travel-dx is origin minus rest. Measured before\n   anything is written: mid-flight, the origin is the stand-in's animated\n   translate, not either end of the track. */\nfunction measureTravel(\n  root: HTMLElement,\n  from: AppearanceToggleValue,\n  to: AppearanceToggleValue\n): Record<(typeof TRAVEL_PROPERTIES)[number], string> | null {\n  const [light, dark] = root.querySelectorAll<HTMLElement>(\n    '[data-slot=\"toggle-group-item\"]'\n  )\n  if (!light || !dark) return null\n  const centre = (el: HTMLElement) => {\n    const r = el.getBoundingClientRect()\n    return r.left + r.width / 2\n  }\n  const rest = (el: HTMLElement) => centre(el) - translateX(getComputedStyle(el))\n  const anchor = rest(light)\n  const restX = rest(to === \"light\" ? light : dark) - anchor\n  const fromX = root.hasAttribute(\"data-travelling\")\n    ? translateX(getComputedStyle(root, \"::before\"))\n    : centre(from === \"light\" ? light : dark) - anchor\n  const dx = fromX - restX\n  return {\n    \"--appearance-x\": `${restX}px`,\n    \"--travel-dx\": `${dx}px`,\n    \"--travel-dir-x\": `${Math.sign(-dx)}`,\n  }\n}\n\n/* The segments step aside for the stand-in while it travels: positioned, so\n   they paint above an absolute `::before`, and with their fill and shadow\n   hidden. The fill is hidden by clipping it to text — of which an icon-only\n   segment has none — rather than by `bg-transparent`, because\n   `background-clip` is not transitioned and `background-color` is: a\n   transparent swap would fade the old fill out as a ghost behind the departing\n   pill, and fade the new one in under the pill's last frame. The icon colour\n   still flips at once, so the state answers before the pill arrives. */\nconst appearanceToggleSegmentTravel =\n  \"group-data-travelling/toggle-group:relative group-data-travelling/toggle-group:bg-clip-text group-data-travelling/toggle-group:shadow-none!\"\n\nfunction AppearanceToggle({\n  className,\n  size = \"default\",\n  value,\n  defaultValue = \"light\",\n  onValueChange,\n  onAnimationEnd,\n  ref,\n  srLabel = \"Appearance\",\n  lightSrLabel = \"Light\",\n  darkSrLabel = \"Dark\",\n  ...props\n}: AppearanceToggleProps) {\n  const [uncontrolled, setUncontrolled] =\n    React.useState<AppearanceToggleValue>(defaultValue)\n  const current = value ?? uncontrolled\n\n  /* Where the pill is travelling to, armed only by a change made through this\n     control — never on mount, and never for a `value` changed from outside,\n     which swaps instantly as it always has. If a controlled consumer declines\n     the change, the arm is dropped rather than left to fire on some later,\n     unrelated value change. */\n  const [travelling, setTravelling] = React.useState<AppearanceToggleValue>()\n  if (travelling !== undefined && travelling !== current) {\n    setTravelling(undefined)\n  }\n\n  const root = React.useRef<HTMLDivElement>(null)\n  /* Returns a cleanup, so React 19 calls that on detach instead of calling\n     this with null — which means the cleanup has to do everything a null call\n     would have: drop our own handle, then release the consumer's, either\n     through the cleanup their ref returned or with the null it would\n     otherwise have been given.\n\n     It also carries the `animationcancel` listener, which React has no prop\n     for. A part hidden mid-journey without unmounting (`display: none`, a\n     `forceMount` container) reports only that, never `animationend`; left\n     armed, it would replay the travel the next time it is shown. */\n  const setRoot = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      root.current = node\n      const cancelTravel = (event: AnimationEvent) => {\n        if (event.target === node && event.pseudoElement === \"::before\") {\n          setTravelling(undefined)\n        }\n      }\n      node?.addEventListener(\"animationcancel\", cancelTravel)\n      const consumerCleanup = typeof ref === \"function\" ? ref(node) : undefined\n      if (ref && typeof ref !== \"function\") ref.current = node\n      return () => {\n        node?.removeEventListener(\"animationcancel\", cancelTravel)\n        root.current = null\n        if (typeof consumerCleanup === \"function\") consumerCleanup()\n        else if (typeof ref === \"function\") ref(null)\n        else if (ref) ref.current = null\n      }\n    },\n    [ref]\n  )\n\n  /* The measured journey lives inline on the root only while it runs. Every\n     commit at rest clears it, which covers a journey that ended, one that was\n     cancelled, and an arm a controlled consumer declined before it painted. */\n  React.useLayoutEffect(() => {\n    const node = root.current\n    if (travelling !== undefined || !node) return\n    for (const name of TRAVEL_PROPERTIES) node.style.removeProperty(name)\n  })\n\n  /* A single-selection ToggleGroup is deselectable by design — pressing the\n     active segment reports `\"\"`. There is no \"no appearance\", so the empty\n     value is swallowed rather than forwarded, which is also why the group is\n     always driven as controlled from here even when the consumer left it\n     uncontrolled. */\n  const handleValueChange = (next: string) => {\n    if (next !== \"light\" && next !== \"dark\") return\n    const node = root.current\n    const travel = next !== current && node ? measureTravel(node, current, next) : null\n    if (node && travel) {\n      /* Written here rather than through render, so the journey and its rest\n         change in the same tick as the measurement they came from. */\n      for (const [name, measured] of Object.entries(travel)) {\n        node.style.setProperty(name, measured)\n      }\n      /* Switched back mid-journey: rewind the running keyframes, as the\n         Switch does, so they replay from the 0% stop — which the measurement\n         just put on the pill's current pixel — instead of carrying their\n         progress over to the new endpoints. */\n      for (const animation of node.getAnimations({ subtree: true })) {\n        if (\n          \"animationName\" in animation &&\n          animation.effect instanceof KeyframeEffect &&\n          animation.effect.pseudoElement === \"::before\"\n        ) {\n          animation.currentTime = 0\n        }\n      }\n      setTravelling(next)\n    }\n    if (value === undefined) setUncontrolled(next)\n    onValueChange?.(next)\n  }\n\n  const segmentClassName = cn(\n    appearanceToggleSegmentVariants({ size }),\n    appearanceToggleSegmentTravel\n  )\n\n  return (\n    <ToggleGroup\n      data-slot=\"appearance-toggle\"\n      data-travelling={travelling}\n      ref={setRoot}\n      onAnimationEnd={(event) => {\n        if (\n          event.target === event.currentTarget &&\n          event.nativeEvent.pseudoElement === \"::before\"\n        ) {\n          setTravelling(undefined)\n        }\n        onAnimationEnd?.(event)\n      }}\n      type=\"single\"\n      /* Forwarded so the group emits one `data-size` on the root for\n         descendants, and so its own per-size icon sizing lands on the\n         segments. The track radius is deliberately left to it. */\n      size={size}\n      /* 2px between segments. Also what keeps the group's `data-[spacing=0]`\n         flush treatment — square middles, 16px outer corners — from firing on\n         a control that wants neither. */\n      spacing={0.5}\n      value={current}\n      onValueChange={handleValueChange}\n      aria-label={srLabel}\n      className={cn(\"bg-secondary p-0.5\", appearanceToggleTravel, className)}\n      {...props}\n    >\n      <ToggleGroupItem\n        value=\"light\"\n        aria-label={lightSrLabel}\n        className={segmentClassName}\n      >\n        <Sun aria-hidden=\"true\" />\n      </ToggleGroupItem>\n      <ToggleGroupItem\n        value=\"dark\"\n        aria-label={darkSrLabel}\n        className={segmentClassName}\n      >\n        <Moon aria-hidden=\"true\" />\n      </ToggleGroupItem>\n    </ToggleGroup>\n  )\n}\n\nexport { AppearanceToggle, appearanceToggleSegmentVariants }\nexport type { AppearanceToggleProps, AppearanceToggleSize, AppearanceToggleValue }\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}