{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-effect-03",
  "title": "Lit find",
  "description": "Find in an article by light: everything dims but the sentences that hold the word, and one plate travels from match to match with Enter while the article scrolls it into view and the count rolls.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/input.json",
    "https://ui.ndekocode.dev/r/kbd.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-presets.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/text-effect/text-effect-03.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, LayoutGroup, motion } from 'motion/react';\nimport {\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  keyDelayOf,\n  prefixesOf,\n  pressedQuietOf,\n  PRESS_LEAD_MS,\n  usePress,\n} from '@/registry/ndeko/lib/demonstration';\nimport {\n  ChevronDownIcon,\n  ChevronUpIcon,\n  SearchIcon,\n  XIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { fadePresence, springOf } from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { Input } from '@/registry/ndeko/ui/input';\nimport { Kbd, KbdGroup } from '@/registry/ndeko/ui/kbd';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\n\nimport { useHover } from './_parts/use-hover';\n\n/** A control of the find bar a demonstration can press. */\nexport type FindControl = 'open' | 'previous' | 'next' | 'close';\n\ntype StepDirection = 1 | -1;\n\ntype Piece =\n  | { kind: 'text'; text: string }\n  | { kind: 'match'; text: string; order: number };\n\ntype SentenceLayout = {\n  pieces: Piece[];\n  hasMatch: boolean;\n};\n\ntype ArticleLayout = {\n  title: SentenceLayout[];\n  paragraphs: SentenceLayout[][];\n  total: number;\n};\n\ntype Search = {\n  query: string;\n  /** The match the plate is on, counted from 0 in the order of the page. */\n  active: number;\n};\n\ntype FocusAfter = 'field' | 'opener' | null;\n\n/** A sentence ends at its stop, with any closing quote or bracket and the space after it. */\nconst SENTENCE_PATTERN = /[^.!?]+[.!?]+[\"')\\]]*\\s*|[^.!?]+$/g;\n\nconst NO_SEARCH: Search = { query: '', active: 0 };\n\n/** A count is said once the person stops typing, not on every key. */\nconst COUNT_SAID_AFTER_MS = 500;\n\n/** A match closer than this to the edge of the frame is scrolled into the middle. */\nconst SCROLL_MARGIN_PX = 24;\n\nconst APPLE_PLATFORM = /Mac|iPhone|iPad|iPod/;\n\nfunction subscribeToNothing() {\n  return () => {};\n}\n\nfunction readIsApple() {\n  return APPLE_PLATFORM.test(navigator.userAgent);\n}\n\nfunction appleOnServer() {\n  return false;\n}\n\n/** The key that goes with F to find in a page: Command on Apple systems, Control elsewhere. */\nfunction useFindModifier() {\n  const isApple = useSyncExternalStore(\n    subscribeToNothing,\n    readIsApple,\n    appleOnServer\n  );\n  if (isApple) {\n    return '⌘';\n  }\n  return 'Ctrl';\n}\n\nfunction sentencesOf(text: string) {\n  const found = text.match(SENTENCE_PATTERN);\n  if (!found) {\n    return [text];\n  }\n  return found;\n}\n\n/** The sentence cut into plain text and matches, each match numbered in the order of the page from `firstOrder`. */\nfunction piecesOf(sentence: string, needle: string, firstOrder: number) {\n  const pieces: Piece[] = [];\n  const haystack = sentence.toLowerCase();\n  const keepsIndexes = haystack.length === sentence.length;\n  const canMatch = needle.length > 0 && keepsIndexes;\n  if (!canMatch) {\n    return [{ kind: 'text', text: sentence } satisfies Piece];\n  }\n  let from = 0;\n  let order = firstOrder;\n  for (\n    let at = haystack.indexOf(needle);\n    at >= 0;\n    at = haystack.indexOf(needle, from)\n  ) {\n    if (at > from) {\n      pieces.push({ kind: 'text', text: sentence.slice(from, at) });\n    }\n    const end = at + needle.length;\n    pieces.push({ kind: 'match', text: sentence.slice(at, end), order });\n    order += 1;\n    from = end;\n  }\n  if (from < sentence.length) {\n    pieces.push({ kind: 'text', text: sentence.slice(from) });\n  }\n  return pieces;\n}\n\nfunction matchCountOf(pieces: Piece[]) {\n  const matches = pieces.filter((piece) => piece.kind === 'match');\n  return matches.length;\n}\n\n/** Lays out a block of text as sentences, numbering its matches from `firstOrder`; hands back the next number too. */\nfunction blockLayoutOf(text: string, needle: string, firstOrder: number) {\n  const sentences: SentenceLayout[] = [];\n  let order = firstOrder;\n  for (const sentence of sentencesOf(text)) {\n    const pieces = piecesOf(sentence, needle, order);\n    const count = matchCountOf(pieces);\n    order += count;\n    sentences.push({ pieces, hasMatch: count > 0 });\n  }\n  return { sentences, nextOrder: order };\n}\n\nfunction articleLayoutOf(\n  title: string,\n  paragraphs: readonly string[],\n  query: string\n): ArticleLayout {\n  const needle = query.toLowerCase();\n  const titleBlock = blockLayoutOf(title, needle, 0);\n  const laidParagraphs: SentenceLayout[][] = [];\n  let order = titleBlock.nextOrder;\n  for (const paragraph of paragraphs) {\n    const block = blockLayoutOf(paragraph, needle, order);\n    laidParagraphs.push(block.sentences);\n    order = block.nextOrder;\n  }\n  return {\n    title: titleBlock.sentences,\n    paragraphs: laidParagraphs,\n    total: order,\n  };\n}\n\nfunction wrappedOf(index: number, total: number) {\n  return ((index % total) + total) % total;\n}\n\nfunction steppedSearchOf(\n  current: Search,\n  direction: StepDirection,\n  total: number\n) {\n  if (total === 0) {\n    return current;\n  }\n  const shown = activeOf(current, total);\n  const next = wrappedOf(shown + direction, total);\n  return { query: current.query, active: next };\n}\n\nfunction activeOf(search: Search, total: number) {\n  if (total === 0) {\n    return 0;\n  }\n  return Math.min(search.active, total - 1);\n}\n\nfunction countSentenceOf(total: number) {\n  if (total === 0) {\n    return 'No matches';\n  }\n  if (total === 1) {\n    return '1 match';\n  }\n  return `${total} matches`;\n}\n\nfunction scrollBehaviorOf(isStill: boolean): ScrollBehavior {\n  if (isStill) {\n    return 'auto';\n  }\n  return 'smooth';\n}\n\n/** Brings the match the plate is on into the frame, scrolling the frame alone and never the page. */\nfunction revealActiveMatch(frame: HTMLElement, behavior: ScrollBehavior) {\n  const mark = frame.querySelector<HTMLElement>('[data-active-match]');\n  if (!mark) {\n    return;\n  }\n  const top = mark.offsetTop;\n  const bottom = top + mark.offsetHeight;\n  const viewTop = frame.scrollTop + SCROLL_MARGIN_PX;\n  const viewBottom = frame.scrollTop + frame.clientHeight - SCROLL_MARGIN_PX;\n  const isAbove = top < viewTop;\n  const isBelow = bottom > viewBottom;\n  if (!isAbove && !isBelow) {\n    return;\n  }\n  const middle = top - frame.clientHeight / 2 + mark.offsetHeight / 2;\n  frame.scrollTo({ top: Math.max(0, middle), behavior });\n}\n\nfunction isFindShortcut(event: React.KeyboardEvent) {\n  const isF = event.key.toLowerCase() === 'f';\n  const hasModifier = event.metaKey || event.ctrlKey;\n  return isF && hasModifier;\n}\n\ntype SentenceProps = {\n  sentence: SentenceLayout;\n  isSearching: boolean;\n  activeOrder: number;\n  plateKey: string;\n  isStill: boolean;\n};\n\n/** A sentence that holds a match stays lit; the others dim while a search finds something. */\nfunction Sentence({\n  sentence,\n  isSearching,\n  activeOrder,\n  plateKey,\n  isStill,\n}: SentenceProps) {\n  const isDimmed = isSearching && !sentence.hasMatch;\n\n  return (\n    <span\n      data-dimmed={isDimmed}\n      className='transition-opacity duration-300 ease-(--ease-out-strong) data-[dimmed=true]:opacity-30'\n    >\n      {sentence.pieces.map((piece, index) => {\n        if (piece.kind === 'text') {\n          return <span key={`text-${index}`}>{piece.text}</span>;\n        }\n        const isActive = piece.order === activeOrder;\n        return (\n          <mark\n            key={`match-${piece.order}`}\n            data-active-match={isActive || undefined}\n            className='bg-primary/15 text-foreground relative isolate rounded-[3px]'\n          >\n            {isActive && (\n              <motion.span\n                layoutId='find-plate'\n                layoutDependency={plateKey}\n                transition={springOf(isStill)}\n                aria-hidden='true'\n                className='bg-primary/30 absolute -inset-x-[3px] -inset-y-px -z-10 rounded-[5px]'\n              />\n            )}\n            {piece.text}\n          </mark>\n        );\n      })}\n    </span>\n  );\n}\n\nexport type LitFindHandle = {\n  open: () => void;\n  /** Puts this text in the find field, as typed. */\n  type: (query: string) => void;\n  /** Carries the plate to the next match, or to the one before with -1. */\n  step: (direction: StepDirection) => void;\n  close: () => void;\n};\n\nexport type LitFindProps = {\n  title: string;\n  /** The article, as paragraphs of plain text. */\n  paragraphs: readonly string[];\n  /** Shows a control of the find bar held down, for a demonstration that presses it. */\n  pressed?: FindControl | null;\n  /** Searches from outside, for a demonstration. */\n  handleRef?: React.Ref<LitFindHandle>;\n  className?: string;\n};\n\n/**\n * An article searched by light. While a search finds something, everything dims except the\n * sentences that hold a match, and one plate travels from match to match with Enter and Shift with\n * Enter while the frame scrolls it into view. Command or Control with F opens the field from inside\n * the piece, and Escape clears it.\n */\nexport function LitFind({\n  title,\n  paragraphs,\n  pressed = null,\n  handleRef,\n  className,\n}: LitFindProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [search, setSearch] = useState<Search>(NO_SEARCH);\n  const [announcement, setAnnouncement] = useState('');\n  const frameRef = useRef<HTMLElement>(null);\n  const fieldRef = useRef<HTMLInputElement>(null);\n  const openerRef = useRef<HTMLButtonElement>(null);\n  const focusAfter = useRef<FocusAfter>(null);\n  const countTimer = useRef<number | undefined>(undefined);\n  const groupId = useId();\n  const titleId = useId();\n  const isStill = usePrefersReducedMotion();\n  const modifier = useFindModifier();\n  const layout = useMemo(\n    () => articleLayoutOf(title, paragraphs, search.query),\n    [title, paragraphs, search.query]\n  );\n  const total = layout.total;\n  const active = activeOf(search, total);\n  const hasQuery = search.query.length > 0;\n  const isSearching = hasQuery && total > 0;\n  const hasNoMatch = hasQuery && total === 0;\n  const plateKey = `${search.query}:${active}`;\n\n  useEffect(() => {\n    const frame = frameRef.current;\n    if (!frame || !isSearching) {\n      return;\n    }\n    revealActiveMatch(frame, scrollBehaviorOf(isStill));\n  }, [plateKey, isSearching, isStill]);\n\n  useEffect(() => {\n    const target = focusAfter.current;\n    focusAfter.current = null;\n    if (target === 'field') {\n      fieldRef.current?.focus();\n      fieldRef.current?.select();\n    }\n    if (target === 'opener') {\n      openerRef.current?.focus();\n    }\n  }, [isOpen]);\n\n  useEffect(() => {\n    return () => window.clearTimeout(countTimer.current);\n  }, []);\n\n  function open() {\n    setIsOpen(true);\n  }\n\n  function type(query: string) {\n    setSearch({ query, active: 0 });\n  }\n\n  function step(direction: StepDirection) {\n    setSearch((current) => {\n      const currentLayout = articleLayoutOf(title, paragraphs, current.query);\n      return steppedSearchOf(current, direction, currentLayout.total);\n    });\n  }\n\n  function close() {\n    window.clearTimeout(countTimer.current);\n    setIsOpen(false);\n    setSearch(NO_SEARCH);\n  }\n\n  function openFromPerson() {\n    if (isOpen) {\n      fieldRef.current?.focus();\n      fieldRef.current?.select();\n      return;\n    }\n    focusAfter.current = 'field';\n    open();\n  }\n\n  function closeFromPerson() {\n    focusAfter.current = 'opener';\n    close();\n    setAnnouncement('Find closed');\n  }\n\n  function stepFromPerson(direction: StepDirection) {\n    if (total === 0) {\n      return;\n    }\n    const next = wrappedOf(active + direction, total);\n    step(direction);\n    setAnnouncement(`Match ${next + 1} of ${total}`);\n  }\n\n  function typeFromPerson(event: React.ChangeEvent<HTMLInputElement>) {\n    const query = event.target.value;\n    type(query);\n    window.clearTimeout(countTimer.current);\n    if (query.length === 0) {\n      return;\n    }\n    const count = articleLayoutOf(title, paragraphs, query).total;\n    countTimer.current = window.setTimeout(() => {\n      setAnnouncement(countSentenceOf(count));\n    }, COUNT_SAID_AFTER_MS);\n  }\n\n  function keysInField(event: React.KeyboardEvent<HTMLInputElement>) {\n    if (event.key !== 'Enter') {\n      return;\n    }\n    event.preventDefault();\n    if (event.shiftKey) {\n      stepFromPerson(-1);\n      return;\n    }\n    stepFromPerson(1);\n  }\n\n  function keysOnPiece(event: React.KeyboardEvent<HTMLDivElement>) {\n    if (isFindShortcut(event)) {\n      event.preventDefault();\n      openFromPerson();\n      return;\n    }\n    const isEscape = event.key === 'Escape';\n    if (isEscape && isOpen) {\n      event.preventDefault();\n      closeFromPerson();\n    }\n  }\n\n  useImperativeHandle(handleRef, () => ({ open, type, step, close }));\n\n  return (\n    <div\n      onKeyDown={keysOnPiece}\n      className={cn('@container flex w-full flex-col gap-2', className)}\n    >\n      <div className='relative h-8'>\n        <AnimatePresence mode='popLayout' initial={false}>\n          {!isOpen && (\n            <motion.div\n              key='opener'\n              {...fadePresence()}\n              className='flex h-8 items-center'\n            >\n              <Button\n                ref={openerRef}\n                type='button'\n                variant='outline'\n                size='sm'\n                onClick={openFromPerson}\n                className={pressedQuietOf(pressed === 'open')}\n              >\n                <SearchIcon />\n                Find in article\n                <KbdGroup className='ms-1'>\n                  <Kbd>{modifier}</Kbd>\n                  <Kbd>F</Kbd>\n                </KbdGroup>\n              </Button>\n            </motion.div>\n          )}\n          {isOpen && (\n            <motion.div\n              key='bar'\n              {...fadePresence()}\n              className='flex h-8 items-center gap-1'\n            >\n              <Input\n                ref={fieldRef}\n                type='search'\n                aria-label='Find in the article'\n                placeholder='Find in article'\n                value={search.query}\n                onChange={typeFromPerson}\n                onKeyDown={keysInField}\n                className='min-w-0 flex-1'\n              />\n              <span\n                aria-hidden='true'\n                className='text-muted-foreground flex w-[4.75rem] shrink-0 justify-center text-xs tabular-nums'\n              >\n                {isSearching && (\n                  <span className='inline-flex items-baseline gap-1'>\n                    <MotionNumber value={active + 1} />\n                    of\n                    <MotionNumber value={total} />\n                  </span>\n                )}\n                {hasNoMatch && 'No matches'}\n              </span>\n              <Button\n                type='button'\n                variant='ghost'\n                size='icon-sm'\n                aria-label='Previous match'\n                disabled={!isSearching}\n                onClick={() => stepFromPerson(-1)}\n                className={pressedQuietOf(pressed === 'previous')}\n              >\n                <ChevronUpIcon />\n              </Button>\n              <Button\n                type='button'\n                variant='ghost'\n                size='icon-sm'\n                aria-label='Next match'\n                disabled={!isSearching}\n                onClick={() => stepFromPerson(1)}\n                className={pressedQuietOf(pressed === 'next')}\n              >\n                <ChevronDownIcon />\n              </Button>\n              <Button\n                type='button'\n                variant='ghost'\n                size='icon-sm'\n                aria-label='Close find'\n                onClick={closeFromPerson}\n                className={pressedQuietOf(pressed === 'close')}\n              >\n                <XIcon />\n              </Button>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n      <LayoutGroup id={groupId}>\n        <motion.article\n          ref={frameRef}\n          layoutScroll\n          tabIndex={0}\n          aria-labelledby={titleId}\n          className='bg-foreground/[0.03] focus-visible:ring-ring/25 relative max-h-48 overflow-y-auto rounded-xl px-4 py-3 text-[13px] leading-relaxed outline-none focus-visible:ring-[3px]'\n        >\n          <h3 id={titleId} className='mb-2 text-sm font-medium text-balance'>\n            {layout.title.map((sentence, index) => (\n              <Sentence\n                key={index}\n                sentence={sentence}\n                isSearching={isSearching}\n                activeOrder={active}\n                plateKey={plateKey}\n                isStill={isStill}\n              />\n            ))}\n          </h3>\n          {layout.paragraphs.map((sentences, paragraphIndex) => (\n            <p key={paragraphIndex} className='mt-2 text-pretty first:mt-0'>\n              {sentences.map((sentence, index) => (\n                <Sentence\n                  key={index}\n                  sentence={sentence}\n                  isSearching={isSearching}\n                  activeOrder={active}\n                  plateKey={plateKey}\n                  isStill={isStill}\n                />\n              ))}\n            </p>\n          ))}\n        </motion.article>\n      </LayoutGroup>\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n\ntype FindBeat =\n  | { after: number; kind: 'open' }\n  | { after: number; kind: 'type'; query: string }\n  | { after: number; kind: 'press'; control: FindControl }\n  | { after: number; kind: 'step'; direction: StepDirection }\n  | { after: number; kind: 'close' };\n\nconst ARTICLE_TITLE = 'Keeping the light at Cape Farrow';\n\nconst ARTICLE = [\n  'The lighthouse on Cape Farrow has kept its light burning since 1874. At dusk the keeper climbs the tower and winds the clockwork that turns the lens, which folds a single lamp into one beam that sweeps the bay every ten seconds.',\n  'Ships tell one light from another by its rhythm. Two short flashes and a long one mean Cape Farrow; three short flashes belong to the harbour wall, whose lamp is green. On a clear night the beam carries twenty miles.',\n  'Fog is the hard part. When the light cannot reach the water, the keeper starts the horn and runs both until daylight. The logbook gives one line to every night, and most lines say the same thing: light kept.',\n];\n\nconst QUERY = 'light';\n\nconst TYPING: FindBeat[] = prefixesOf(QUERY).map((query, index): FindBeat => ({\n  after: keyDelayOf(index, 600, 130),\n  kind: 'type',\n  query,\n}));\n\nfunction stepBeats(control: FindControl, direction: StepDirection): FindBeat[] {\n  return [\n    { after: 1000, kind: 'press', control },\n    { after: PRESS_LEAD_MS, kind: 'step', direction },\n  ];\n}\n\n/** Opens the field and types, steps forward to a match below the fold and back once, then closes, each control pressed the way a person would. */\nconst FIND_BEATS: FindBeat[] = [\n  { after: 1300, kind: 'press', control: 'open' },\n  { after: PRESS_LEAD_MS, kind: 'open' },\n  ...TYPING,\n  ...stepBeats('next', 1),\n  ...stepBeats('next', 1),\n  ...stepBeats('next', 1),\n  ...stepBeats('next', 1),\n  ...stepBeats('previous', -1),\n  { after: 1800, kind: 'press', control: 'close' },\n  { after: PRESS_LEAD_MS, kind: 'close' },\n];\n\nexport default function TextEffect03() {\n  const find = useRef<LitFindHandle>(null);\n  const isStill = usePrefersReducedMotion();\n  const { pressed, press } = usePress<FindControl>();\n  const { isHovered, hoverHandlers } = useHover();\n  const steps: AutoplayStep[] = FIND_BEATS.map((beat) => ({\n    after: beat.after,\n    run: () => playBeat(beat),\n  }));\n  const { ref } = useAutoplay(steps, {\n    loopAfter: 1500,\n    onReset: () => find.current?.close(),\n    isEnabled: !isHovered,\n  });\n\n  /** Under reduced motion the piece rests on a search under way: the word lit, the plate on its third match. */\n  useEffect(() => {\n    if (!isStill) {\n      return;\n    }\n    find.current?.open();\n    find.current?.type(QUERY);\n    find.current?.step(1);\n    find.current?.step(1);\n  }, [isStill]);\n\n  function playBeat(beat: FindBeat) {\n    if (beat.kind === 'press') {\n      press(beat.control);\n      return;\n    }\n    if (beat.kind === 'open') {\n      find.current?.open();\n      return;\n    }\n    if (beat.kind === 'type') {\n      find.current?.type(beat.query);\n      return;\n    }\n    if (beat.kind === 'step') {\n      find.current?.step(beat.direction);\n      return;\n    }\n    find.current?.close();\n  }\n\n  return (\n    <div ref={ref} className='w-full max-w-[32rem]' {...hoverHandlers}>\n      <LitFind\n        handleRef={find}\n        title={ARTICLE_TITLE}\n        paragraphs={ARTICLE}\n        pressed={pressed}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/text-effect-03.tsx"
    },
    {
      "path": "registry/ndeko/components/text-effect/_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/text-effect/_parts/use-hover.ts"
    }
  ],
  "categories": [
    "components",
    "text-effect"
  ],
  "type": "registry:component"
}