{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "input-66",
  "title": "Waitlist field",
  "description": "An email field that checks the address, turns its button into You are on the list and slides the new avatar into a stack while the last one tucks under a counter that rolls.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/input.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/morph-button.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/input/input-66.tsx",
      "content": "'use client';\n\nimport {\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type FormEvent,\n} from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\nimport {\n  keyDelayOf,\n  prefixesOf,\n  PRESS_LEAD_MS,\n  pressedSolidOf,\n  usePress,\n} from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\nimport { Input } from '@/registry/ndeko/ui/input';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { MorphButton, type MorphState } from '@/registry/ndeko/ui/morph-button';\n\nimport { useHover } from './_parts/use-hover';\nimport {\n  WaitlistStack,\n  type StackEntry,\n  type WaitlistPerson,\n} from './_parts/waitlist-stack';\n\ntype Status = 'idle' | 'invalid' | 'busy' | 'joined' | 'failed';\n\ntype Joiner = {\n  id: string;\n  name: string;\n  isLeaving: boolean;\n  /** Joined here, now: its avatar slides in. Someone who started on the list is simply there. */\n  isNew: boolean;\n};\n\nconst ADDRESS_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nconst WHITESPACE = /\\s/;\n\nconst IDLE_HINT = 'One email when your invite is ready';\n\nconst BUSY_HINT = 'Adding you to the list';\n\nconst JOINED_HINT = 'We will email you when your spot opens';\n\nconst JOINED_MESSAGE = 'You are on the list';\n\nconst FAILED_MESSAGE = 'Something went wrong, try again';\n\nconst BUTTON_LABELS: Record<MorphState, string> = {\n  idle: 'Join waitlist',\n  busy: 'Joining',\n  done: JOINED_MESSAGE,\n};\n\n/** A leaving avatar is drawn this long, the time its slide takes, before it is removed. */\nconst LEAVE_MS = 500;\n\nconst NUDGE_KEYFRAMES: Keyframe[] = [\n  { transform: 'translateX(0px)' },\n  { transform: 'translateX(-4px)' },\n  { transform: 'translateX(4px)' },\n  { transform: 'translateX(-2px)' },\n  { transform: 'translateX(0px)' },\n];\n\nconst NUDGE_TIMING: KeyframeAnimationOptions = {\n  duration: 300,\n  easing: 'cubic-bezier(0.23, 1, 0.32, 1)',\n};\n\nfunction problemOf(address: string) {\n  if (address === '') {\n    return 'Enter your email address';\n  }\n  if (WHITESPACE.test(address)) {\n    return 'An email address has no spaces';\n  }\n  const atAt = address.indexOf('@');\n  if (atAt < 0) {\n    return 'An email address has an @ in it';\n  }\n  if (atAt === 0) {\n    return 'Add a name before the @';\n  }\n  if (!ADDRESS_PATTERN.test(address)) {\n    return 'Add the domain after the @';\n  }\n  return null;\n}\n\nfunction hintOf(status: Status, problem: string) {\n  if (status === 'invalid') {\n    return problem;\n  }\n  if (status === 'busy') {\n    return BUSY_HINT;\n  }\n  if (status === 'joined') {\n    return JOINED_HINT;\n  }\n  if (status === 'failed') {\n    return FAILED_MESSAGE;\n  }\n  return IDLE_HINT;\n}\n\nfunction hintToneOf(status: Status) {\n  const isInvalid = status === 'invalid';\n  const hasFailed = status === 'failed';\n  if (isInvalid || hasFailed) {\n    return 'text-destructive-ink';\n  }\n  return 'text-muted-foreground';\n}\n\nfunction morphStateOf(status: Status): MorphState {\n  if (status === 'busy') {\n    return 'busy';\n  }\n  if (status === 'joined') {\n    return 'done';\n  }\n  return 'idle';\n}\n\n/** Typing again takes a refused or failed attempt back to a fresh one. */\nfunction settledStatusOf(current: Status): Status {\n  const isInvalid = current === 'invalid';\n  const hasFailed = current === 'failed';\n  if (isInvalid || hasFailed) {\n    return 'idle';\n  }\n  return current;\n}\n\nfunction leavingOf(current: Joiner | null): Joiner | null {\n  if (current === null) {\n    return null;\n  }\n  return { ...current, isLeaving: true };\n}\n\n/** Clears the joiner once it has left, and only then: a new one who joined in the meantime stays. */\nfunction clearedWhenLeft(current: Joiner | null) {\n  if (current?.isLeaving) {\n    return null;\n  }\n  return current;\n}\n\nfunction isOnList(joiner: Joiner | null) {\n  if (joiner === null) {\n    return false;\n  }\n  return !joiner.isLeaving;\n}\n\nfunction entriesOf(\n  people: readonly WaitlistPerson[],\n  joiner: Joiner | null\n): StackEntry[] {\n  if (joiner === null || joiner.isLeaving) {\n    return [...people];\n  }\n  const you: StackEntry = {\n    id: joiner.id,\n    name: joiner.name,\n    label: 'You',\n    isArrival: joiner.isNew,\n  };\n  return [you, ...people];\n}\n\nfunction leaverOf(joiner: Joiner | null): WaitlistPerson | null {\n  if (joiner === null || !joiner.isLeaving) {\n    return null;\n  }\n  return { id: joiner.id, name: joiner.name };\n}\n\nfunction startingStatusOf(joinedAs: string | undefined): Status {\n  if (joinedAs === undefined) {\n    return 'idle';\n  }\n  return 'joined';\n}\n\nfunction startingJoinerOf(joinedAs: string | undefined): Joiner | null {\n  if (joinedAs === undefined) {\n    return null;\n  }\n  return { id: 'you-0', name: joinedAs, isLeaving: false, isNew: false };\n}\n\nexport type WaitlistFieldHandle = {\n  /** Puts this text in the field, as typed. */\n  type: (text: string) => void;\n  /** Sends the address in the field, as the button does. */\n  submit: () => void;\n  /** Takes the new address off the list, as Change email does. */\n  undo: () => void;\n};\n\nexport type WaitlistFieldProps = {\n  /** The people on the list, the latest first, not counting the visitor. The first few wear the stack. */\n  people: readonly WaitlistPerson[];\n  /** How many people are on the list in all, the ones in the stack included and the visitor left out: the visitor is added once they join. */\n  total: number;\n  /** Called with a valid address. Resolve once it is saved; a rejection shows an error and lets them try again. */\n  onJoin: (email: string) => Promise<void>;\n  /** Starts as if this address had already joined, for a visitor who is on the list or for a picture that rests. */\n  joinedAs?: string;\n  /** The line beside the stack. */\n  caption?: string;\n  /** Shows the button held down, for a demonstration that presses it. */\n  isPressed?: boolean;\n  /** Types and sends from outside, for a demonstration. */\n  handleRef?: React.Ref<WaitlistFieldHandle>;\n  className?: string;\n};\n\n/**\n * An email field with its button. A refused address nudges the field once and says what is wrong;\n * a good one turns the button into \"You are on the list\" and slides the new avatar into the stack\n * while the last one tucks under a counter that rolls. Change email takes the address back.\n */\nexport function WaitlistField({\n  people,\n  total,\n  onJoin,\n  joinedAs,\n  caption = 'on the waitlist',\n  isPressed = false,\n  handleRef,\n  className,\n}: WaitlistFieldProps) {\n  const [email, setEmail] = useState(joinedAs ?? '');\n  const [status, setStatus] = useState<Status>(startingStatusOf(joinedAs));\n  const [problem, setProblem] = useState('');\n  const [joiner, setJoiner] = useState(() => startingJoinerOf(joinedAs));\n  const [announcement, setAnnouncement] = useState('');\n  const rowRef = useRef<HTMLDivElement>(null);\n  const fieldRef = useRef<HTMLInputElement>(null);\n  const focusAfter = useRef(false);\n  const fieldId = useId();\n  const hintId = useId();\n  const joinCount = useRef(0);\n  const { schedule } = useTimeouts();\n  const isStill = usePrefersReducedMotion();\n  const entries = entriesOf(people, joiner);\n  const leaver = leaverOf(joiner);\n  const count = total + Number(isOnList(joiner));\n  const isInvalid = status === 'invalid';\n  const isBusy = status === 'busy';\n  const hasJoined = status === 'joined';\n  const isLocked = isBusy || hasJoined;\n  const hint = hintOf(status, problem);\n\n  useEffect(() => {\n    if (!focusAfter.current || status !== 'idle') {\n      return;\n    }\n    focusAfter.current = false;\n    fieldRef.current?.focus();\n    fieldRef.current?.select();\n  }, [status]);\n\n  function nudge() {\n    const row = rowRef.current;\n    if (!row || isStill) {\n      return;\n    }\n    row.animate(NUDGE_KEYFRAMES, NUDGE_TIMING);\n  }\n\n  function refuse(message: string, announces: boolean) {\n    setStatus('invalid');\n    setProblem(message);\n    nudge();\n    if (announces) {\n      setAnnouncement(message);\n    }\n  }\n\n  async function attempt(address: string, announces: boolean) {\n    if (isLocked) {\n      return;\n    }\n    const trimmed = address.trim();\n    const message = problemOf(trimmed);\n    if (message !== null) {\n      refuse(message, announces);\n      return;\n    }\n    setStatus('busy');\n    try {\n      await onJoin(trimmed);\n    } catch {\n      setStatus('failed');\n      if (announces) {\n        setAnnouncement(FAILED_MESSAGE);\n      }\n      return;\n    }\n    joinCount.current += 1;\n    const id = `you-${joinCount.current}`;\n    setJoiner({ id, name: trimmed, isLeaving: false, isNew: true });\n    setStatus('joined');\n    if (announces) {\n      setAnnouncement(JOINED_MESSAGE);\n    }\n  }\n\n  function type(text: string) {\n    setEmail(text);\n    setStatus(settledStatusOf);\n  }\n\n  function undo() {\n    if (joiner === null) {\n      return;\n    }\n    setJoiner(leavingOf);\n    setStatus('idle');\n    schedule(() => setJoiner(clearedWhenLeft), LEAVE_MS);\n  }\n\n  function submit() {\n    void attempt(email, false);\n  }\n\n  function typeFromPerson(event: ChangeEvent<HTMLInputElement>) {\n    type(event.target.value);\n  }\n\n  function submitFromPerson(event: FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    void attempt(email, true);\n  }\n\n  function changeFromPerson() {\n    focusAfter.current = true;\n    undo();\n  }\n\n  useImperativeHandle(handleRef, () => ({ type, submit, undo }));\n\n  return (\n    <form\n      noValidate\n      onSubmit={submitFromPerson}\n      className={cn('flex w-full flex-col gap-4', className)}\n    >\n      <div className='flex flex-col gap-1.5'>\n        <label htmlFor={fieldId} className='text-[13px] font-medium'>\n          Join the waitlist\n        </label>\n        <div ref={rowRef} className='flex gap-2'>\n          <Input\n            ref={fieldRef}\n            id={fieldId}\n            type='email'\n            name='email'\n            inputMode='email'\n            autoComplete='email'\n            spellCheck={false}\n            placeholder='you@company.com'\n            value={email}\n            onChange={typeFromPerson}\n            readOnly={isLocked}\n            aria-invalid={isInvalid}\n            aria-describedby={hintId}\n          />\n          <MorphButton\n            type='submit'\n            state={morphStateOf(status)}\n            labels={BUTTON_LABELS}\n            className={pressedSolidOf(isPressed)}\n          />\n        </div>\n        <div className='flex items-start justify-between gap-3'>\n          <p id={hintId} className={cn('text-xs', hintToneOf(status))}>\n            <Swap swapKey={hint}>{hint}</Swap>\n          </p>\n          {hasJoined && (\n            <Button\n              type='button'\n              variant='link'\n              size='sm'\n              onClick={changeFromPerson}\n              className='h-auto shrink-0 p-0 text-xs'\n            >\n              Change email\n            </Button>\n          )}\n        </div>\n      </div>\n      <WaitlistStack\n        entries={entries}\n        leaver={leaver}\n        count={count}\n        caption={caption}\n      />\n      <LiveRegion message={announcement} />\n    </form>\n  );\n}\n\ntype DemoBeat =\n  | { after: number; kind: 'type'; text: string }\n  | { after: number; kind: 'press' }\n  | { after: number; kind: 'submit' }\n  | { after: number; kind: 'undo' };\n\nconst PEOPLE: WaitlistPerson[] = [\n  { id: 'theo', name: 'Theo Marchetti' },\n  { id: 'priya', name: 'Priya Raman' },\n  { id: 'kasongo', name: 'Kasongo Tshibola' },\n  { id: 'elise', name: 'Elise Moreau' },\n  { id: 'jonas', name: 'Jonas Weber' },\n];\n\nconst TOTAL = 2418;\n\nconst FIRST_PART = 'amina';\n\nconst SECOND_PART = '@example.com';\n\nconst FULL_ADDRESS = `${FIRST_PART}${SECOND_PART}`;\n\n/** What the demonstration's server takes to answer, long enough for the button to show it is working. */\nconst JOIN_MS = 650;\n\nfunction typedBeatsOf(\n  typedBefore: string,\n  typed: string,\n  startAfter: number,\n  characterMs: number\n) {\n  return prefixesOf(typed).map((prefix, index): DemoBeat => {\n    const after = keyDelayOf(index, startAfter, characterMs);\n    return { after, kind: 'type', text: `${typedBefore}${prefix}` };\n  });\n}\n\n/** The address is typed without its domain and sent, which is refused; then it is finished and sent again, and joins. */\nconst DEMO_BEATS: DemoBeat[] = [\n  ...typedBeatsOf('', FIRST_PART, 1200, 80),\n  { after: 600, kind: 'press' },\n  { after: PRESS_LEAD_MS, kind: 'submit' },\n  ...typedBeatsOf(FIRST_PART, SECOND_PART, 1800, 70),\n  { after: 600, kind: 'press' },\n  { after: PRESS_LEAD_MS, kind: 'submit' },\n  { after: 4600, kind: 'undo' },\n  { after: 900, kind: 'type', text: '' },\n];\n\ntype DemoProps = {\n  isResting: boolean;\n};\n\nfunction restingAddressOf(isResting: boolean) {\n  if (isResting) {\n    return FULL_ADDRESS;\n  }\n  return undefined;\n}\n\nfunction Input66Demo({ isResting }: DemoProps) {\n  const field = useRef<WaitlistFieldHandle>(null);\n  const { pressed, press } = usePress<'join'>();\n  const { isHovered, hoverHandlers } = useHover();\n  const { schedule } = useTimeouts();\n  const steps: AutoplayStep[] = DEMO_BEATS.map((beat) => ({\n    after: beat.after,\n    run: () => playBeat(beat),\n  }));\n  const { ref } = useAutoplay(steps, {\n    loopAfter: 1500,\n    isEnabled: !isHovered,\n  });\n\n  useKeptHeight(ref);\n\n  function joinAfterDelay() {\n    return new Promise<void>((resolve) => {\n      schedule(() => resolve(), JOIN_MS);\n    });\n  }\n\n  function playBeat(beat: DemoBeat) {\n    if (beat.kind === 'type') {\n      field.current?.type(beat.text);\n      return;\n    }\n    if (beat.kind === 'press') {\n      press('join');\n      return;\n    }\n    if (beat.kind === 'submit') {\n      field.current?.submit();\n      return;\n    }\n    field.current?.undo();\n  }\n\n  return (\n    <div ref={ref} className='w-full max-w-[24rem]' {...hoverHandlers}>\n      <WaitlistField\n        handleRef={field}\n        people={PEOPLE}\n        total={TOTAL}\n        onJoin={joinAfterDelay}\n        joinedAs={restingAddressOf(isResting)}\n        caption='on the waitlist · Illustrative'\n        isPressed={pressed === 'join'}\n      />\n    </div>\n  );\n}\n\n/** Under reduced motion the piece rests on its best picture, the address in, the button saying so and the new avatar in the stack, and the demonstration is built again from there. */\nexport default function Input66() {\n  const isStill = usePrefersReducedMotion();\n\n  return <Input66Demo key={String(isStill)} isResting={isStill} />;\n}\n",
      "type": "registry:component",
      "target": "components/input/input-66.tsx"
    },
    {
      "path": "registry/ndeko/components/input/_parts/use-hover.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** Whether a mouse or a pen rests on an element. A finger does not hover, so a touch never counts. */\nexport function useHover() {\n  const [isHovered, setIsHovered] = useState(false);\n\n  function hoverStarts(event: React.PointerEvent<HTMLElement>) {\n    if (event.pointerType !== 'touch') {\n      setIsHovered(true);\n    }\n  }\n\n  function hoverEnds() {\n    setIsHovered(false);\n  }\n\n  const hoverHandlers = {\n    onPointerEnter: hoverStarts,\n    onPointerLeave: hoverEnds,\n  };\n\n  return { isHovered, hoverHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/input/_parts/use-hover.ts"
    },
    {
      "path": "registry/ndeko/components/input/_parts/waitlist-stack.tsx",
      "content": "'use client';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nexport type WaitlistPerson = {\n  id: string;\n  name: string;\n};\n\nexport type StackEntry = WaitlistPerson & {\n  /** Slides in from the left when it appears, as someone who just joined. */\n  isArrival?: boolean;\n  /** What a screen reader says in place of the name, for someone whose name is an address. */\n  label?: string;\n};\n\ntype StackItem = {\n  entry: StackEntry;\n  slot: number;\n  isHidden: boolean;\n};\n\ntype StackStyle = React.CSSProperties & {\n  '--step': string;\n  '--visible': number;\n};\n\ntype SlotStyle = React.CSSProperties & {\n  '--at': string;\n  '--born': string;\n};\n\n/** How many avatars the stack shows before the counter takes over. */\nexport const VISIBLE_AVATARS = 4;\n\n/** Someone who leaves the stack goes back the way they came: out at the left, where a new one slides in. */\nconst LEAVER_SLOT = -1;\n\n/** The nearer to the left, the higher: a new avatar lies over its neighbour and the one that tucks away lies under the counter. */\nconst FRONT_LAYER = 20;\n\nconst AVATAR_CLASS =\n  'ring-card absolute top-0 left-0 size-8 translate-x-(--at) ring-2 transition-[translate,opacity] duration-500 ease-(--ease-out-strong) data-[hidden=true]:opacity-0';\n\nconst ARRIVAL_CLASS = 'starting:translate-x-(--born) starting:opacity-0';\n\nconst COUNTER_CLASS =\n  'bg-muted text-muted-foreground ring-card relative z-30 ms-[calc(var(--step)*var(--visible))] flex h-8 min-w-8 items-center justify-center rounded-full px-2.5 text-xs font-medium ring-2 transition-[margin] duration-500 ease-(--ease-out-strong)';\n\nfunction stackStyleOf(visible: number): StackStyle {\n  return { '--step': '1.5rem', '--visible': visible };\n}\n\nfunction itemsOf(\n  entries: readonly StackEntry[],\n  leaver: WaitlistPerson | null\n) {\n  const shown = entries.slice(0, VISIBLE_AVATARS + 1);\n  const items: StackItem[] = shown.map((entry, slot) => ({\n    entry,\n    slot,\n    isHidden: slot >= VISIBLE_AVATARS,\n  }));\n  if (leaver) {\n    items.push({ entry: leaver, slot: LEAVER_SLOT, isHidden: true });\n  }\n  return items;\n}\n\nfunction slotStyleOf(slot: number): SlotStyle {\n  return {\n    '--at': `calc(var(--step) * ${slot})`,\n    '--born': `calc(var(--step) * ${LEAVER_SLOT})`,\n    zIndex: FRONT_LAYER - slot,\n  };\n}\n\nfunction arrivalClassOf(entry: StackEntry) {\n  if (entry.isArrival) {\n    return ARRIVAL_CLASS;\n  }\n  return undefined;\n}\n\nfunction unseenIf(isHidden: boolean) {\n  if (isHidden) {\n    return true;\n  }\n  return undefined;\n}\n\nfunction StackAvatar({ item }: { item: StackItem }) {\n  const { entry, slot, isHidden } = item;\n\n  return (\n    <Avatar\n      seed={entry.name}\n      role='img'\n      aria-label={entry.label ?? entry.name}\n      aria-hidden={unseenIf(isHidden)}\n      data-hidden={isHidden}\n      style={slotStyleOf(slot)}\n      className={cn(AVATAR_CLASS, arrivalClassOf(entry))}\n    />\n  );\n}\n\nexport type WaitlistStackProps = {\n  /** The people on the list, the latest first. The stack shows the first few. */\n  entries: readonly StackEntry[];\n  /** Someone who is on their way out of the stack, still drawn while they fade. */\n  leaver: WaitlistPerson | null;\n  /** How many people are on the list in all, the ones in the stack included. The counter says how many the stack does not show. */\n  count: number;\n  caption: string;\n};\n\n/**\n * Avatars that overlap in a row, the latest on the left. A new one slides in at the left while the\n * others shift one place to the right and the last one tucks under the counter, which rolls to its\n * new figure. Every avatar keeps its own element, so each move is a transition of `translate` and\n * `opacity` and nothing runs between two changes.\n */\nexport function WaitlistStack({\n  entries,\n  leaver,\n  count,\n  caption,\n}: WaitlistStackProps) {\n  const items = itemsOf(entries, leaver);\n  const visible = Math.min(VISIBLE_AVATARS, entries.length);\n  const others = Math.max(0, count - visible);\n\n  return (\n    <div className='flex flex-wrap items-center gap-x-3 gap-y-1'>\n      <div\n        role='group'\n        aria-label='People on the waitlist'\n        style={stackStyleOf(visible)}\n        className='relative flex h-8 items-center'\n      >\n        {items.map((item) => (\n          <StackAvatar key={item.entry.id} item={item} />\n        ))}\n        <span className={COUNTER_CLASS}>\n          <MotionNumber value={others} prefix='+' />\n          <span className='sr-only'> others</span>\n        </span>\n      </div>\n      <p className='text-muted-foreground text-xs'>{caption}</p>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/input/_parts/waitlist-stack.tsx"
    }
  ],
  "categories": [
    "components",
    "input"
  ],
  "type": "registry:component"
}