+ {/* A real heading: the answer sits outside the listbox, where headings are allowed. */}
+
{t('search.overlay.ai_autocomplete_list_heading')}
-
+
{initialLoading ? (
-
+
) : (
+
+
+
+ {responseLoading ? fixIncompleteMarkdown(message) : message}
+
+
{!aiCouldNotAnswer && message !== '' ? (
) : null}
-
- {responseLoading ? fixIncompleteMarkdown(message) : message}
-
)}
{!responseLoading ? (
@@ -457,50 +452,6 @@ export function AskAIResults({
>
) : null}
- {!aiCouldNotAnswer && !responseLoading && references && references.length > 0 ? (
- <>
-
-
-
- {t('search.ai.references')}
-
- {references
- .map((source, index) => {
- if (index >= MAX_REFERENCES_TO_SHOW) {
- return null
- }
- const refIndex = index + referencesIndexOffset
- return (
- {
- referenceOnSelect(source.url)
- }}
- active={refIndex === selectedIndex}
- ref={(element: HTMLLIElement | null) => {
- if (listElementsRef.current) {
- listElementsRef.current[refIndex] = element
- }
- }}
- >
-
-
-
- {source.title}
-
- )
- })
- .filter(Boolean)}
-
-
- >
- ) : null}
{announcement}
diff --git a/src/search/components/input/SearchContext.tsx b/src/search/components/input/SearchContext.tsx
index 2e7e3d8cc87f..6cafbf857152 100644
--- a/src/search/components/input/SearchContext.tsx
+++ b/src/search/components/input/SearchContext.tsx
@@ -1,6 +1,7 @@
-import { createContext, useContext, RefObject, SetStateAction, MutableRefObject } from 'react'
+import { createContext, useContext, SetStateAction, MutableRefObject } from 'react'
import type { AIReference } from '../types'
import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types'
+import type { SearchOption, SearchOptionModel } from './search-options'
export type TranslationFunction = (
strings: TemplateStringsArray | string,
@@ -12,7 +13,6 @@ export interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHi
}
export interface GeneralSearchHitWithOptions extends GeneralSearchHit {
- isViewAllResults?: boolean
isNoResultsFound?: boolean
isSearchDocsOption?: boolean
}
@@ -25,8 +25,6 @@ export interface AskAIState {
setAISearchError: (isError?: boolean) => void
references: AIReference[]
setReferences: (value: SetStateAction
) => void
- referencesIndexOffset: number
- referenceOnSelect: (url: string) => void
askAIEventGroupId: MutableRefObject
aiSearchError: boolean
aiCouldNotAnswer: boolean
@@ -35,13 +33,13 @@ export interface AskAIState {
export interface SearchContextType {
t: TranslationFunction
- generalSearchOptions: GeneralSearchHitWithOptions[]
- aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[]
- generalSearchResultOnSelect: (selectedOption: GeneralSearchHit) => void
- aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void
- performGeneralSearch: () => void
- selectedIndex: number
- listElementsRef: RefObject>
+ model: SearchOptionModel
+ // null means nothing is selected. Drives aria-activedescendant.
+ activeId: string | null
+ // Shared by mouse and keyboard, so both dispatch the same way.
+ activate: (option: SearchOption) => void
+ // One stable callback for every row, keyed by the element's own id.
+ registerOption: (element: HTMLElement | null) => (() => void) | void
askAIState: AskAIState
showSpinner: boolean
searchLoading: boolean
diff --git a/src/search/components/input/SearchDialog.tsx b/src/search/components/input/SearchDialog.tsx
new file mode 100644
index 000000000000..48c8d6027036
--- /dev/null
+++ b/src/search/components/input/SearchDialog.tsx
@@ -0,0 +1,127 @@
+import React, { useEffect, useRef, useState, type RefObject } from 'react'
+import { createPortal } from 'react-dom'
+import { focusTrap } from '@primer/behaviors'
+import { useTheme as useBrandTheme } from '@primer/react-brand'
+
+import styles from './SearchOverlay.module.scss'
+
+type Props = {
+ ariaLabel: string
+ returnFocusRef: RefObject
+ initialFocusRef?: RefObject
+ onClose: () => void
+ children: React.ReactNode
+}
+
+// @primer/react-brand has no Dialog, Overlay, or Portal, so this hand-builds the portal,
+// focus trap, Escape, click-outside, and return focus.
+// Portals to `document.body`: dialog-overrides.scss forces `z-index: 3` inside
+// `#__primerPortalRoot__` so the version picker clears the header, which would clamp this too.
+export function SearchDialog({
+ ariaLabel,
+ returnFocusRef,
+ initialFocusRef,
+ onClose,
+ children,
+}: Props) {
+ const dialogRef = useRef(null)
+
+ // The portal sits outside the provider's element, so stamp the resolved mode on the scrim.
+ const { colorMode } = useBrandTheme()
+ // The type allows "auto", but the provider has already resolved it.
+ const brandColorMode = colorMode === 'dark' ? 'dark' : 'light'
+
+ // Open state comes from the URL, so this renders during SSR with no document to portal into.
+ const [isMounted, setIsMounted] = useState(false)
+ useEffect(() => {
+ setIsMounted(true)
+ }, [])
+
+ // Abort the focusTrap controller on cleanup, or the trap outlives the dialog and steals focus.
+ useEffect(() => {
+ if (!isMounted || !dialogRef.current) {
+ return
+ }
+ const controller = focusTrap(dialogRef.current, initialFocusRef?.current || undefined)
+ return () => controller?.abort()
+ }, [isMounted, initialFocusRef])
+
+ // Lock body scroll behind the scrim; pad for the scrollbar's width so content doesn't shift.
+ useEffect(() => {
+ if (!isMounted) {
+ return
+ }
+ const { body, documentElement } = document
+ const previousOverflow = body.style.overflow
+ const previousPaddingRight = body.style.paddingRight
+ const scrollbarWidth = window.innerWidth - documentElement.clientWidth
+
+ body.style.overflow = 'hidden'
+ if (scrollbarWidth > 0) {
+ const currentPaddingRight = parseFloat(window.getComputedStyle(body).paddingRight) || 0
+ body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`
+ }
+
+ return () => {
+ body.style.overflow = previousOverflow
+ body.style.paddingRight = previousPaddingRight
+ }
+ }, [isMounted])
+
+ // Listen on document to keep the scrim handler-free. Use mousedown so drags onto it don't close.
+ useEffect(() => {
+ if (!isMounted) {
+ return
+ }
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // The input closes on Escape and prevents default, so this catches Escape elsewhere.
+ if (event.key === 'Escape' && !event.defaultPrevented) {
+ onClose()
+ }
+ }
+ const handleMouseDown = (event: MouseEvent) => {
+ const target = event.target
+ if (target instanceof Node && !dialogRef.current?.contains(target)) {
+ // Prevent default, or focus moves to after the dialog returns it to the opener.
+ event.preventDefault()
+ onClose()
+ }
+ }
+ document.addEventListener('keydown', handleKeyDown)
+ document.addEventListener('mousedown', handleMouseDown)
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ document.removeEventListener('mousedown', handleMouseDown)
+ }
+ }, [isMounted, onClose])
+
+ // Skip if the opener left the DOM, for example after navigating to a picked result.
+ useEffect(() => {
+ return () => {
+ // Read at teardown: Header swaps `parentRef` between desktop and mobile buttons on resize.
+ const returnFocusTo = returnFocusRef.current
+ if (returnFocusTo && document.contains(returnFocusTo)) {
+ returnFocusTo.focus({ preventScroll: true })
+ }
+ }
+ }, [returnFocusRef])
+
+ if (!isMounted) {
+ return null
+ }
+
+ return createPortal(
+ ,
+ document.body,
+ )
+}
diff --git a/src/search/components/input/SearchGroups.tsx b/src/search/components/input/SearchGroups.tsx
index 9707e0bef149..1f65832db96f 100644
--- a/src/search/components/input/SearchGroups.tsx
+++ b/src/search/components/input/SearchGroups.tsx
@@ -1,246 +1,218 @@
-import { ActionList, Spinner } from '@primer/react'
-import {
- SearchIcon,
- FileIcon,
- ArrowRightIcon,
- CopilotIcon,
- CommentIcon,
-} from '@primer/octicons-react'
+import type { ReactNode } from 'react'
+import { Spinner } from '@primer/react'
+import { SearchIcon, ArrowUpRightIcon, TriangleDownIcon, FileIcon } from '@primer/octicons-react'
import { AskAIResults } from './AskAIResults'
-import { useSearchContext, AutocompleteSearchHitWithUserQuery } from './SearchContext'
+import { useSearchContext } from './SearchContext'
+import { VIEW_ALL_OPTION_ID, type ReferenceOption, type SearchOption } from './search-options'
import styles from './SearchOverlay.module.scss'
+import answerStyles from './AskAIResults.module.scss'
+type OptionRowProps = {
+ option: SearchOption
+ label: string
+ // Figma gives AI rows a caret and docs rows an arrow-up-right.
+ trailingIcon: ReactNode
+}
+
+function OptionRow({ option, label, trailingIcon }: OptionRowProps) {
+ const { activeId, activate, registerOption } = useSearchContext()
+ const isActive = activeId === option.id
+ // Figma insets docs-row separators 4px from each end.
+ const isDocs = option.kind === 'general' || option.kind === 'search-docs'
+
+ return (
+ // No key handler: selection is virtual, so the combobox input handles every key.
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events
+ event.preventDefault()}
+ onClick={() => activate(option)}
+ >
+ {label}
+
+ {trailingIcon}
+
+
+ )
+}
+
+// A Copilot source. Matches the docs rows' size and active state, with a leading file glyph.
+function SourceRow({ option }: { option: ReferenceOption }) {
+ const { activeId, activate, registerOption } = useSearchContext()
+ const isActive = activeId === option.id
+
+ return (
+ // No key handler: selection is virtual, so the combobox input handles every key.
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events
+ event.preventDefault()}
+ onClick={() => activate(option)}
+ >
+
+ {option.payload.title}
+
+ )
+}
+
+const SOURCES_HEADING_ID = 'search-sources-heading'
+
+// Renders the Copilot answer and the listbox. The answer, with its prose, links and feedback
+// buttons, sits outside the listbox: a listbox may only hold options and groups, and some
+// screen readers expose nothing else inside one. Headings are allowed out here too, so the
+// answer and its sources get real ones.
export function SearchGroups() {
- const {
- t,
- generalSearchOptions,
- aiOptionsWithUserInput,
- generalSearchResultOnSelect,
- aiAutocompleteOnSelect,
- performGeneralSearch,
- selectedIndex,
- listElementsRef,
- askAIState,
- showSpinner,
- searchLoading,
- previousSuggestionsListHeight,
- } = useSearchContext()
+ const { t, model, askAIState, showSpinner, searchLoading, previousSuggestionsListHeight } =
+ useSearchContext()
const isInAskAIState = askAIState?.isAskAIState && !askAIState.aiSearchError
- const isInAskAIStateButNoAnswer = isInAskAIState && askAIState.aiCouldNotAnswer
+
+ // The pinned view-all renders outside this element, below the scroller; aria-owns makes it
+ // a child of the listbox, so aria-activedescendant can name it.
+ const listboxProps = {
+ id: 'search-listbox',
+ role: 'listbox',
+ 'aria-label': t('search.overlay.suggestions_list_aria_label'),
+ 'aria-owns': model.viewAll ? VIEW_ALL_OPTION_ID : undefined,
+ } as const
// Reuse this spinner for autocomplete; Ask AI streaming shows its own spinner.
if (showSpinner && !isInAskAIState) {
return (
-
-
-
+ <>
+
+
+
+ {/* Kept mounted, empty and busy, so the input's aria-controls still resolves. */}
+
+ >
)
}
const groups = []
- // Show general search suggestions above the Ask AI section when Ask AI cannot answer.
- if (generalSearchOptions.length || isInAskAIStateButNoAnswer) {
- const items = []
- for (let index = 0; index < generalSearchOptions.length; index++) {
- const option = generalSearchOptions[index]
- if (option.isNoResultsFound) {
- items.push(
-
- {option.title}
- ,
- )
- // No-results ends the general list.
- break
- // When autocomplete fails, let the user's query fall back to docs search.
- } else if (option.isSearchDocsOption) {
- const isActive = selectedIndex === index
- items.push(
- performGeneralSearch()}
- aria-label={t('search.overlay.search_docs_with_query').replace('{query}', option.title)}
- ref={(element: HTMLLIElement | null) => {
- if (listElementsRef.current) {
- listElementsRef.current[index] = element
- }
- }}
- >
-
-
-
- {option.title}
-
-
-
- ,
- )
- } else if (option.title) {
- const isActive = selectedIndex === index
- items.push(
-
- option.isViewAllResults ? performGeneralSearch() : generalSearchResultOnSelect(option)
- }
- className={option.isViewAllResults ? styles.viewAllSearchResults : ''}
- active={isActive}
- tabIndex={-1}
- ref={(element: HTMLLIElement | null) => {
- if (listElementsRef.current) {
- listElementsRef.current[index] = element
- }
- }}
- >
- {!option.isNoResultsFound && (
-
-
-
- )}
- {option.title}
-
-
-
- ,
- )
- }
- }
+ if (isInAskAIState && model.references.length) {
+ groups.push(
+
+ {model.references.map((option) => (
+
+ ))}
+
,
+ )
+ }
+
+ if (model.ai.length && !isInAskAIState) {
+ groups.push(
+
+
+ {t('search.overlay.ai_autocomplete_list_heading')}
+
+ {model.ai.map((option) => (
+
}
+ />
+ ))}
+
,
+ )
+ }
+ if (model.general.length || model.noResults) {
groups.push(
-
-
+
+
{t('search.overlay.general_suggestions_list_heading')}
-
+
{searchLoading && isInAskAIState ? (
+ ) : model.noResults ? (
+ // Not an option, so arrows need no skip rule. The overlay's live region announces it.
+
{t('search.overlay.no_results_found')}
) : (
- items
- )}
- ,
- )
-
- if (isInAskAIState || isInAskAIStateButNoAnswer) {
- groups.push(
)
- }
-
- if (isInAskAIState) {
- groups.push(
-
-
- (
+ : }
/>
-
- ,
- )
- }
-
- // Hide the bottom divider for no-answer, AI-error, and empty-suggestions states.
- if (
- !isInAskAIState &&
- !askAIState.aiSearchError &&
- generalSearchOptions.filter((option) => !option.isViewAllResults && !option.isNoResultsFound)
- .length &&
- aiOptionsWithUserInput.length
- ) {
- groups.push(
)
- }
- }
-
- if (aiOptionsWithUserInput.length && !isInAskAIState) {
- groups.push(
-
-
-
- {t('search.overlay.ai_autocomplete_list_heading')}
-
- {aiOptionsWithUserInput.map((option: AutocompleteSearchHitWithUserQuery, index: number) => {
- // Since general search comes first, we need to add an offset for AI suggestions
- const indexWithOffset = generalSearchOptions.length + index
- const isActive = selectedIndex === indexWithOffset
- const item = (
- aiAutocompleteOnSelect(option)}
- active={isActive}
- tabIndex={-1}
- ref={(element: HTMLLIElement | null) => {
- if (listElementsRef.current) {
- listElementsRef.current[indexWithOffset] = element
- }
- }}
- >
-
-
-
- {option.term}
-
-
-
-
- )
- return item
- })}
- ,
+ ))
+ )}
+
,
)
}
- return <>{groups}>
+ return (
+ <>
+ {/* Rendered even with no docs results, so the answer never depends on them. */}
+ {isInAskAIState && (
+
+ )}
+ {isInAskAIState && model.references.length > 0 && (
+
+ {t('search.ai.references')}
+
+ )}
+ {groups}
+ >
+ )
}
diff --git a/src/search/components/input/SearchOverlay.module.scss b/src/search/components/input/SearchOverlay.module.scss
index cec958b78bd5..4907f3269ff4 100644
--- a/src/search/components/input/SearchOverlay.module.scss
+++ b/src/search/components/input/SearchOverlay.module.scss
@@ -5,86 +5,276 @@
$mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76));
-.overlayBackdrop {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: var(
- --overlay-backdrop-bgColor,
- var(--color-primer-fg-canvas-backdrop, rgba(31, 35, 40, 0.5))
+// Figma's dashed rule: 3px dashes, 3px gaps. A gradient fixes the dash length, which
+// `border-style: dashed` leaves to the browser, and adds no height to rows.
+@mixin dashedRuleGeometry($inset: 0px) {
+ background-size: calc(100% - #{$inset * 2}) 1px;
+ background-position: $inset 0;
+}
+
+@mixin dashedRule($inset: 0px) {
+ background-image: repeating-linear-gradient(
+ to right,
+ var(--brand-color-border-subtle) 0 3px,
+ transparent 3px 6px
);
- // Keep the backdrop above page content and below the overlay.
- z-index: 1000;
+ background-repeat: no-repeat;
+ @include dashedRuleGeometry($inset);
}
-.overlayContainer {
- // Place the overlay above the backdrop.
- z-index: 1001;
- top: 0;
- left: 0;
- width: searchVariables.$smSearchOverlayWidth !important;
- max-width: 100%;
- height: auto;
- max-height: 95vh;
- overflow-y: auto;
+// The dialog is a child, not a sibling, so a mousedown in the gutter counts as click-outside.
+.scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
display: flex;
flex-direction: column;
- justify-content: center;
align-items: center;
- @include breakpoint(sm) {
- top: 0 !important;
- left: 0 !important;
- width: searchVariables.$smSearchOverlayWidth !important;
+ background-color: searchVariables.$searchScrimColor;
+ backdrop-filter: blur(25px);
+
+ // `[data-color-mode]:not(html)` in frame/stylesheets/index.scss clears nested theme
+ // backgrounds at specificity (0,1,1), so a bare `.scrim` loses and the dim disappears.
+ &[data-color-mode] {
+ background-color: searchVariables.$searchScrimColor;
+ }
+
+ overflow-y: auto;
+ overscroll-behavior: contain;
+
+ @include breakpoint(md) {
+ justify-content: center;
+ padding: searchVariables.$searchDialogGutter;
}
+}
+
+// Full-bleed below `md`. From `md` up, the Figma's floating card.
+.dialog {
+ width: 100%;
+ min-height: 100%;
+ // Without a cap, the scrim scrolls the overflow and pushes the pinned footer off-screen.
+ max-height: 100%;
+
+ display: flex;
+ flex-direction: column;
+
+ // Mona Sans, loaded by @primer/react-brand/fonts/fonts.css.
+ font-family: var(--brand-body-fontFamily);
+ background-color: var(--brand-color-canvas-default);
+
+ // Match brand's antialiased type. Primer's subpixel default renders the same weight heavier.
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
@include breakpoint(md) {
- top: 0 !important;
- left: 0 !important;
- width: searchVariables.$mdSearchOverlayWidth !important;
+ max-width: searchVariables.$searchDialogMaxWidth;
+ min-height: 0;
+ max-height: calc(100vh - #{searchVariables.$searchDialogGutter} * 2);
+ // Unlike `justify-content: center` on the scrim, this scrolls an over-tall card from its top.
+ margin: auto;
+ padding: 16px 16px 32px;
+ gap: 24px;
+
+ border: 1px solid var(--brand-color-border-default);
+ border-radius: 16px;
+ box-shadow: 0 0 34px rgba(0, 0, 0, 0.1);
+ overflow-y: auto;
}
- @include breakpoint(lg) {
- // Offset by twice the header's 8px p-2 padding.
- top: 16px !important;
- left: calc(50vw - searchVariables.$lgSearchOverlayWidth / 2) !important;
- width: searchVariables.$lgSearchOverlayWidth !important;
+ // Type from Figma node 446-105514. The field is @primer/react's TextInput, since brand's
+ // has no interactive trailing action for the clear button. The rules below restyle it.
+
+ input {
+ font-family: inherit;
+ font-size: 18px;
+ font-weight: 480;
+ line-height: 1.5;
+ letter-spacing: 0.18px;
}
- @include breakpoint(xl) {
- top: 16px !important;
- left: calc(50vw - searchVariables.$xlSearchOverlayWidth / 2) !important;
- width: searchVariables.$xlSearchOverlayWidth !important;
+ // Figma draws the field as a bare icon and text on the canvas, so strip Primer's input box.
+ [class*="TextInput-wrapper"] {
+ height: 40px;
+ padding-left: 0;
+ background-color: transparent;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+
+ // Focus stays in the input while arrow keys move through results, so a :focus-within
+ // ring would always show and convey nothing. The caret marks focus.
+ outline: none;
+
+ &::after {
+ display: none;
+ }
+ }
+
+ // Figma puts an 18px glyph 24px from the query text. Primer ships 16px and 8px.
+ :global(.TextInput-icon) {
+ margin-right: 24px;
+
+ svg {
+ width: 18px;
+ height: 18px;
+ }
+ }
+
+ .dividerTopMargin {
+ margin: 0;
}
}
+// Wraps the scroller and the pinned view-all link, so both are valid aria-activedescendant targets.
+.listbox {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1 1 auto;
+}
+
+// Presentational only: aria-label names each group, and a listbox can't own a heading element.
+.groupHeading {
+ padding: 24px 0;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 700;
+ line-height: 1.2;
+ color: var(--brand-color-text-muted);
+}
+
+// The first heading already has the dialog's 24px flex gap above it.
+[role="group"]:first-child > .groupHeading {
+ padding-top: 0;
+}
+
+.option {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-height: 56px;
+ // Figma insets AI rows 4px, but that puts text 2px off the active ring, so all rows use 8px.
+ padding: 16px 8px;
+ cursor: pointer;
+
+ font-family: inherit;
+ font-size: 16px;
+ // Figma says 550, but in "Mona Sans WT3 VF". Brand's "Mona Sans" is heavier at the same
+ // weight, so 500 matches the design visually.
+ font-weight: 500;
+ line-height: 1.5;
+ letter-spacing: 0.16px;
+ color: var(--brand-color-text-default);
+
+ & + & {
+ @include dashedRule;
+ }
+}
+
+// Figma insets docs-row rules 4px at each end. Set geometry only, never `background-image`,
+// so the active row's `background-image: none` still wins.
+.optionDocs + .optionDocs {
+ @include dashedRuleGeometry(4px);
+}
+
+$activeIndicatorColor: var(--fgColor-accent, var(--color-accent-fg, #0969da));
+// Translucent, so they work in both color modes. Same values as the rest of docs.github.com.
+$optionHoverFill: rgba(129, 139, 152, 0.1);
+$optionActiveFill: rgba(129, 139, 152, 0.15);
+
+// Figma has no selected state. The ring carries the contrast because the fill alone is too
+// faint to track. Hover gets a lighter fill and no ring, so it never looks selected.
+.option:hover {
+ background-color: $optionHoverFill;
+ border-radius: 6px;
+}
+
+.optionActive {
+ background-color: $optionActiveFill;
+ border-radius: 6px;
+ font-weight: 600;
+ // Inset, so the scroller can't clip it. Not brand's accent: that green reads as "success".
+ box-shadow: inset 0 0 0 2px $activeIndicatorColor;
+
+ .optionTrailing {
+ color: $activeIndicatorColor;
+ }
+
+ // Forced colors drop the fill and the ring, so draw the selection as a system-color outline.
+ @media (forced-colors: active) {
+ outline: 2px solid Highlight;
+ outline-offset: -2px;
+ }
+}
+
+// Drop the rules around the active row, since a rounded fill against a rule looks clipped.
+// Two classes, to beat the specificity of `.option + .option`.
+.option.optionActive,
+.optionActive + .option {
+ background-image: none;
+}
+
+.optionLabel {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.optionTrailing {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ // Center the 16px octicon in Figma's 20px glyph box.
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ color: var(--brand-color-text-muted);
+}
+
.header {
display: flex;
- padding: 12px 14px 0px 14px !important;
+ align-items: center;
width: 100%;
- background-color: var(--overlay-bgColor) !important;
+ min-height: 40px;
+ // Below `md` the dialog is full-bleed, so the header keeps its own inset.
+ padding: 12px 14px 0 14px;
+
+ @include breakpoint(md) {
+ padding: 0;
+ }
}
+// Figma spaces this 12px from the field's clear button.
+.closeButton {
+ flex-shrink: 0;
+ margin-left: 12px;
+}
+
+// Outside the listbox, so the disclaimer stays put while results scroll.
.footer {
- padding: 5px 16px 13px 16px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ flex-shrink: 0;
+
width: 100%;
- background-color: var(--overlay-bgColor);
- overflow-y: auto;
- min-height: 15vh;
- border-top: 1px solid
- var(--borderColor-default, var(--color-border-default, #d0d7de));
+ // Below `md` the dialog has no flex `gap`, so this adds 12px under the view-all link.
+ padding: 12px 16px 13px 16px;
+ // No border-top: Figma closes the card with space, not a rule.
- @include breakpoint(sm) {
- min-height: fit-content !important;
+ @include breakpoint(md) {
+ padding: 0;
}
}
.betaToken {
color: var(--fgColor-success, var(--fgColor-open, green)) !important;
- background-color: var(--overlay-bgColor);
margin-right: 1em;
height: 22px !important;
font-weight: 700 !important;
@@ -101,11 +291,56 @@ $mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76));
.suggestionsList {
width: 100%;
max-width: 100%;
- // In the rare viewport case where the results are taller than the viewport, scroll
+ // This list takes the overflow, so the view-all link and footer below it stay put.
max-height: 95vh;
- overflow-y: auto;
overflow-x: hidden;
- padding: 4px 0 4px 0 !important;
+ padding: 0 !important;
+
+ // Force a visible scrollbar, since macOS overlay scrollbars hide when idle.
+ // Safari ignores `scrollbar-gutter: stable` when content fits, so rows would shift while typing.
+ overflow-y: scroll;
+
+ // Put the 12px scrollbar lane in the card's right padding, so rows line up with the header
+ // and footer. Only WebKit/Blink reserve the lane, and only `md` and up has padding for it.
+ @supports selector(::-webkit-scrollbar) {
+ @include breakpoint(md) {
+ // Widen instead of a negative margin, which can't grow a box with an explicit width.
+ width: calc(100% + 12px);
+ max-width: calc(100% + 12px);
+ }
+ }
+
+ &::-webkit-scrollbar {
+ width: 12px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ // Text-muted, not border-default: border-default is about 1.9:1 on the dark canvas,
+ // under the WCAG 1.4.11 3:1 minimum. The transparent border insets the pill.
+ background-color: color-mix(
+ in srgb,
+ var(--brand-color-text-muted) 75%,
+ transparent
+ );
+ background-clip: padding-box;
+ border: 3px solid transparent;
+ border-radius: 6px;
+ }
+
+ // Firefox only: Chrome drops all ::-webkit-scrollbar rules if scrollbar-width/color is set.
+ @supports not selector(::-webkit-scrollbar) {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(
+ in srgb,
+ var(--brand-color-text-muted) 75%,
+ transparent
+ )
+ transparent;
+ }
}
.errorBanner {
@@ -114,11 +349,42 @@ $mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76));
margin-bottom: 16px;
}
-.viewAllSearchResults {
- button {
- span {
- color: var(--color-accent-emphasis) !important;
- font-weight: 500 !important;
+.viewAllResults {
+ // Brand's Link centers its label, so a stretched one renders as a centered bar.
+ // Set here, not on the parent, because the sibling scroller needs full width.
+ align-self: flex-start;
+ flex-shrink: 0;
+
+ // 14px plus the 10px padding puts the label 24px below the last row, per Figma.
+ margin-top: 14px;
+ // Pad the 24px target to 44px and frame the selected fill. Negative margins cancel the
+ // padding so layout holds. 10px, not 12px, keeps the overhang inside the 12px above the footer.
+ padding: 10px 8px;
+ margin-bottom: -10px;
+ margin-inline: -8px;
+ border-radius: 6px;
+
+ // Brand positions its underline against the link box, which the padding enlarged, so move
+ // it back under the text. Keep brand's trailing-visual terms so `arrowDirection` still works.
+ &::after {
+ bottom: calc(10px - 0.15em);
+ left: 8px;
+ width: calc(100% - 16px - var(--gap) - var(--visual-width));
+ }
+
+ // Arrow-key selection: the fill hugs the label and the underline shows as on hover, so it
+ // still reads as a link, not a result row.
+ &[aria-selected="true"] {
+ background-color: $optionActiveFill;
+
+ &::after {
+ transform: scaleX(1);
+ }
+
+ // Forced colors drop the fill, so draw the selection as a system-color outline.
+ @media (forced-colors: active) {
+ outline: 2px solid Highlight;
+ outline-offset: -2px;
}
}
}
@@ -163,23 +429,34 @@ $mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76));
display: block;
margin-top: 16px;
width: 100%;
+ // Replace the global `hr` solid border with the rows' dashed rule.
+ height: 1px;
+ border-bottom: 0;
+ @include dashedRule;
}
-.dividerTopMarginHidden {
- display: none;
- margin-top: 16px;
- width: 100%;
-}
+// The input's focus indicator. Primer's ring is stripped because the input holds focus
+// nearly all the time; instead this rule turns solid accent while the input is focused,
+// and drops back to dashed when Tab moves to the close button or into an answer. The
+// second pixel is a shadow, so the layout doesn't move.
+.header:has(input:focus) + .dividerTopMargin {
+ background-image: none;
+ background-color: $activeIndicatorColor;
+ box-shadow: 0 1px 0 $activeIndicatorColor;
-.dividerFullWidth {
- width: 100%;
+ // Forced colors drop backgrounds and shadows; a border survives as a system color.
+ @media (forced-colors: active) {
+ border-bottom: 2px solid Highlight;
+ }
}
.privacyDisclaimer {
- color: var(--color-fg-muted);
- margin-top: 0.5rem;
+ color: var(--brand-color-text-muted);
+ // The footer's `gap` handles spacing, so clear the default paragraph margins.
+ margin-top: 0;
margin-bottom: 0;
- font-size: small;
+ font-size: 14px;
+ line-height: 1.5;
}
.trailingVisualActive {
@@ -191,11 +468,3 @@ $mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76));
visibility: hidden;
width: 1rem;
}
-
-.leadingVisualHidden {
- visibility: hidden;
-}
-
-.leadingVisualVisible {
- visibility: visible;
-}
diff --git a/src/search/components/input/SearchOverlay.tsx b/src/search/components/input/SearchOverlay.tsx
index ebecf690624a..f220de91bc63 100644
--- a/src/search/components/input/SearchOverlay.tsx
+++ b/src/search/components/input/SearchOverlay.tsx
@@ -1,23 +1,22 @@
-import React, { useState, useRef, RefObject, useEffect, useMemo } from 'react'
-import cx from 'clsx'
+import React, { useState, useRef, RefObject, useEffect, useMemo, useCallback } from 'react'
import { useRouter } from 'next/router'
-import { ActionList, IconButton, Overlay, TextInput, Banner } from '@primer/react'
-import { Stack } from '@primer/react-brand'
-import { SearchIcon, XCircleFillIcon, CopilotIcon, ArrowLeftIcon } from '@primer/octicons-react'
-import { focusTrap } from '@primer/behaviors'
+import { IconButton, TextInput, Banner } from '@primer/react'
+import { Link as BrandLink, Stack } from '@primer/react-brand'
+import { SearchIcon, XCircleFillIcon, XIcon, ArrowLeftIcon } from '@primer/octicons-react'
import { useTranslation } from '@/languages/components/useTranslation'
import { useVersion } from '@/versions/components/useVersion'
import {
AI_SEARCH_CONTEXT,
+ buildGeneralSearchPath,
executeGeneralSearch,
GENERAL_SEARCH_CONTEXT,
+ withInheritedParams,
} from '../helpers/execute-search-actions'
import { useCombinedSearchResults } from '@/search/components/hooks/useAISearchAutocomplete'
import { sendEvent, uuidv4 } from '@/events/components/events'
import { EventType } from '@/events/types'
import { ASK_AI_EVENT_GROUP, SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups'
-import { useSharedUIContext } from '@/frame/components/context/SharedUIContext'
import type { AIReference } from '../types'
import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types'
@@ -25,12 +24,9 @@ import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types'
import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query'
import { MAX_QUERY_LENGTH } from '@/search/lib/ai-search-constants'
-import {
- SearchContext,
- SearchContextType,
- AutocompleteSearchHitWithUserQuery,
- GeneralSearchHitWithOptions,
-} from './SearchContext'
+import { SearchContext, SearchContextType } from './SearchContext'
+import { buildSearchOptions, VIEW_ALL_OPTION_ID, type SearchOption } from './search-options'
+import { SearchDialog } from './SearchDialog'
import { SearchGroups } from './SearchGroups'
import styles from './SearchOverlay.module.scss'
import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML'
@@ -39,7 +35,11 @@ type Props = {
searchOverlayOpen: boolean
parentRef: RefObject
debug: boolean
+ // Escape, scrim click, or the close button. Writes the URL.
onClose: () => void
+ // Closes with a URL write and resolves once it lands. Await it, then navigate: the two
+ // must not overlap, or Next cancels one of them.
+ onNavigateAway: () => Promise
params: {
'search-overlay-input': string
'search-overlay-ask-ai': string
@@ -57,6 +57,7 @@ export function SearchOverlay({
parentRef,
debug,
onClose,
+ onNavigateAway,
params,
updateParams,
}: Props) {
@@ -68,30 +69,32 @@ export function SearchOverlay({
const isAskAIState = params['search-overlay-ask-ai'] === 'true'
const inputRef = useRef(null)
- const suggestionsListHeightRef = useRef(null)
- // Keep list item refs so keyboard navigation can scroll the selected option into view.
- const listElementsRef = React.useRef>([])
+ const suggestionsListHeightRef = useRef(null)
+ // Keyed by option id, not index, so it can't drift out of sync with the option list.
+ const optionElements = useRef(new Map())
+
+ // One stable callback for every row, so React doesn't reattach refs and empty the map.
+ const registerOption = useCallback((element: HTMLElement | null) => {
+ if (!element) {
+ return
+ }
+ const { id } = element
+ optionElements.current.set(id, element)
+ return () => {
+ optionElements.current.delete(id)
+ }
+ }, [])
- const [selectedIndex, setSelectedIndex] = useState(-1)
+ const [activeId, setActiveId] = useState(null)
const [aiQuery, setAIQuery] = useState(urlSearchInputQuery)
const [aiSearchError, setAISearchError] = useState(false)
const [aiReferences, setAIReferences] = useState([] as AIReference[])
const [aiCouldNotAnswer, setAICouldNotAnswer] = useState(false)
const [showSpinner, setShowSpinner] = useState(false)
- const [scrollPos, setScrollPos] = useState(0)
const [announcement, setAnnouncement] = useState('')
- const { hasOpenHeaderNotifications } = useSharedUIContext()
-
// Group overlay selection and keyboard events that pass this session ID.
const searchEventGroupId = useRef('')
- const overlayRef = useRef(null)
-
- useEffect(() => {
- if (searchOverlayOpen && overlayRef.current) {
- focusTrap(overlayRef.current, inputRef.current || undefined)
- }
- }, [searchOverlayOpen])
useEffect(() => {
searchEventGroupId.current = uuidv4()
@@ -99,19 +102,6 @@ export function SearchOverlay({
// Each Ask AI session gets its own event group.
const askAIEventGroupId = useRef('')
- // Header notifications push the fixed overlay down until the page scrolls past them.
- useEffect(() => {
- if (hasOpenHeaderNotifications) {
- const handleScroll = () => {
- setScrollPos(window.scrollY)
- }
-
- window.addEventListener('scroll', handleScroll)
- return () => window.removeEventListener('scroll', handleScroll)
- }
- }, [hasOpenHeaderNotifications])
- const overlayTopValue = scrollPos > 72 ? '0px' : `${88 - scrollPos}px !important`
-
const {
autoCompleteOptions,
searchLoading,
@@ -157,84 +147,75 @@ export function SearchOverlay({
autoCompleteSearchError,
])
- // Drop the typed-query duplicate; userInputOptions adds it back with isUserQuery.
- const filteredAIOptions = aiAutocompleteOptions.filter(
- (option) => option.term !== urlSearchInputQuery,
+ // Results can outlive an emptied input, so check the query or the link offers `?query=`.
+ const showViewAllResults = generalSearchResults.length > 0 && urlSearchInputQuery.trim() !== ''
+
+ const viewAllResultsHref = buildGeneralSearchPath(
+ router,
+ currentVersion,
+ urlSearchInputQuery,
+ debug,
)
- const userInputOptions =
- urlSearchInputQuery.trim() !== ''
- ? [
- {
- term: urlSearchInputQuery,
- title: urlSearchInputQuery,
- highlights: [],
- isUserQuery: true,
- },
- ]
- : []
-
- const [combinedOptions, generalOptionsWithViewStatus, aiOptionsWithUserInput] = useMemo(() => {
- setAnnouncement('')
- let generalWithView = [...generalSearchResults]
- const aiWithUser = [...userInputOptions, ...filteredAIOptions]
- const combined = [] as Array<{
- group: 'general' | 'ai' | string
- url?: string
- option: AutocompleteSearchHitWithUserQuery | GeneralSearchHitWithOptions
- }>
-
- if (generalSearchResults.length > 0) {
- generalWithView.push({
- title: t('search.overlay.view_all_search_results'),
- isViewAllResults: true,
- } as unknown as GeneralSearchHitWithOptions)
- } else if (autoCompleteSearchError) {
- if (urlSearchInputQuery.trim() !== '') {
- generalWithView.push({
- ...(userInputOptions[0] || {}),
- isSearchDocsOption: true,
- } as unknown as GeneralSearchHit)
- }
- } else if (urlSearchInputQuery.trim() !== '' && !searchLoading) {
- setAnnouncement(t('search.overlay.no_results_found_announcement'))
- generalWithView.push({
- title: t('search.overlay.no_results_found'),
- isNoResultsFound: true,
- } as unknown as GeneralSearchHitWithOptions)
- } else {
- generalWithView = []
- }
- // Keep general options before AI options because selectedIndex indexes this combined array.
- combined.push(...generalWithView.map((option) => ({ group: 'general', option })))
- // Add AI suggestions and user input only outside Ask AI and AI-error states.
- if (!aiSearchError && !isAskAIState) {
- combined.push(...aiWithUser.map((option) => ({ group: 'ai', option })))
- } else if (isAskAIState && !aiCouldNotAnswer) {
- // Ask AI references become keyboard-navigable options after results replace suggestions.
- combined.push(
- ...aiReferences.map((option) => ({
- group: 'reference',
- url: option.url,
- option: {
- term: option.title,
- highlights: [],
- isUserQuery: false,
- },
- })),
- )
- }
+ // Match SearchGroups: the spinner hides rows unless ask-AI is streaming with its own spinner.
+ const suggestionsHidden = showSpinner && !(isAskAIState && !aiSearchError)
+
+ const model = useMemo(
+ () =>
+ buildSearchOptions({
+ query: urlSearchInputQuery,
+ isAskAIState,
+ searchLoading,
+ suggestionsHidden,
+ aiSearchError,
+ aiCouldNotAnswer,
+ autoCompleteSearchError,
+ aiAutocompleteOptions,
+ aiReferences,
+ generalSearchResults,
+ showViewAllResults,
+ viewAllHref: viewAllResultsHref,
+ viewAllLabel: totalGeneralSearchResults
+ ? t('search.overlay.view_all_search_results_with_count').replace(
+ '{{n}}',
+ // The page's language, not the browser's: a German page shows "1.234" even
+ // in an English browser.
+ totalGeneralSearchResults.toLocaleString(router.locale),
+ )
+ : t('search.overlay.view_all_search_results'),
+ }),
+ [
+ urlSearchInputQuery,
+ isAskAIState,
+ searchLoading,
+ suggestionsHidden,
+ aiSearchError,
+ aiCouldNotAnswer,
+ autoCompleteSearchError,
+ aiAutocompleteOptions,
+ aiReferences,
+ generalSearchResults,
+ showViewAllResults,
+ viewAllResultsHref,
+ totalGeneralSearchResults,
+ router.locale,
+ t,
+ ],
+ )
+ const { options } = model
+ const activeIndex = activeId ? options.findIndex((option) => option.id === activeId) : -1
- return [combined, generalWithView, aiWithUser]
- }, [
- generalSearchResults,
- totalGeneralSearchResults,
- urlSearchInputQuery,
- aiSearchError,
- aiReferences,
- isAskAIState,
- autoCompleteSearchError,
- ])
+ // Announce from an effect, not the memo, so the live region doesn't re-fire on every recompute.
+ useEffect(() => {
+ setAnnouncement(model.noResults ? t('search.overlay.no_results_found_announcement') : '')
+ }, [model.noResults, t])
+
+ // Clear a selection whose row new results unmounted, so aria-activedescendant names a real row.
+ useEffect(() => {
+ if (activeId && activeIndex === -1) {
+ setActiveId(null)
+ }
+ }, [activeId, activeIndex])
// Focus manually with preventScroll because Primer Overlay initialFocusRef scrolls to the top.
useEffect(() => {
@@ -268,13 +249,17 @@ export function SearchOverlay({
aiCouldNotAnswer,
])
- // Keyboard control refs must track the current option count.
+ // Skip the view-all option: it sits outside the scroller, so scrolling to it moves the scrim.
useEffect(() => {
- listElementsRef.current = listElementsRef.current.slice(
- 0,
- generalOptionsWithViewStatus.length + aiOptionsWithUserInput.length,
- )
- }, [generalOptionsWithViewStatus, aiOptionsWithUserInput])
+ if (!activeId) {
+ return
+ }
+ const element = optionElements.current.get(activeId)
+ if (!element || !suggestionsListHeightRef.current?.contains(element)) {
+ return
+ }
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }, [activeId])
// Estimate loading space from result counts, or reserve 150px for two suggestions.
const previousSuggestionsListHeight = useMemo(() => {
@@ -288,7 +273,7 @@ export function SearchOverlay({
const handleSearchQueryChange = (event: React.ChangeEvent) => {
event.preventDefault()
const newQuery = event.target.value
- setSelectedIndex(-1)
+ setActiveId(null)
setSearchLoading(true)
updateAutocompleteResults(newQuery)
// Changing the query leaves the Ask AI state.
@@ -304,7 +289,11 @@ export function SearchOverlay({
}
}
- const generalSearchResultOnSelect = (selectedOption: GeneralSearchHit) => {
+ // Take `generalRank` from the option, not the cursor, because mouse clicks have no cursor.
+ const generalSearchResultOnSelect = async (
+ selectedOption: GeneralSearchHit,
+ generalRank: number,
+ ) => {
sendEvent({
type: EventType.search,
search_query: sanitizeSearchQuery(urlSearchInputQuery),
@@ -315,28 +304,16 @@ export function SearchOverlay({
sendEvent({
type: EventType.searchResult,
search_result_query: sanitizeSearchQuery(urlSearchInputQuery),
- search_result_index: selectedIndex,
+ search_result_index: generalRank,
search_result_total: totalGeneralSearchResults,
search_result_url: selectedOption.url || '',
- search_result_rank: (totalGeneralSearchResults - selectedIndex) / totalGeneralSearchResults,
+ search_result_rank: (totalGeneralSearchResults - generalRank) / totalGeneralSearchResults,
eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP,
eventGroupId: searchEventGroupId.current,
})
- const searchParams = new URLSearchParams((router.query as Record) || {})
- if (searchParams.has('search-overlay-open')) {
- searchParams.delete('search-overlay-open')
- }
- if (searchParams.has('search-overlay-input')) {
- searchParams.delete('search-overlay-input')
- }
- if (searchParams.has('search-overlay-ask-ai')) {
- searchParams.delete('search-overlay-ask-ai')
- }
- if (searchParams.has('query')) {
- searchParams.delete('query')
- }
- router.push(`${selectedOption.url}?${searchParams.toString()}`)
- onClose()
+ const destination = withInheritedParams(selectedOption.url || '')
+ await onNavigateAway()
+ router.push(destination)
}
// AI results replace suggestions, so keep focus in the input after selection.
@@ -351,7 +328,7 @@ export function SearchOverlay({
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: askAIEventGroupId.current,
})
- setSelectedIndex(-1)
+ setActiveId(null)
setSearchLoading(true)
updateParams({
'search-overlay-ask-ai': 'true',
@@ -362,9 +339,9 @@ export function SearchOverlay({
}
}
- const performGeneralSearch = () => {
+ const performGeneralSearch = async () => {
+ await onNavigateAway()
executeGeneralSearch(router, currentVersion, urlSearchInputQuery, debug)
- onClose()
}
const referenceOnSelect = (url: string) => {
@@ -374,128 +351,73 @@ export function SearchOverlay({
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: askAIEventGroupId.current,
})
- setSelectedIndex(-1)
- const searchParams = new URLSearchParams((router.query as Record) || {})
- if (searchParams.has('search-overlay-open')) {
- searchParams.delete('search-overlay-open')
- }
- if (searchParams.has('search-overlay-input')) {
- searchParams.delete('search-overlay-input')
+ setActiveId(null)
+ window.open(withInheritedParams(url), '_blank')
+ }
+
+ // Shared by keyboard and row clicks, so both paths report the same analytics rank.
+ const activate = (option: SearchOption) => {
+ if (option.kind === 'general') {
+ generalSearchResultOnSelect(option.payload, option.groupRank)
+ } else if (option.kind === 'ai') {
+ aiSearchOptionOnSelect(option.payload)
+ } else if (option.kind === 'reference') {
+ referenceOnSelect(option.payload.url)
+ } else {
+ // 'search-docs' and 'view-all' both run the full docs search.
+ performGeneralSearch()
}
- if (searchParams.has('search-overlay-ask-ai')) {
- searchParams.delete('search-overlay-ask-ai')
+ }
+
+ // Keep these `pressed_on` values stable so they stay comparable with historical analytics.
+ const keyboardDispatch: Record =
+ {
+ ai: { pressedOn: 'ai-option', askAIGroup: false },
+ general: { pressedOn: 'general-option', askAIGroup: false },
+ reference: { pressedOn: 'reference-option', askAIGroup: true },
+ 'search-docs': { pressedOn: 'view-all', askAIGroup: false },
+ 'view-all': { pressedOn: 'view-all', askAIGroup: false },
}
- if (searchParams.has('query')) {
- searchParams.delete('query')
+
+ // Stepping off either end clears the selection instead of wrapping.
+ const stepActiveOption = (delta: 1 | -1) => {
+ if (options.length === 0) {
+ return
}
- window.open(`${url}?${searchParams.toString()}`, '_blank')
+ const next = activeIndex === -1 ? (delta === 1 ? 0 : options.length - 1) : activeIndex + delta
+ setActiveId(next < 0 || next >= options.length ? null : options[next].id)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
- const optionsLength = listElementsRef.current?.length ?? 0
if (event.key === 'ArrowDown') {
event.preventDefault()
- if (optionsLength > 0) {
- let newIndex = 0
- if (selectedIndex === -1) {
- newIndex = 0
- } else {
- newIndex = (selectedIndex + 1) % optionsLength
- // Wraparound after the last option clears the selection.
- if (newIndex < selectedIndex) {
- newIndex = -1
- }
- }
- // If it's the "no results found" option, skip it
- if (
- newIndex >= selectedIndex &&
- (combinedOptions[newIndex]?.option as GeneralSearchHitWithOptions)?.isNoResultsFound
- ) {
- newIndex += 1
- }
- setSelectedIndex(newIndex)
- if (newIndex !== -1 && listElementsRef.current[newIndex]) {
- listElementsRef.current[newIndex]?.scrollIntoView({
- behavior: 'smooth',
- block: 'center',
- })
- }
- }
+ stepActiveOption(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
- if (optionsLength > 0) {
- let newIndex = 0
- if (selectedIndex === -1) {
- newIndex = optionsLength - 1
- } else {
- newIndex = (selectedIndex - 1 + optionsLength) % optionsLength
- // Wraparound before the first option clears the selection.
- if (newIndex > selectedIndex) {
- newIndex = -1
- }
- }
- // If it's the "no results found" option, skip it
- if (
- newIndex <= selectedIndex &&
- (combinedOptions[newIndex]?.option as GeneralSearchHitWithOptions)?.isNoResultsFound
- ) {
- newIndex -= 1
- }
- setSelectedIndex(newIndex)
- if (newIndex !== -1 && listElementsRef.current[newIndex]) {
- listElementsRef.current[newIndex]?.scrollIntoView({
- behavior: 'smooth',
- block: 'center',
- })
- }
- }
+ stepActiveOption(-1)
} else if (event.key === 'Enter') {
event.preventDefault()
- let pressedGroupKey = SEARCH_OVERLAY_EVENT_GROUP
- let pressedGroupId = searchEventGroupId
- let pressedOnContext = ''
-
- // Enter with no selected option asks AI with the typed query.
- if (selectedIndex === -1) {
- pressedOnContext = AI_SEARCH_CONTEXT
- pressedGroupKey = ASK_AI_EVENT_GROUP
- pressedGroupId = askAIEventGroupId
- sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey)
- aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit)
- } else if (
- combinedOptions.length > 0 &&
- selectedIndex >= 0 &&
- selectedIndex < combinedOptions.length
- ) {
- const selectedItem = combinedOptions[selectedIndex]
- if (!selectedItem) {
+
+ // With nothing selected, Enter asks Copilot. Full results are the last arrow stop.
+ if (activeIndex === -1) {
+ if (!urlSearchInputQuery.trim()) {
return
}
- // Send the event before running the action.
- let action = () => {}
- if (selectedItem?.group === 'general') {
- if (
- (selectedItem.option as GeneralSearchHitWithOptions).isViewAllResults ||
- (selectedItem.option as GeneralSearchHitWithOptions).isSearchDocsOption
- ) {
- pressedOnContext = 'view-all'
- action = performGeneralSearch
- } else {
- pressedOnContext = 'general-option'
- action = () => generalSearchResultOnSelect(selectedItem.option as GeneralSearchHit)
- }
- } else if (selectedItem?.group === 'ai') {
- pressedOnContext = 'ai-option'
- action = () => aiSearchOptionOnSelect(selectedItem.option as AutocompleteSearchHit)
- } else if (selectedItem?.group === 'reference') {
- pressedGroupKey = ASK_AI_EVENT_GROUP
- pressedGroupId = askAIEventGroupId
- pressedOnContext = 'reference-option'
- action = () => referenceOnSelect(selectedItem.url || '')
- }
- sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey)
- return action()
+ sendKeyboardEvent(event.key, AI_SEARCH_CONTEXT, askAIEventGroupId, ASK_AI_EVENT_GROUP)
+ aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit)
+ return
}
+
+ const option = options[activeIndex]
+ const { pressedOn, askAIGroup } = keyboardDispatch[option.kind]
+ // Send before navigating: router.push unmounts this component.
+ sendKeyboardEvent(
+ event.key,
+ pressedOn,
+ askAIGroup ? askAIEventGroupId : searchEventGroupId,
+ askAIGroup ? ASK_AI_EVENT_GROUP : SEARCH_OVERLAY_EVENT_GROUP,
+ )
+ activate(option)
} else if (event.key === 'Escape') {
event.preventDefault()
onClose()
@@ -504,7 +426,7 @@ export function SearchOverlay({
const onBackButton = () => {
// Leave the Ask AI state when the user clicks the back button
- setSelectedIndex(-1)
+ setActiveId(null)
updateParams({
'search-overlay-ask-ai': '',
'search-overlay-input': urlSearchInputQuery,
@@ -527,8 +449,6 @@ export function SearchOverlay({
},
references: aiReferences,
setReferences: setAIReferences,
- referencesIndexOffset: generalOptionsWithViewStatus.length,
- referenceOnSelect,
askAIEventGroupId,
aiSearchError,
aiCouldNotAnswer,
@@ -537,13 +457,10 @@ export function SearchOverlay({
const searchContextValue: SearchContextType = {
t,
- generalSearchOptions: generalOptionsWithViewStatus,
- aiOptionsWithUserInput,
- generalSearchResultOnSelect,
- aiAutocompleteOnSelect: aiSearchOptionOnSelect,
- performGeneralSearch,
- selectedIndex,
- listElementsRef: listElementsRef as RefObject>,
+ model,
+ activeId,
+ activate,
+ registerOption,
askAIState,
showSpinner,
searchLoading,
@@ -556,179 +473,197 @@ export function SearchOverlay({
const inErrorState = aiSearchError || (autoCompleteSearchError && !isAskAIState)
if (inErrorState) {
OverlayContents = (
-
-
+
{/* Show the AI Search UI error message whenever AI search fails. */}
{aiSearchError && (
- <>
-
-
-
-
- {t('search.overlay.ai_autocomplete_list_heading')}
-
-
-
-
-
-
-
- {/* Show the bottom divider when general results follow the AI error. */}
- {generalOptionsWithViewStatus.length > 0 && (
-
- )}
- >
+
+
+ {t('search.overlay.ai_autocomplete_list_heading')}
+
+
+
+
+
)}
-
+
)
} else {
OverlayContents = (
-
-
+
)
}
return (
- <>
-