From eecfd5e9a773bd84095edb001d9a4dae6d15b474 Mon Sep 17 00:00:00 2001 From: Anar Kafkas <36949216+kafkas@users.noreply.github.com> Date: Sun, 24 Sep 2023 22:31:32 -0400 Subject: [PATCH] Add support for keyboard navigation in search dialog (#149) * Accept searchService as prop in SearchDialog * Make hover a controlled state in SearchHits * Implement useKeyboardPress hook * Hover search hits with keyboard * Navigate to search hit page with Enter * Automatically hover first hit * Store a map of search hit refs and focus on arrow key press * Lock pointer on arrow key press and exit lock on mouse move --- .../commons/react/react-commons/src/index.ts | 1 + .../react-commons/src/useKeyboardPress.ts | 58 ++++++++++ packages/ui/app/src/docs/Docs.tsx | 15 +-- packages/ui/app/src/search/SearchDialog.tsx | 6 +- packages/ui/app/src/search/SearchHit.tsx | 33 ++++-- packages/ui/app/src/search/SearchHits.tsx | 101 +++++++++++++++++- 6 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 packages/commons/react/react-commons/src/useKeyboardPress.ts diff --git a/packages/commons/react/react-commons/src/index.ts b/packages/commons/react/react-commons/src/index.ts index 0a5e00bda8..728453312c 100644 --- a/packages/commons/react/react-commons/src/index.ts +++ b/packages/commons/react/react-commons/src/index.ts @@ -9,6 +9,7 @@ export { useInterval } from "./useInterval"; export { useIsDirectlyHovering } from "./useIsDirectlyHovering"; export { useIsHovering } from "./useIsHovering"; export { useKeyboardCommand } from "./useKeyboardCommand"; +export { useKeyboardPress } from "./useKeyboardPress"; export { useLocalTextState, type LocalTextState } from "./useLocalTextState"; export { useNumericState } from "./useNumericState"; export { useTimeout } from "./useTimeout"; diff --git a/packages/commons/react/react-commons/src/useKeyboardPress.ts b/packages/commons/react/react-commons/src/useKeyboardPress.ts new file mode 100644 index 0000000000..49dc81a9eb --- /dev/null +++ b/packages/commons/react/react-commons/src/useKeyboardPress.ts @@ -0,0 +1,58 @@ +import { type Digit, type UppercaseLetter } from "@fern-ui/core-utils"; +import { useEffect } from "react"; + +type Arrow = "Up" | "Down" | "Right" | "Left"; + +type OtherKey = "Enter"; + +export declare namespace useKeyboardPress { + export interface Args { + key: UppercaseLetter | Digit | Arrow | OtherKey; + onPress: () => void | Promise; + preventDefault?: boolean; + } + + export type Return = void; +} + +function isUppercaseLetter(key: unknown): key is UppercaseLetter { + return typeof key === "string" && key.length === 1 && key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90; +} + +function isArrow(key: unknown): key is Arrow { + return typeof key === "string" && ["Up", "Down", "Right", "Left"].includes(key); +} + +function isDigit(key: unknown): key is Digit { + return typeof key === "number" && Number.isInteger(key) && key >= 0 && key <= 9; +} + +export function useKeyboardPress(args: useKeyboardPress.Args): void { + const { key, onPress, preventDefault = false } = args; + + useEffect(() => { + async function handleSaveKeyPress(e: KeyboardEvent) { + const doKeysMatch = + e.code === + (isUppercaseLetter(key) + ? `Key${key}` + : isArrow(key) + ? `Arrow${key}` + : isDigit(key) + ? `Digit${key}` + : key); + if (doKeysMatch) { + if (preventDefault) { + e.preventDefault(); + } + await onPress(); + } + } + + document.addEventListener("keydown", handleSaveKeyPress, false); + + return () => { + document.removeEventListener("keydown", handleSaveKeyPress, false); + }; + }, [key, onPress, preventDefault]); +} diff --git a/packages/ui/app/src/docs/Docs.tsx b/packages/ui/app/src/docs/Docs.tsx index 4c019a15ba..14f0ddbd82 100644 --- a/packages/ui/app/src/docs/Docs.tsx +++ b/packages/ui/app/src/docs/Docs.tsx @@ -42,14 +42,15 @@ export const Docs: React.FC = memo(function UnmemoizedDocs() { backgroundType={backgroundType} hasSpecifiedBackgroundImage={hasSpecifiedBackgroundImage} /> + {searchService.isAvailable && ( + + )}
- {searchService.isAvailable && ( - - )}
void; activeVersion?: string; + searchService: SearchService; } } export const SearchDialog: React.FC = (providedProps) => { - const { isOpen, onClose, activeVersion } = providedProps; - const searchService = useSearchService(); + const { isOpen, onClose, activeVersion, searchService } = providedProps; const [credentials, setSearchCredentials] = useState(undefined); useEffect(() => { diff --git a/packages/ui/app/src/search/SearchHit.tsx b/packages/ui/app/src/search/SearchHit.tsx index 8e076a13ce..8080774d9b 100644 --- a/packages/ui/app/src/search/SearchHit.tsx +++ b/packages/ui/app/src/search/SearchHit.tsx @@ -1,5 +1,6 @@ import { Icon } from "@blueprintjs/core"; import { visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import classNames from "classnames"; import Link from "next/link"; import { useCallback } from "react"; import { Snippet } from "react-instantsearch-hooks-web"; @@ -9,11 +10,15 @@ import type { SearchRecord } from "./types"; export declare namespace SearchHit { export interface Props { + setRef?: (elem: HTMLAnchorElement | null) => void; hit: SearchRecord; + isHovered: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; } } -export const SearchHit: React.FC = ({ hit }) => { +export const SearchHit: React.FC = ({ setRef, hit, isHovered, onMouseEnter, onMouseLeave }) => { const { navigateToPath } = useDocsContext(); const { closeSearchDialog } = useSearchContext(); @@ -27,13 +32,21 @@ export const SearchHit: React.FC = ({ hit }) => { return ( setRef?.(elem)} + className={classNames("flex w-full items-center space-x-4 space-y-1 rounded-md px-3 py-2 !no-underline", { + "bg-background-secondary-light dark:bg-background-secondary-dark": isHovered, + })} onClick={handleClick} href={href} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} >
"code", @@ -46,12 +59,17 @@ export const SearchHit: React.FC = ({ hit }) => {
-
+
{visitDiscriminatedUnion(hit, "type")._visit({ page: () => "Page", endpoint: () => "Endpoint", @@ -61,7 +79,10 @@ export const SearchHit: React.FC = ({ hit }) => {
= ({ children }) => { }; export const SearchHits: React.FC = () => { + const { closeSearchDialog } = useSearchContext(); const { hits } = useInfiniteHits(); const search = useInstantSearch(); + const containerRef = useRef(null); + const [hoveredSearchHitId, setHoveredSearchHitId] = useState(null); + const router = useRouter(); + + const refs = useRef(new Map()); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + const handleMouseMove = () => { + document.exitPointerLock(); + }; + document.addEventListener("mousemove", handleMouseMove); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + }; + }, []); + + const hoveredSearchHit = useMemo(() => { + return hits + .map((hit, index) => ({ record: hit, index })) + .find(({ record }) => record.objectID === hoveredSearchHitId); + }, [hits, hoveredSearchHitId]); + + useEffect(() => { + const [firstHit] = hits; + if (hoveredSearchHit == null && firstHit != null) { + setHoveredSearchHitId(firstHit.objectID); + } + }, [hits, hoveredSearchHit]); + + useKeyboardPress({ + key: "Up", + onPress: () => { + if (hoveredSearchHit == null) { + return; + } + const previousHit = hits[hoveredSearchHit.index - 1]; + if (previousHit != null) { + setHoveredSearchHitId(previousHit.objectID); + const ref = refs.current.get(previousHit.objectID); + ref?.requestPointerLock(); + ref?.focus(); + } + }, + }); + + useKeyboardPress({ + key: "Down", + onPress: () => { + if (hoveredSearchHit == null) { + return; + } + const nextHit = hits[hoveredSearchHit.index + 1]; + if (nextHit != null) { + setHoveredSearchHitId(nextHit.objectID); + const ref = refs.current.get(nextHit.objectID); + ref?.requestPointerLock(); + ref?.focus(); + } + }, + }); + + useKeyboardPress({ + key: "Enter", + onPress: async () => { + if (hoveredSearchHit == null) { + return; + } + const { versionSlug, path } = hoveredSearchHit.record; + const href = `/${versionSlug != null ? `${versionSlug}/` : ""}${path}`; + void router.push(href); + closeSearchDialog(); + }, + preventDefault: true, + }); const progress = useMemo((): Progress => { switch (search.status) { @@ -34,6 +115,7 @@ export const SearchHits: React.FC = () => { return (
0, @@ -49,7 +131,22 @@ export const SearchHits: React.FC = () => { pending: () => , error: () => "An unexpected error has occurred while loading the results.", success: () => - hits.length > 0 ? hits.map((hit) => ) : "No results", + hits.length > 0 + ? hits.map((hit) => ( + { + if (elem != null) { + refs.current.set(hit.objectID, elem); + } + }} + key={hit.objectID} + hit={hit} + isHovered={hoveredSearchHitId === hit.objectID} + onMouseEnter={() => setHoveredSearchHitId(hit.objectID)} + onMouseLeave={() => setHoveredSearchHitId(null)} + /> + )) + : "No results", _other: () => null, })}