Skip to content

Commit

Permalink
Add support for keyboard navigation in search dialog (#149)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kafkas authored Sep 25, 2023
1 parent 0d3652a commit eecfd5e
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 18 deletions.
1 change: 1 addition & 0 deletions packages/commons/react/react-commons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
58 changes: 58 additions & 0 deletions packages/commons/react/react-commons/src/useKeyboardPress.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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]);
}
15 changes: 8 additions & 7 deletions packages/ui/app/src/docs/Docs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,15 @@ export const Docs: React.FC = memo(function UnmemoizedDocs() {
backgroundType={backgroundType}
hasSpecifiedBackgroundImage={hasSpecifiedBackgroundImage}
/>
{searchService.isAvailable && (
<SearchDialog
isOpen={isSearchDialogOpen}
onClose={closeSearchDialog}
activeVersion={docsInfo.type === "versioned" ? docsInfo.activeVersionName : undefined}
searchService={searchService}
/>
)}
<div className="relative flex min-h-0 flex-1 flex-col">
{searchService.isAvailable && (
<SearchDialog
isOpen={isSearchDialogOpen}
onClose={closeSearchDialog}
activeVersion={docsInfo.type === "versioned" ? docsInfo.activeVersionName : undefined}
/>
)}
<div className="border-border-concealed-light dark:border-border-concealed-dark bg-background/50 dark:shadow-header sticky inset-x-0 top-0 z-20 h-16 border-b backdrop-blur-xl">
<Header
className="max-w-8xl mx-auto"
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/app/src/search/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import algolia from "algoliasearch/lite";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { InstantSearch, SearchBox } from "react-instantsearch-hooks-web";
import { useSearchService, type SearchCredentials } from "../services/useSearchService";
import { type SearchCredentials, type SearchService } from "../services/useSearchService";
import styles from "./SearchDialog.module.scss";
import { SearchHits } from "./SearchHits";

Expand All @@ -13,12 +13,12 @@ export declare namespace SearchDialog {
isOpen: boolean;
onClose: () => void;
activeVersion?: string;
searchService: SearchService;
}
}

export const SearchDialog: React.FC<SearchDialog.Props> = (providedProps) => {
const { isOpen, onClose, activeVersion } = providedProps;
const searchService = useSearchService();
const { isOpen, onClose, activeVersion, searchService } = providedProps;
const [credentials, setSearchCredentials] = useState<SearchCredentials | undefined>(undefined);

useEffect(() => {
Expand Down
33 changes: 27 additions & 6 deletions packages/ui/app/src/search/SearchHit.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<SearchHit.Props> = ({ hit }) => {
export const SearchHit: React.FC<SearchHit.Props> = ({ setRef, hit, isHovered, onMouseEnter, onMouseLeave }) => {
const { navigateToPath } = useDocsContext();
const { closeSearchDialog } = useSearchContext();

Expand All @@ -27,13 +32,21 @@ export const SearchHit: React.FC<SearchHit.Props> = ({ hit }) => {

return (
<Link
className="hover:bg-background-secondary-light hover:dark:bg-background-secondary-dark group flex w-full items-center space-x-4 space-y-1 rounded-md px-3 py-2 hover:no-underline"
ref={(elem) => 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}
>
<div className="border-border-default-light dark:border-border-default-dark flex flex-col items-center justify-center rounded-md border p-1">
<Icon
className="!text-text-muted-light dark:!text-text-muted-dark group-hover:!text-text-primary-light group-hover:dark:!text-text-primary-dark"
className={classNames({
"!text-text-muted-light dark:!text-text-muted-dark": !isHovered,
"!text-text-primary-light dark:!text-text-primary-dark": isHovered,
})}
size={14}
icon={visitDiscriminatedUnion(hit, "type")._visit({
endpoint: () => "code",
Expand All @@ -46,12 +59,17 @@ export const SearchHit: React.FC<SearchHit.Props> = ({ hit }) => {
<div className="flex w-full flex-col space-y-1.5">
<div className="flex justify-between">
<Snippet
className="text-text-primary-light dark:text-text-primary-dark group-hover:text-text-primary-light group-hover:dark:text-text-primary-dark line-clamp-1 text-start"
className="text-text-primary-light dark:text-text-primary-dark line-clamp-1 text-start"
attribute="title"
highlightedTagName="span"
hit={hit}
/>
<div className="group-hover:text-text-primary-light group-hover:dark:text-text-primary-dark text-text-disabled-light dark:text-text-disabled-dark text-xs uppercase tracking-widest">
<div
className={classNames("text-xs uppercase tracking-widest", {
"text-text-disabled-light dark:text-text-disabled-dark": !isHovered,
"text-text-primary-light dark:text-text-primary-dark": isHovered,
})}
>
{visitDiscriminatedUnion(hit, "type")._visit({
page: () => "Page",
endpoint: () => "Endpoint",
Expand All @@ -61,7 +79,10 @@ export const SearchHit: React.FC<SearchHit.Props> = ({ hit }) => {
</div>
<div className="flex flex-col items-start">
<Snippet
className="t-muted group-hover:text-text-primary-light group-hover:dark:text-text-primary-dark line-clamp-1 text-start"
className={classNames("line-clamp-1 text-start", {
"text-text-primary-light dark:text-text-primary-dark": isHovered,
"t-muted": !isHovered,
})}
attribute="subtitle"
highlightedTagName="span"
hit={hit}
Expand Down
101 changes: 99 additions & 2 deletions packages/ui/app/src/search/SearchHits.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Spinner, SpinnerSize } from "@blueprintjs/core";
import { visitDiscriminatedUnion } from "@fern-ui/core-utils";
import { useKeyboardPress } from "@fern-ui/react-commons";
import classNames from "classnames";
import React, { PropsWithChildren, useMemo } from "react";
import { useRouter } from "next/router";
import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from "react";
import { useInfiniteHits, useInstantSearch } from "react-instantsearch-hooks-web";
import { useSearchContext } from "../search-context/useSearchContext";
import { SearchHit } from "./SearchHit";
import type { SearchRecord } from "./types";

Expand All @@ -13,8 +16,86 @@ export const EmptyStateView: React.FC<PropsWithChildren> = ({ children }) => {
};

export const SearchHits: React.FC = () => {
const { closeSearchDialog } = useSearchContext();
const { hits } = useInfiniteHits<SearchRecord>();
const search = useInstantSearch();
const containerRef = useRef<HTMLDivElement | null>(null);
const [hoveredSearchHitId, setHoveredSearchHitId] = useState<string | null>(null);
const router = useRouter();

const refs = useRef(new Map<string, HTMLAnchorElement>());

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) {
Expand All @@ -34,6 +115,7 @@ export const SearchHits: React.FC = () => {

return (
<div
ref={containerRef}
className={classNames("max-h-80 overflow-y-auto p-2", {
"border-border-default-light/10 dark:border-border-default-dark/10 border-t":
(progress === "success" || progress === "pending") && hits.length > 0,
Expand All @@ -49,7 +131,22 @@ export const SearchHits: React.FC = () => {
pending: () => <Spinner size={SpinnerSize.SMALL} />,
error: () => "An unexpected error has occurred while loading the results.",
success: () =>
hits.length > 0 ? hits.map((hit) => <SearchHit key={hit.objectID} hit={hit} />) : "No results",
hits.length > 0
? hits.map((hit) => (
<SearchHit
setRef={(elem) => {
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,
})}
</div>
Expand Down

1 comment on commit eecfd5e

@vercel
Copy link

@vercel vercel bot commented on eecfd5e Sep 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

fern-dev – ./packages/ui/fe-bundle

fern-dev-git-main-buildwithfern.vercel.app
app-dev.buildwithfern.com
fern-dev-buildwithfern.vercel.app
devtest.buildwithfern.com

Please sign in to comment.