{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "faq-pro",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["@base-ui/react/accordion", "motion", "lucide-react"],
  "devDependencies": [],
  "files": [
    {
      "path": "faq-pro.tsx",
      "content": "\"use client\";\n\nimport { Accordion as AccordionPrimitive } from \"@base-ui/react/accordion\";\nimport { ChevronDown, X } from \"lucide-react\";\nimport { motion, type Transition, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst QUERY_SPLIT_REGEX = /\\s+/;\nconst ESCAPE_REGEX = /[.*+?^${}()|[\\]\\\\]/g;\n\nconst componentThemeClassName =\n  \"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)]\";\n\nconst PANEL_EASE = [0.18, 1, 0.32, 1] as const;\n\nconst panelShellTransition: Transition = {\n  height: {\n    type: \"spring\",\n    stiffness: 138,\n    damping: 27,\n    mass: 0.98,\n  },\n  opacity: { duration: 0.28, ease: PANEL_EASE },\n};\n\nconst panelCopyOpenTransition: Transition = {\n  opacity: { duration: 0.28, ease: PANEL_EASE, delay: 0.05 },\n  y: { duration: 0.3, ease: PANEL_EASE, delay: 0.04 },\n};\n\nconst panelCopyCloseTransition: Transition = {\n  opacity: { duration: 0.24, ease: PANEL_EASE },\n  y: { duration: 0.28, ease: PANEL_EASE },\n};\n\nconst instantTransition: Transition = { duration: 0 };\n\nconst searchInputClassName = cn(\n  \"h-12 w-full appearance-none rounded-full border-[0.5px] border-border bg-card px-5 pr-11 text-[15px] text-foreground\",\n  \"placeholder:text-muted-foreground\",\n  \"outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n  \"[&::-webkit-search-cancel-button]:appearance-none\",\n  \"[&::-webkit-search-decoration]:appearance-none\"\n);\n\nconst itemClassName = \"overflow-hidden rounded-lg bg-muted/70 dark:bg-muted/50\";\n\nconst triggerClassName =\n  \"flex w-full items-start justify-between gap-4 px-5 py-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed\";\n\nconst answerClassName = \"px-5 pb-5 text-[14px] text-muted-foreground leading-6\";\n\ntype PanelRenderProps = React.HTMLAttributes<HTMLDivElement> & {\n  children?: React.ReactNode;\n  className?: string;\n  hidden?: boolean;\n  ref?: React.Ref<HTMLDivElement>;\n  style?: React.CSSProperties;\n};\n\nexport type FaqProItem = {\n  id: string;\n  question: string;\n  answer: React.ReactNode;\n  keywords?: string[];\n  disabled?: boolean;\n};\n\nexport type FaqProProps = {\n  className?: string;\n  defaultOpenFirst?: boolean;\n  defaultOpenId?: string;\n  defaultValue?: string | null;\n  emptyMessage?: string;\n  filter?: (item: FaqProItem, query: string) => boolean;\n  hideSearch?: boolean;\n  items: FaqProItem[];\n  noResultsMessage?: string;\n  onOpenChange?: (openId: string | null) => void;\n  onQueryChange?: (query: string) => void;\n  searchPlaceholder?: string;\n  themed?: boolean;\n  value?: string | null;\n};\n\nfunction escapeRegExp(value: string) {\n  return value.replace(ESCAPE_REGEX, \"\\\\$&\");\n}\n\nfunction getQueryTerms(query: string) {\n  return query.trim().toLowerCase().split(QUERY_SPLIT_REGEX).filter(Boolean);\n}\n\nfunction itemSearchText(item: FaqProItem) {\n  const answerText = typeof item.answer === \"string\" ? item.answer : \"\";\n\n  return [item.question, answerText, ...(item.keywords ?? [])]\n    .join(\" \")\n    .toLowerCase();\n}\n\nfunction defaultMatchesQuery(item: FaqProItem, query: string) {\n  const terms = getQueryTerms(query);\n\n  if (terms.length === 0) {\n    return true;\n  }\n\n  return terms.every((term) => itemSearchText(item).includes(term));\n}\n\nfunction highlightText(text: string, query: string) {\n  const terms = getQueryTerms(query);\n\n  if (terms.length === 0) {\n    return text;\n  }\n\n  const pattern = new RegExp(`(${terms.map(escapeRegExp).join(\"|\")})`, \"gi\");\n  const parts = text.split(pattern);\n\n  return parts.map((part, index) => {\n    if (terms.some((term) => part.toLowerCase() === term.toLowerCase())) {\n      return (\n        <mark\n          className=\"rounded-sm bg-amber-200/90 px-0.5 text-foreground dark:bg-amber-400/40\"\n          key={index}\n        >\n          {part}\n        </mark>\n      );\n    }\n\n    return <React.Fragment key={index}>{part}</React.Fragment>;\n  });\n}\n\nfunction renderAnswer(answer: React.ReactNode, query: string) {\n  return typeof answer === \"string\" ? highlightText(answer, query) : answer;\n}\n\nfunction getInitialOpenId(\n  items: FaqProItem[],\n  defaultValue: string | null | undefined,\n  defaultOpenId: string | undefined,\n  defaultOpenFirst: boolean\n) {\n  if (defaultValue !== undefined) {\n    return defaultValue;\n  }\n\n  if (defaultOpenId && items.some((item) => item.id === defaultOpenId)) {\n    return defaultOpenId;\n  }\n\n  if (defaultOpenFirst) {\n    return items.find((item) => !item.disabled)?.id ?? null;\n  }\n\n  return null;\n}\n\nfunction isOpenIdVisible(openId: string | null, visibleItems: FaqProItem[]) {\n  return (\n    openId !== null &&\n    visibleItems.some((item) => item.id === openId && !item.disabled)\n  );\n}\n\nfunction getFirstVisibleId(visibleItems: FaqProItem[]) {\n  return visibleItems.find((item) => !item.disabled)?.id ?? null;\n}\n\nfunction resolveAccordionOpenId(\n  openId: string | null,\n  visibleItems: FaqProItem[],\n  searchActive: boolean\n) {\n  if (isOpenIdVisible(openId, visibleItems)) {\n    return openId;\n  }\n\n  if (searchActive) {\n    return getFirstVisibleId(visibleItems);\n  }\n\n  return null;\n}\n\nfunction sanitizePanelStyle(style?: React.CSSProperties) {\n  if (!style) {\n    return undefined;\n  }\n\n  const {\n    height: _height,\n    maxHeight: _maxHeight,\n    minHeight: _minHeight,\n    overflow: _overflow,\n    ...rest\n  } = style;\n\n  return Object.keys(rest).length > 0 ? rest : undefined;\n}\n\nfunction resolvePanelProps(panelProps: PanelRenderProps) {\n  const {\n    children: _panelChildren,\n    className: panelClassName,\n    hidden: _panelHidden,\n    onAnimationEnd: _onAnimationEnd,\n    onAnimationIteration: _onAnimationIteration,\n    onAnimationStart: _onAnimationStart,\n    onDrag: _onDrag,\n    onDragEnd: _onDragEnd,\n    onDragEnter: _onDragEnter,\n    onDragExit: _onDragExit,\n    onDragLeave: _onDragLeave,\n    onDragOver: _onDragOver,\n    onDragStart: _onDragStart,\n    onDrop: _onDrop,\n    ref: panelRef,\n    style: panelStyle,\n    ...resolvedPanelProps\n  } = panelProps;\n\n  return {\n    panelClassName,\n    panelRef,\n    panelStyle,\n    resolvedPanelProps,\n  };\n}\n\nfunction getPanelShellTransition(reduceMotion: boolean) {\n  return reduceMotion ? instantTransition : panelShellTransition;\n}\n\nfunction getPanelCopyTransition(reduceMotion: boolean, isOpen: boolean) {\n  if (reduceMotion) {\n    return instantTransition;\n  }\n\n  return isOpen ? panelCopyOpenTransition : panelCopyCloseTransition;\n}\n\ntype FaqProPanelBodyProps = {\n  answer: React.ReactNode;\n  isOpen: boolean;\n  query: string;\n  reduceMotion: boolean;\n};\n\nfunction FaqProPanelBody({\n  answer,\n  isOpen,\n  query,\n  reduceMotion,\n}: FaqProPanelBodyProps) {\n  if (reduceMotion) {\n    if (!isOpen) {\n      return null;\n    }\n\n    return (\n      <div className=\"overflow-hidden\">\n        <div className={answerClassName}>{renderAnswer(answer, query)}</div>\n      </div>\n    );\n  }\n\n  return (\n    <motion.div\n      animate={{ height: isOpen ? \"auto\" : 0, opacity: isOpen ? 1 : 0 }}\n      className=\"overflow-hidden\"\n      initial={false}\n      transition={getPanelShellTransition(reduceMotion)}\n    >\n      <motion.div\n        animate={{ opacity: isOpen ? 1 : 0, y: isOpen ? 0 : -4 }}\n        aria-hidden={!isOpen}\n        className={answerClassName}\n        inert={isOpen ? undefined : true}\n        initial={false}\n        transition={getPanelCopyTransition(reduceMotion, isOpen)}\n      >\n        {renderAnswer(answer, query)}\n      </motion.div>\n    </motion.div>\n  );\n}\n\ntype FaqProRowProps = {\n  item: FaqProItem;\n  query: string;\n  reduceMotion: boolean;\n};\n\nfunction FaqProRow({ item, query, reduceMotion }: FaqProRowProps) {\n  return (\n    <AccordionPrimitive.Item\n      className={cn(itemClassName, item.disabled && \"opacity-60\")}\n      data-slot=\"faq-pro-item\"\n      disabled={item.disabled}\n      value={item.id}\n    >\n      <AccordionPrimitive.Header className=\"flex\">\n        <AccordionPrimitive.Trigger\n          className={triggerClassName}\n          data-slot=\"faq-pro-trigger\"\n        >\n          <span className=\"font-medium text-[15px] text-foreground leading-6 tracking-[-0.02em]\">\n            {highlightText(item.question, query)}\n          </span>\n          <ChevronDown\n            aria-hidden\n            className=\"mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] data-panel-open:rotate-180\"\n          />\n        </AccordionPrimitive.Trigger>\n      </AccordionPrimitive.Header>\n\n      <AccordionPrimitive.Panel\n        data-slot=\"faq-pro-panel\"\n        keepMounted\n        render={(renderProps, state) => {\n          const { panelClassName, panelRef, panelStyle, resolvedPanelProps } =\n            resolvePanelProps(renderProps);\n\n          return (\n            <div\n              {...resolvedPanelProps}\n              className={cn(\"overflow-hidden\", panelClassName)}\n              ref={panelRef}\n              style={sanitizePanelStyle(panelStyle)}\n            >\n              <FaqProPanelBody\n                answer={item.answer}\n                isOpen={state.open}\n                query={query}\n                reduceMotion={reduceMotion}\n              />\n            </div>\n          );\n        }}\n      />\n    </AccordionPrimitive.Item>\n  );\n}\n\nconst FaqPro = React.forwardRef<HTMLDivElement, FaqProProps>(\n  (\n    {\n      className,\n      defaultOpenFirst = false,\n      defaultOpenId,\n      defaultValue,\n      emptyMessage = \"No FAQs to show yet.\",\n      filter,\n      hideSearch = false,\n      items,\n      noResultsMessage = \"No FAQs match your search.\",\n      onOpenChange,\n      onQueryChange,\n      searchPlaceholder = \"Search FAQs...\",\n      themed = true,\n      value: valueProp,\n    },\n    ref\n  ) => {\n    const reduceMotion = useReducedMotion() === true;\n    const wasSearchingRef = React.useRef(false);\n    const inputRef = React.useRef<HTMLInputElement>(null);\n\n    const [query, setQuery] = React.useState(\"\");\n    const [uncontrolledOpenId, setUncontrolledOpenId] = React.useState<\n      string | null\n    >(() =>\n      getInitialOpenId(items, defaultValue, defaultOpenId, defaultOpenFirst)\n    );\n\n    const isControlled = valueProp !== undefined;\n    const openId = isControlled ? (valueProp ?? null) : uncontrolledOpenId;\n\n    const normalizedQuery = query.trim();\n    const searchActive = !hideSearch && normalizedQuery.length > 0;\n\n    const matchesQuery = React.useCallback(\n      (item: FaqProItem, searchQuery: string) =>\n        filter\n          ? filter(item, searchQuery)\n          : defaultMatchesQuery(item, searchQuery),\n      [filter]\n    );\n\n    const visibleItems = React.useMemo(() => {\n      if (hideSearch || !searchActive) {\n        return items;\n      }\n\n      return items.filter((item) => matchesQuery(item, query));\n    }, [hideSearch, items, matchesQuery, query, searchActive]);\n\n    const accordionOpenId = React.useMemo(() => {\n      if (isControlled) {\n        return isOpenIdVisible(openId, visibleItems) ? openId : null;\n      }\n\n      return resolveAccordionOpenId(openId, visibleItems, searchActive);\n    }, [isControlled, openId, searchActive, visibleItems]);\n\n    const setOpenId = React.useCallback(\n      (next: string | null | ((current: string | null) => string | null)) => {\n        if (isControlled) {\n          const resolved =\n            typeof next === \"function\" ? next(valueProp ?? null) : next;\n          onOpenChange?.(resolved);\n          return;\n        }\n\n        setUncontrolledOpenId((current) => {\n          const resolved = typeof next === \"function\" ? next(current) : next;\n          onOpenChange?.(resolved);\n          return resolved;\n        });\n      },\n      [isControlled, onOpenChange, valueProp]\n    );\n\n    const updateQuery = React.useCallback(\n      (nextQuery: string) => {\n        setQuery(nextQuery);\n        onQueryChange?.(nextQuery);\n      },\n      [onQueryChange]\n    );\n\n    React.useEffect(() => {\n      if (isControlled || hideSearch) {\n        return;\n      }\n\n      if (searchActive) {\n        wasSearchingRef.current = true;\n\n        if (accordionOpenId !== openId) {\n          setUncontrolledOpenId(accordionOpenId);\n          onOpenChange?.(accordionOpenId);\n        }\n\n        return;\n      }\n\n      if (wasSearchingRef.current) {\n        wasSearchingRef.current = false;\n        const restored = getInitialOpenId(\n          items,\n          undefined,\n          defaultOpenId,\n          defaultOpenFirst\n        );\n        setUncontrolledOpenId(restored);\n        onOpenChange?.(restored);\n        return;\n      }\n\n      if (openId && !items.some((item) => item.id === openId)) {\n        setUncontrolledOpenId(null);\n        onOpenChange?.(null);\n      }\n    }, [\n      accordionOpenId,\n      defaultOpenFirst,\n      defaultOpenId,\n      hideSearch,\n      isControlled,\n      items,\n      onOpenChange,\n      openId,\n      searchActive,\n    ]);\n\n    const handleValueChange = React.useCallback(\n      (nextValue: string[]) => {\n        setOpenId(nextValue[0] ?? null);\n      },\n      [setOpenId]\n    );\n\n    const handleSearchKeyDown = (\n      event: React.KeyboardEvent<HTMLInputElement>\n    ) => {\n      if (event.key === \"Escape\" && query) {\n        event.preventDefault();\n        updateQuery(\"\");\n      }\n    };\n\n    const accordionValue = accordionOpenId ? [accordionOpenId] : [];\n    const hasItems = items.length > 0;\n    const hasVisibleItems = visibleItems.length > 0;\n    const statusMessage = searchActive\n      ? `${visibleItems.length} result${visibleItems.length === 1 ? \"\" : \"s\"} for \"${normalizedQuery}\"`\n      : \"\";\n    const emptyMessageText = searchActive ? noResultsMessage : emptyMessage;\n\n    return (\n      <div\n        className={cn(\n          themed && componentThemeClassName,\n          \"mx-auto flex w-full max-w-2xl flex-col gap-3\",\n          className\n        )}\n        data-slot=\"faq-pro\"\n        ref={ref}\n      >\n        {hideSearch ? null : (\n          <div className=\"relative\" data-slot=\"faq-pro-search\">\n            <input\n              aria-label={searchPlaceholder}\n              className={searchInputClassName}\n              onChange={(event) => updateQuery(event.target.value)}\n              onKeyDown={handleSearchKeyDown}\n              placeholder={searchPlaceholder}\n              ref={inputRef}\n              type=\"search\"\n              value={query}\n            />\n            {query ? (\n              <button\n                aria-label=\"Clear search\"\n                className=\"absolute top-1/2 right-3 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                onClick={() => {\n                  updateQuery(\"\");\n                  inputRef.current?.focus();\n                }}\n                type=\"button\"\n              >\n                <X aria-hidden className=\"size-4\" />\n              </button>\n            ) : null}\n          </div>\n        )}\n\n        <output\n          aria-live=\"polite\"\n          className=\"sr-only\"\n          data-slot=\"faq-pro-status\"\n        >\n          {statusMessage}\n        </output>\n\n        {hasItems && hasVisibleItems ? null : (\n          <p\n            className=\"px-2 py-8 text-center text-[14px] text-muted-foreground\"\n            data-slot=\"faq-pro-empty\"\n          >\n            {emptyMessageText}\n          </p>\n        )}\n\n        {hasVisibleItems ? (\n          <AccordionPrimitive.Root\n            aria-label=\"Frequently asked questions\"\n            className=\"flex flex-col gap-2.5\"\n            data-slot=\"faq-pro-list\"\n            multiple={false}\n            onValueChange={handleValueChange}\n            value={accordionValue}\n          >\n            {visibleItems.map((item) => (\n              <FaqProRow\n                item={item}\n                key={item.id}\n                query={query}\n                reduceMotion={reduceMotion}\n              />\n            ))}\n          </AccordionPrimitive.Root>\n        ) : null}\n      </div>\n    );\n  }\n);\n\nFaqPro.displayName = \"FaqPro\";\n\nexport { FaqPro };\n",
      "type": "registry:ui"
    }
  ],
  "title": "FAQ Pro",
  "description": "Searchable FAQ accordion with rounded cards, animated panels, automatic match expansion, and inline query highlighting. Built with Base UI Accordion, Motion, and Lucide."
}
