export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Search, X } from \"lucide-react\";\nimport { IconButton } from \"cherry-styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\nimport {\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n resultsRef: React.RefObject;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record;\n isSearching: boolean;\n navigate: (\n slug: string,\n modifiers?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean },\n ) => void;\n canAskAssistant: boolean;\n onAskAssistant: () => void;\n shouldRestoreFocus: () => boolean;\n returnFocusTo: HTMLElement | null;\n}\n\nconst ANIMATION_MS = 300;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n /* Backdrop is intentionally near-black in both modes \u2014 covers everything\n behind the modal regardless of the user's theme. */\n background: rgba(0, 0, 0, 0.5);\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n\n /* type=\"search\" gets the platform clear button, which duplicates the modal's\n own close control and sits at a size and color we do not control. */\n &::-webkit-search-cancel-button,\n &::-webkit-search-decoration {\n -webkit-appearance: none;\n appearance: none;\n }\n`;\n\n// \"Ask AI\" affordance shown before the close button when the AI chat is\n// enabled. Desktop-only (like the header's Cmd+K hint) since it advertises the\n// Option+Enter shortcut; mobile users reach the assistant from the header CTA.\nconst StyledAskAssistant = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n display: none;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) => theme.colors.light};\n color: ${({ theme }) => theme.colors.grayDark};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 5px 4px 5px 8px;\n font-family: inherit;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n font-weight: 500;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n\n &:disabled {\n opacity: 0.5;\n cursor: default;\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.grayLight};\n }\n\n &:focus,\n &:active {\n outline: none;\n box-shadow: 0 0 0 0 ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n ${mq(\"lg\")} {\n display: inline-flex;\n }\n`;\n\nconst StyledAskKeys = styled.span`\n display: inline-flex;\n align-items: center;\n gap: 3px;\n`;\n\nconst StyledAskKbd = styled.kbd<{ theme: Theme }>`\n font-family: inherit;\n font-size: 12px;\n font-weight: 600;\n line-height: 1;\n min-width: 18px;\n height: 18px;\n padding: 0 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n border-radius: 4px;\n`;\n\n// The return glyph's ink sits high in its em box, so it reads top-aligned even\n// when the keycap flex-centers it. Nudge just the character (not the pill) down\n// a pixel so it optically centers and lines up with the option glyph.\nconst StyledReturnGlyph = styled.span`\n display: inline-block;\n transform: translateY(1.5px);\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n position: relative;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n ${thinScrollbar};\n\n &[hidden] {\n display: none;\n }\n`;\n\n// Single indicator that slides between result items instead of each item\n// fading its own background in and out. Positioned/sized from the active item's\n// measured offset (see the layout effect below); the transition on\n// transform + height produces the up/down glide as the active item changes.\nconst StyledHighlight = styled.div<{\n theme: Theme;\n $top: number;\n $height: number;\n}>`\n position: absolute;\n left: 8px;\n right: 8px;\n top: 0;\n z-index: 0;\n pointer-events: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n transition:\n transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),\n height 0.2s cubic-bezier(0.22, 1, 0.36, 1);\n will-change: transform, height;\n transform: translateY(${({ $top }) => $top}px);\n height: ${({ $height }) => $height}px;\n\n @media (prefers-reduced-motion: reduce) {\n transition: none;\n }\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n position: relative;\n z-index: 1;\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n\n &:focus-visible {\n outline: none;\n box-shadow: inset 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n & > span:first-of-type {\n color: ${theme.colors.primary};\n }\n `}\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n transition: color 0.15s ease;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledSnippet = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.grayDark};\n display: block;\n margin-top: 4px;\n line-height: 1.4;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n & mark {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 35%, transparent)`};\n color: inherit;\n border-radius: 4px;\n padding: 0 1px;\n }\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px 20px 12px;\n min-height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n /* Separates the spinner from \"Searching documentation...\"; the whitespace\n between them in the markup collapses away as an anonymous flex item. */\n gap: 8px;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nconst StyledLiveStatus = styled.div`\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n border: 0;\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&\")\n .replace(//g, \">\")\n .replace(/\"/g, \""\");\n}\n\nfunction highlightMatch(snippet: string, query: string): string {\n const escaped = escapeHtml(snippet);\n if (!query.trim()) return escaped;\n const q = escapeHtml(query.trim());\n const regex = new RegExp(\n `(${q.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`,\n \"gi\",\n );\n return escaped.replace(regex, \"$1\");\n}\n\nexport function SearchModalContent({\n isClosing,\n closeSearch,\n onCloseAnimationEnd,\n query,\n setQuery,\n activeIndex,\n setActiveIndex,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n canAskAssistant,\n onAskAssistant,\n shouldRestoreFocus,\n returnFocusTo,\n}: SearchModalContentProps) {\n const dialogRef = React.useRef(null);\n const inputRef = React.useRef(null);\n const previouslyFocusedRef = React.useRef(\n returnFocusTo?.isConnected === true\n ? returnFocusTo\n : typeof document === \"undefined\"\n ? null\n : (document.activeElement as HTMLElement | null),\n );\n const listboxId = React.useId();\n const optionId = (index: number) => listboxId + \"-option-\" + index;\n\n React.useEffect(() => {\n const previouslyFocused = previouslyFocusedRef.current;\n const containFocus = (event: FocusEvent) => {\n if (\n event.target instanceof Node &&\n !dialogRef.current?.contains(event.target)\n ) {\n inputRef.current?.focus();\n }\n };\n\n document.addEventListener(\"focusin\", containFocus);\n inputRef.current?.focus();\n\n return () => {\n document.removeEventListener(\"focusin\", containFocus);\n if (shouldRestoreFocus() && previouslyFocused?.isConnected) {\n previouslyFocused.focus();\n }\n };\n }, [shouldRestoreFocus]);\n\n const handleDialogKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n event.stopPropagation();\n closeSearch();\n return;\n }\n\n if (event.key !== \"Tab\") return;\n\n const focusable = Array.from(\n dialogRef.current?.querySelectorAll(\n 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex=\"-1\"])',\n ) ?? [],\n ).filter(\n (element) =>\n element.offsetParent !== null &&\n element.getAttribute(\"aria-hidden\") !== \"true\",\n );\n\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n if (event.shiftKey && document.activeElement === first) {\n event.preventDefault();\n last.focus();\n } else if (!event.shiftKey && document.activeElement === last) {\n event.preventDefault();\n first.focus();\n }\n };\n\n // Measure the active item so the sliding highlight can be placed over it.\n // useLayoutEffect runs before paint, so the first position is committed\n // without a transition (no slide-in on open); later activeIndex/hover\n // changes glide because only the transform + height differ.\n const [indicator, setIndicator] = React.useState<{\n top: number;\n height: number;\n } | null>(null);\n\n React.useLayoutEffect(() => {\n const list = resultsRef.current;\n if (!list) {\n setIndicator(null);\n return;\n }\n const items = list.querySelectorAll(\"li\");\n const active = items[activeIndex] as HTMLElement | undefined;\n if (!active) {\n setIndicator(null);\n return;\n }\n setIndicator({ top: active.offsetTop, height: active.offsetHeight });\n\n // Keep the active row inside the scroll viewport in the SAME pre-paint pass\n // that positions the highlight, so the two never desync into a one-frame\n // flicker. merged is a dependency, so re-filtering (a new query) also\n // re-scrolls the reset active row into view even when its index is\n // unchanged. This list is flat (no group headers), so no header inclusion\n // is needed; a small margin keeps rows off the container's hard edges.\n // Adjust scrollTop directly (never scrollIntoView, which can also pan\n // ancestors).\n const margin = 8;\n const viewTop = active.offsetTop - margin;\n const viewBottom = active.offsetTop + active.offsetHeight + margin;\n\n if (viewTop < list.scrollTop) {\n list.scrollTop = viewTop;\n } else if (viewBottom > list.scrollTop + list.clientHeight) {\n list.scrollTop = viewBottom - list.clientHeight;\n }\n }, [activeIndex, merged, resultsRef]);\n\n return (\n {\n // animationend bubbles, so this handler also fires for animations\n // completing on descendants (the inner modal, icons, result items).\n // Only the backdrop's own exit animation should trigger the unmount -\n // otherwise a child finishing its animation first tears the modal down\n // before the close animation plays, making it vanish instantly.\n if (e.target === e.currentTarget) onCloseAnimationEnd();\n }}\n >\n e.stopPropagation()}\n >\n \n \n {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={onKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n aria-label=\"Search documentation\"\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-haspopup=\"listbox\"\n aria-expanded=\"true\"\n aria-controls={listboxId}\n aria-activedescendant={\n merged[activeIndex] ? optionId(activeIndex) : undefined\n }\n />\n {canAskAssistant && (\n \n Ask AI\n \n \n \n \n \n \n \n )}\n \n \n \n \n \n {isSearching\n ? \"Searching documentation\"\n : merged.length === 0\n ? \"No search results found\"\n : `${merged.length} search ${merged.length === 1 ? \"result\" : \"results\"} available`}\n \n