{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select",
  "title": "Select",
  "description": "A choice set inside a sentence: the chosen word is yours, italic over a hairline. It can be reset from the last word's letters, or wear the other choices above it as ruby.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\n\ntype SelectProps = React.ComponentProps<\"select\"> & {\n  /** The words before the choice: \"Sort by\". Clicking them opens the list. */\n  label?: React.ReactNode\n  /**\n   * underline (default): the chosen word, italic over a hairline.\n   * compose: a new choice is set from the letters of the last; the ones they share slide over.\n   * ruby: the other choices are set small above the word; point at one to pick it.\n   */\n  variant?: \"underline\" | \"compose\" | \"ruby\"\n  /** Classes for the sentence around the choice. className goes to the select. */\n  rootClassName?: string\n  /** Pins a state for documentation (\"hover\"); set on the root. */\n  \"data-force\"?: string\n}\n\ntype Choice = { value: string; label: string }\n\nconst text = (node: React.ReactNode): string =>\n  typeof node === \"string\" || typeof node === \"number\" ? String(node) : Array.isArray(node) ? node.map(text).join(\"\") : \"\"\n\n/** The <option>s among the children, for the first paint. After that the select itself is read. */\nfunction choicesOf(children: React.ReactNode): Choice[] {\n  return React.Children.toArray(children).flatMap((child) => {\n    if (!React.isValidElement<React.ComponentProps<\"option\">>(child) || child.type !== \"option\") return []\n    const label = text(child.props.children)\n    return [{ value: child.props.value === undefined ? label : String(child.props.value), label }]\n  })\n}\n\nconst read = (el: HTMLSelectElement): Choice[] => Array.from(el.options, (o) => ({ value: o.value, label: o.text }))\nconst graphemes = (s: string) => Array.from(new Intl.Segmenter(undefined, { granularity: \"grapheme\" }).segment(s), (g) => g.segment)\n/** Distance from the word's inline start to a letter's inline start, so right to left measures the same way. */\nfunction edge(word: HTMLElement) {\n  const rtl = getComputedStyle(word).direction === \"rtl\"\n  const box = word.getBoundingClientRect()\n  const at = (el: Element) => {\n    const r = el.getBoundingClientRect()\n    return rtl ? box.right - r.right : r.left - box.left\n  }\n  return Object.assign(at, { dir: rtl ? -1 : 1 })\n}\n/** A tempo token in milliseconds: the tokens are written in seconds or milliseconds. */\nconst ms = (v: string, fallback: number) => (v.trim().endsWith(\"ms\") ? parseFloat(v) : parseFloat(v) * 1000) || fallback\nconst still = () => typeof matchMedia === \"undefined\" || matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n/** A choice inside a sentence. The chosen word is yours, in italic over a hairline. Give it <option>s. */\nfunction Select({ label, variant = \"underline\", className, rootClassName, \"data-force\": force, children, onChange, ref: forwardedRef, ...props }: SelectProps) {\n  const Root = label ? \"label\" : \"span\"\n  const drawn = variant !== \"underline\"\n  const select = React.useRef<HTMLSelectElement>(null)\n  const composedRef = useComposedRefs(select, forwardedRef)\n  const box = React.useRef<HTMLSpanElement>(null)\n  const word = React.useRef<HTMLSpanElement>(null)\n  const ghosts = React.useRef<HTMLSpanElement>(null)\n  const ruby = React.useRef<HTMLSpanElement>(null)\n  // Where things stood the moment before a change, so the layout effect can move them from there.\n  const before = React.useRef<{ width: number; sorts: { ch: string; x: number }[]; line?: DOMRect; picked?: DOMRect; tier: Map<string, number>; was: string } | null>(null)\n\n  const [choices, setChoices] = React.useState<Choice[]>(() => choicesOf(children))\n  const [current, setCurrent] = React.useState<string>(() => {\n    const want = props.value ?? props.defaultValue\n    return want === undefined ? (choicesOf(children)[0]?.value ?? \"\") : String(want)\n  })\n\n  // The select is the truth: read it after mount, and whenever a controlled value or the options change.\n  React.useLayoutEffect(() => {\n    if (!drawn || !select.current) return\n    setChoices(read(select.current))\n    setCurrent(select.current.value)\n  }, [drawn, props.value, children])\n\n  const chosen = choices.find((c) => c.value === current)?.label ?? \"\"\n\n  React.useLayoutEffect(() => {\n    const from = before.current\n    before.current = null\n    if (!from || !box.current || !word.current || still()) return\n    const css = getComputedStyle(box.current)\n    const moderato = ms(css.getPropertyValue(\"--ot-moderato\"), 320)\n    const allegro = ms(css.getPropertyValue(\"--ot-allegro\"), 160)\n    const breath = css.getPropertyValue(\"--ot-breath\").trim() || \"ease\"\n    const spiccato = css.getPropertyValue(\"--ot-spiccato\").trim() || \"ease-out\"\n    const width = box.current.offsetWidth\n    if (width !== from.width) box.current.animate([{ width: `${from.width}px` }, { width: `${width}px` }], { duration: moderato, easing: breath })\n\n    if (variant === \"compose\") {\n      // The compositor's stick: every sort the two words share slides to its new place (the nearest\n      // one of the same letter), the ones the new word doesn't need drop out, and the rest are set in.\n      const at = edge(word.current)\n      const used = new Set<number>()\n      Array.from(word.current.children as HTMLCollectionOf<HTMLElement>).forEach((sort) => {\n        const x = at(sort)\n        const ch = (sort.textContent ?? \"\").toLocaleLowerCase()\n        let best = -1\n        from.sorts.forEach((s, j) => {\n          if (!used.has(j) && s.ch.toLocaleLowerCase() === ch && ch.trim() && (best < 0 || Math.abs(s.x - x) < Math.abs(from.sorts[best].x - x))) best = j\n        })\n        if (best >= 0) {\n          used.add(best)\n          sort.animate([{ translate: `${(from.sorts[best].x - x) * at.dir}px 0` }, { translate: \"0 0\" }], { duration: moderato, easing: breath })\n        } else {\n          sort.animate([{ opacity: 0, translate: \"0 -0.5em\" }, { opacity: 1, translate: \"0 0\" }], { duration: moderato, delay: allegro / 2, easing: spiccato, fill: \"backwards\" })\n        }\n      })\n      from.sorts.forEach((s, j) => {\n        if (used.has(j) || !ghosts.current) return\n        const ghost = document.createElement(\"span\")\n        ghost.textContent = s.ch\n        ghost.style[at.dir > 0 ? \"left\" : \"right\"] = `${s.x}px` // physical: the ghosts' box takes the page's direction, the word its own\n        ghosts.current.append(ghost)\n        ghost.animate([{ opacity: 1 }, { opacity: 0, translate: \"0 0.5em\" }], { duration: allegro * 1.5, easing: breath, fill: \"forwards\" }).finished.then(\n          () => ghost.remove(),\n          () => ghost.remove(),\n        )\n      })\n    }\n\n    if (variant === \"ruby\" && from.line) {\n      // Interlinear: the picked word comes down out of the ruby onto the line, and the old one goes up into its place.\n      // Each word travels between the two tiers and inks in as it arrives; neither is scaled, so neither is ever set in the other's face.\n      const move = (el: Element | null | undefined, start?: DOMRect) => {\n        if (!el || !start) return\n        const end = el.getBoundingClientRect()\n        const dx = getComputedStyle(el).direction === \"rtl\" ? start.right - end.right : start.left - end.left\n        el.animate([{ translate: `${dx}px ${start.bottom - end.bottom}px`, opacity: 0 }, { translate: \"0 0\", opacity: 1 }], { duration: moderato, easing: breath })\n      }\n      // The words that stay in the ruby close up (or open) round the change.\n      Array.from(ruby.current?.children ?? [], (el) => {\n        const left = from.tier.get((el as HTMLElement).dataset.value ?? \"\")\n        const dx = left === undefined ? 0 : left - el.getBoundingClientRect().left\n        if (Math.abs(dx) > 0.5) el.animate([{ translate: `${dx}px 0` }, { translate: \"0 0\" }], { duration: moderato, easing: breath })\n      })\n      move(word.current, from.picked)\n      move(ruby.current?.querySelector(`[data-value=\"${CSS.escape(from.was)}\"]`), from.line)\n    }\n  }, [current, variant])\n\n  const change = (e: React.ChangeEvent<HTMLSelectElement>) => {\n    onChange?.(e)\n    if (!drawn) return\n    const next = e.currentTarget.value\n    if (box.current && word.current) {\n      const at = edge(word.current)\n      before.current = {\n        width: box.current.offsetWidth,\n        sorts: Array.from(word.current.children, (s) => ({ ch: s.textContent ?? \"\", x: at(s) })),\n        line: word.current.getBoundingClientRect(),\n        picked: ruby.current?.querySelector(`[data-value=\"${CSS.escape(next)}\"]`)?.getBoundingClientRect(),\n        tier: new Map(Array.from(ruby.current?.children ?? [], (el) => [(el as HTMLElement).dataset.value ?? \"\", el.getBoundingClientRect().left])),\n        was: current,\n      }\n    }\n    setCurrent(next)\n  }\n\n  const pick = (value: string) => {\n    const el = select.current\n    if (!el || el.disabled) return\n    el.value = value\n    el.dispatchEvent(new Event(\"change\", { bubbles: true }))\n    el.focus({ preventScroll: true, focusVisible: false } as FocusOptions)\n  }\n\n  return (\n    <Root data-slot=\"select\" data-variant={drawn ? variant : undefined} data-force={force} className={cn(\"ot-select\", rootClassName)}>\n      {label ? <span>{label}</span> : null}\n      <span ref={box} className=\"ot-select-box\">\n        <select ref={composedRef} data-slot=\"select-input\" className={className} onChange={change} {...props}>\n          {children}\n        </select>\n        {drawn ? (\n          <span ref={word} className=\"ot-select-word\" dir=\"auto\" aria-hidden=\"true\">\n            {variant === \"compose\" ? graphemes(chosen).map((g, i) => <span key={i}>{g}</span>) : chosen}\n          </span>\n        ) : null}\n        {variant === \"compose\" ? <span ref={ghosts} className=\"ot-select-ghosts\" aria-hidden=\"true\" /> : null}\n        {variant === \"ruby\" ? (\n          // A shortcut for the pointer only: the select below holds the same choices for the keyboard and screen readers.\n          <span ref={ruby} className=\"ot-select-ruby\" aria-hidden=\"true\">\n            {choices\n              .filter((c) => c.value !== current)\n              .map((c) => (\n                <span\n                  key={c.value}\n                  data-value={c.value}\n                  onClick={(e) => {\n                    e.preventDefault()\n                    pick(c.value)\n                  }}\n                >\n                  {c.label}\n                </span>\n              ))}\n          </span>\n        ) : null}\n      </span>\n    </Root>\n  )\n}\n\nexport { Select, type SelectProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/select.css",
      "content": "@layer components {\n  /* ── ot-select ── a choice inside a sentence */\n  .ot-select { display: inline-flex; flex-wrap: wrap; align-items: baseline; column-gap: 0.25em; font-size: var(--ot-mp); line-height: var(--ot-mp-lh); color: var(--ot-graphite); }\n  .ot-select-box { position: relative; display: inline-flex; align-items: baseline; }\n  .ot-select-box::after {\n    content: \"↓\" / \"\"; position: absolute; inset-inline-end: 0; bottom: 0.3em;\n    font-family: var(--ot-voice); font-size: 0.72em; line-height: 1; color: var(--ot-pencil); pointer-events: none;\n    transition: translate var(--ot-moderato) var(--ot-exhale), color var(--ot-allegro);\n  }\n  :is(.ot-select-box:hover, .ot-select[data-force~=\"hover\"] .ot-select-box)::after { translate: 0 2px; color: var(--ot-ink); }\n  .ot-select select {\n    -webkit-appearance: none; appearance: none; field-sizing: content;\n    margin: 0; border: 0; border-radius: 0; padding: 0 0.6em 0.1em 0; /* the arrow sits in the padding */\n    background: linear-gradient(var(--ot-rule-strong), var(--ot-rule-strong)) no-repeat 0 100% / calc(100% - 0.6em) var(--ot-hairline);\n    color: var(--ot-ink); cursor: pointer;\n    font-family: var(--ot-expression); font-style: italic; font-weight: 400;\n    font-size: calc(var(--ot-expression-scale) * 1em); line-height: 1.2;\n  }\n  .ot-select select:is([dir=\"rtl\"], [dir=\"rtl\"] *) { padding: 0 0 0.1em 0.6em; background-position: 100% 100%; }\n  .ot-select select:focus-visible { outline-offset: 4px; }\n  .ot-select select:disabled { color: var(--ot-pencil); cursor: not-allowed; }\n  @supports (appearance: base-select) {\n    .ot-select select, .ot-select select::picker(select) { appearance: base-select; }\n    .ot-select select { display: inline-flex; min-block-size: 0; }\n    .ot-select select::picker-icon { display: none; }\n    .ot-select select::picker(select) {\n      margin-top: var(--ot-space-2); padding: var(--ot-space-2) 0; border: 0;\n      background: var(--ot-paper); box-shadow: inset 0 0 0 var(--ot-hairline) var(--ot-rule);\n      color: var(--ot-graphite); scrollbar-width: none; /* the picker is the browser's own: it takes no rail, and only a very long list scrolls */\n    }\n    .ot-select option { padding: 0.3em 1.1em; }\n    .ot-select option::checkmark { display: none; }\n    .ot-select option { position: relative; padding-inline-start: 1.6em; }\n    .ot-select option:checked { color: var(--ot-ink); }\n    .ot-select option:checked::before { content: \"\"; position: absolute; inset-inline-start: 0.7em; top: calc(50% - 3px); width: 6px; height: 6px; border-radius: 50%; background: var(--ot-accent); }\n    .ot-select option:is(:hover, :focus-visible) { background: var(--ot-mark); color: var(--ot-on-mark); outline: 0; }\n    .ot-select select::picker(select) { transition: opacity var(--ot-moderato) var(--ot-exhale), translate var(--ot-moderato) var(--ot-exhale), overlay var(--ot-moderato) allow-discrete, display var(--ot-moderato) allow-discrete; }\n    @starting-style { .ot-select select:open::picker(select) { opacity: 0; translate: 0 -6px; } }\n    .ot-select select:open option { animation: ot-arrive var(--ot-moderato) var(--ot-exhale) backwards; animation-delay: calc((sibling-index() - 1) * var(--ot-arpeggio)); }\n  }\n\n  /* ── The drawn variants: the select keeps the focus, the keyboard and the list, but its own words\n     are made invisible and a copy is set in the same place that script can move. The hairline moves\n     to the box, so it can stretch between the old word's width and the new one's. */\n  .ot-select[data-variant] .ot-select-box { display: inline-grid; background: linear-gradient(var(--ot-rule-strong), var(--ot-rule-strong)) no-repeat 0 100% / calc(100% - 0.6em) var(--ot-hairline); }\n  .ot-select[data-variant] .ot-select-box:is([dir=\"rtl\"], [dir=\"rtl\"] *) { background-position: 100% 100%; }\n  .ot-select[data-variant] .ot-select-box > * { grid-area: 1 / 1; }\n  .ot-select[data-variant] select { color: transparent; background: none; }\n  .ot-select[data-variant] option { color: var(--ot-ink); } /* a native list would take the select's colour */\n  .ot-select-word, .ot-select-ghosts {\n    padding: 0 0.6em 0.1em 0; white-space: pre; pointer-events: none; color: var(--ot-ink);\n    font-family: var(--ot-expression); font-style: italic; font-weight: 400; font-size: calc(var(--ot-expression-scale) * 1em); line-height: 1.2;\n  }\n  .ot-select-word:is([dir=\"rtl\"], [dir=\"rtl\"] *) { padding: 0 0 0.1em 0.6em; }\n  .ot-select-word > span { display: inline-block; }\n  .ot-select-ghosts { position: relative; }\n  .ot-select-ghosts > span { position: absolute; top: 0; color: var(--ot-pencil); }\n  .ot-select:has(select:disabled) :is(.ot-select-word, .ot-select-ruby) { color: var(--ot-pencil); pointer-events: none; }\n  .ot-select[data-variant] select:disabled + .ot-select-word { cursor: not-allowed; }\n\n  /* ruby: the other choices set small above the word, as ruby rides over a line of Japanese.\n     Ours (roman, pencil) over yours (italic, ink). They hang from the word's start and may overhang\n     the words after it, so the sentence gives them a line of their own above. */\n  .ot-select[data-variant=\"ruby\"] .ot-select-box { margin-block-start: calc(var(--ot-pp) * var(--ot-pp-lh)); }\n  .ot-select-ruby {\n    position: absolute; inset-inline-start: 0; bottom: 100%; display: flex; gap: 0 0.9em; white-space: nowrap;\n    font-family: var(--ot-voice); font-style: normal; font-size: var(--ot-pp); line-height: var(--ot-pp-lh); color: var(--ot-pencil);\n  }\n  .ot-select-ruby > span {\n    padding-block: 0.3em 0.15em; cursor: pointer;\n    background: linear-gradient(currentColor, currentColor) no-repeat 0 100% / 0% var(--ot-hairline);\n    transition: color var(--ot-allegro), background-size var(--ot-moderato) var(--ot-exhale);\n  }\n  .ot-select-ruby > span:is([dir=\"rtl\"], [dir=\"rtl\"] *) { background-position: 100% 100%; }\n  .ot-select-ruby > span:hover, .ot-select[data-force~=\"hover\"] .ot-select-ruby > span:first-child { color: var(--ot-ink); background-size: 100% var(--ot-hairline); }\n\n  @media (forced-colors: active) {\n    .ot-select[data-variant] select { color: CanvasText; }\n    .ot-select-word, .ot-select-ghosts { display: none; }\n    .ot-select-ruby > span:hover { color: LinkText; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/select.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/select.css\"": {}
  },
  "meta": {
    "movement": "VI",
    "underneath": "native",
    "contract": "ot-select"
  },
  "type": "registry:ui"
}
