{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "zone-reader",
  "title": "Zone Reader",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Escolher um fuso horário passando um filme sob a janela de um leitor de microficha, com hora e diferença ao vivo.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@visant/use-media-query",
    "@visant/use-idle"
  ],
  "files": [
    {
      "path": "registry/visant/ui/zone-reader.tsx",
      "content": "\"use client\";\n\n/*\n * ZONE: pick a time zone by sliding a strip of film under a reader.\n *\n * Collision: a dense index as the whole interface + a microfiche reader, whose\n * window stays still while the film moves and shows what it reads in negative.\n * The list is a native scroller with snap, so a swipe, a wheel and momentum all\n * work without code. Over it sits a second, inverted copy of the list, clipped to\n * the window: a row passing through really is half positive, half negative. Fast\n * scrolling stretches the window's trailing edge. Tap a row, or use the keys.\n * Time and offset are live, from Intl, for real IANA zones.\n */\nimport * as React from \"react\";\nimport { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from \"motion/react\";\nimport { useMediaQuery } from \"@/hooks/use-media-query\";\nimport { useInView } from \"@/hooks/use-idle\";\n\nconst ZONES = [\n  [\"Honolulu\", \"Pacific/Honolulu\"], [\"Los Angeles\", \"America/Los_Angeles\"], [\"Cidade do México\", \"America/Mexico_City\"],\n  [\"Nova York\", \"America/New_York\"], [\"São Paulo\", \"America/Sao_Paulo\"], [\"Lisboa\", \"Europe/Lisbon\"], [\"Londres\", \"Europe/London\"],\n  [\"Berlim\", \"Europe/Berlin\"], [\"Lagos\", \"Africa/Lagos\"], [\"Joanesburgo\", \"Africa/Johannesburg\"], [\"Dubai\", \"Asia/Dubai\"], [\"Mumbai\", \"Asia/Kolkata\"],\n  [\"Bangkok\", \"Asia/Bangkok\"], [\"Singapura\", \"Asia/Singapore\"], [\"Tóquio\", \"Asia/Tokyo\"], [\"Sydney\", \"Australia/Sydney\"], [\"Auckland\", \"Pacific/Auckland\"],\n].map(([city, tz]) => ({\n  city,\n  clock: new Intl.DateTimeFormat(\"pt-BR\", { timeZone: tz, hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\", hourCycle: \"h23\" }),\n  day: new Intl.DateTimeFormat(\"pt-BR\", { timeZone: tz, weekday: \"short\" }),\n  off: new Intl.DateTimeFormat(\"en-US\", { timeZone: tz, timeZoneName: \"shortOffset\" }),\n}));\ntype Zone = (typeof ZONES)[number];\nconst START = 4; // São Paulo\n\n/** Minutes east of UTC, from Intl's \"GMT-3\" / \"GMT+5:30\" / \"GMT\". */\nfunction offsetOf(z: Zone, now: Date) {\n  const name = z.off.formatToParts(now).find((p) => p.type === \"timeZoneName\")?.value ?? \"GMT\";\n  const m = name.match(/GMT([+-])(\\d+)(?::(\\d+))?/);\n  return m ? (m[1] === \"-\" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) : 0;\n}\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nfunction offsetLabel(min: number) {\n  const a = Math.abs(min);\n  return `${min < 0 ? \"−\" : \"+\"}${pad(Math.floor(a / 60))}${a % 60 ? `:${pad(a % 60)}` : \"\"}`;\n}\nfunction diffLabel(z: Zone, now: Date) {\n  const d = (offsetOf(z, now) + now.getTimezoneOffset()) / 60;\n  const h = d === 0 ? \"sua hora\" : `${d > 0 ? \"+\" : \"−\"}${String(Math.abs(d)).replace(\".\", \",\")} h`;\n  return `${h} · ${z.day.format(now).replace(\".\", \"\")}`;\n}\n\nfunction Rows({ now, R, on, selected, onPick }: { now: Date; R: number; on?: boolean; selected?: number; onPick?: (i: number) => void }) {\n  return (\n    <>\n      {ZONES.map((z, i) => (\n        <div\n          key={z.city}\n          id={on ? undefined : `fuso-${i}`}\n          role={on ? undefined : \"option\"}\n          aria-selected={on ? undefined : selected === i}\n          onClick={onPick && (() => onPick(i))}\n          style={{ height: R }}\n          className={`flex snap-center items-center justify-between gap-4 px-3 font-mono text-xs uppercase tabular-nums ${on ? \"\" : \"cursor-pointer text-muted-foreground\"}`}\n        >\n          <span className=\"truncate\">{z.city}</span>\n          <span>{offsetLabel(offsetOf(z, now))}</span>\n        </div>\n      ))}\n    </>\n  );\n}\n\nexport function ZoneReader() {\n  const reduce = useReducedMotion();\n  const coarse = useMediaQuery(\"(pointer: coarse)\");\n  const R = coarse ? 44 : 32; // a finger needs the taller row; same window height either way\n  const rows = coarse ? 5 : 7;\n  const H = R * rows;\n  const PAD = (R * (rows - 1)) / 2;\n\n  const tile = React.useRef<HTMLDivElement>(null);\n  const film = React.useRef<HTMLDivElement>(null);\n  const inView = useInView(tile);\n  const [i, setI] = React.useState(START);\n  const [now, setNow] = React.useState(() => new Date());\n\n  const scroll = useMotionValue(START * R);\n  const shift = useTransform(scroll, (v) => -v);\n  const speed = useSpring(useVelocity(scroll), { stiffness: 300, damping: 40 });\n  const stretch = (v: number) => (reduce ? 0 : Math.min(Math.abs(v) * 0.012, R));\n  const top = useTransform(speed, (v) => PAD - (v > 0 ? stretch(v) : 0));\n  const bottom = useTransform(speed, (v) => PAD - (v < 0 ? stretch(v) : 0));\n  const clip = useMotionTemplate`inset(${top}px 4px ${bottom}px 4px round var(--r-control))`;\n\n  React.useEffect(() => {\n    if (!inView) return;\n    setNow(new Date());\n    const t = window.setInterval(() => setNow(new Date()), 1000);\n    return () => window.clearInterval(t);\n  }, [inView]);\n\n  // Rows change height with the pointer type: keep the same city in the window.\n  React.useLayoutEffect(() => {\n    const el = film.current;\n    if (!el) return;\n    el.scrollTop = i * R;\n    scroll.set(i * R);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [R]);\n\n  const go = (n: number) => {\n    const next = Math.max(0, Math.min(ZONES.length - 1, n));\n    film.current?.scrollTo({ top: next * R, behavior: reduce ? \"auto\" : \"smooth\" });\n  };\n  const onKey = (e: React.KeyboardEvent) => {\n    const map: Record<string, number> = { ArrowDown: i + 1, ArrowUp: i - 1, PageDown: i + rows, PageUp: i - rows, Home: 0, End: ZONES.length - 1 };\n    if (!(e.key in map)) return;\n    e.preventDefault();\n    go(map[e.key]);\n  };\n\n  const zone = ZONES[i];\n  const [hm, sec] = [zone.clock.format(now).slice(0, 5), zone.clock.format(now).slice(5)];\n\n  return (\n    <div ref={tile} className=\"flex h-full flex-col gap-4\">\n      <div className=\"flex items-baseline justify-between gap-3 px-1\">\n        <motion.span\n          key={zone.city}\n          initial={reduce ? false : { y: 6, opacity: 0.4 }}\n          animate={{ y: 0, opacity: 1 }}\n          transition={{ type: \"spring\", stiffness: 420, damping: 34 }}\n          className=\"[font-family:var(--font-display,inherit)] text-[clamp(2.5rem,12vw,3.5rem)] font-medium leading-none tracking-[-0.03em] tabular-nums text-foreground\"\n        >\n          {hm}\n          <span className=\"text-muted-foreground\">{sec}</span>\n        </motion.span>\n        <span className=\"shrink-0 font-mono text-xs tabular-nums text-muted-foreground\">{diffLabel(zone, now)}</span>\n      </div>\n      <span className=\"sr-only\" aria-live=\"polite\">{`${zone.city}, ${hm}`}</span>\n\n      <div className=\"relative overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_22%,black_78%,transparent)]\" style={{ height: H }}>\n        <div\n          ref={film}\n          data-lab-target\n          role=\"listbox\"\n          tabIndex={0}\n          aria-label=\"Fuso horário\"\n          aria-activedescendant={`fuso-${i}`}\n          onKeyDown={onKey}\n          onScroll={(e) => {\n            const t = e.currentTarget.scrollTop;\n            scroll.set(t);\n            const n = Math.max(0, Math.min(ZONES.length - 1, Math.round(t / R)));\n            if (n !== i) setI(n);\n          }}\n          style={{ height: H, paddingBlock: PAD }}\n          className=\"snap-y snap-mandatory touch-pan-y select-none overflow-y-scroll rounded-[var(--r-control)] outline-none [scrollbar-width:none] focus-visible:[box-shadow:var(--focus)] [&::-webkit-scrollbar]:hidden\"\n        >\n          <Rows now={now} R={R} selected={i} onPick={go} />\n        </div>\n        {/* The window: the same film, in negative, moved only by transform. */}\n        <motion.div aria-hidden style={{ clipPath: clip }} className=\"pointer-events-none absolute inset-0 bg-foreground text-background\">\n          <motion.div style={{ y: shift, paddingBlock: PAD }}>\n            <Rows now={now} R={R} on />\n          </motion.div>\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Escolher um fuso horário ou um item de uma lista densa e monoespaçada",
      "evite": "Escolher uma hora: time-dial; poucas opções: segmented-control"
    }
  },
  "categories": [
    "texto",
    "fuso",
    "horario",
    "lista",
    "rolagem",
    "cidade",
    "relogio"
  ],
  "type": "registry:ui"
}