Skip to content

Commit

Permalink
feat: Improve keyboard interactions in searchbox (#6537)
Browse files Browse the repository at this point in the history
* feat(search): Add keyboard interactions to searchbox and fix useKeyboardCommands removeEventListener

* feat(search): Move searchHitToLinkPath and remove console logs

* feat(search): Bound selection of search results and add scroll into view while selecting

* feat(search): Add accessibility tags to searchbox

* feat(search): Simplify searchbox keyboard commands logic

* feat(search): Add brackets on every if statement

* feat(search): Rename searchUtils file

* feat(search): Use arrow function on eventHandler

* feat(search): Remove unnecessary null check

* feat(search): Remove useless template string

* feat(search): Reset searchbox onClose

* feat(search): Shrink selectedResult state type

* feat(search): Change selectedResult type to number|undefined

* feat(search): Run handleClose before router.push

* feat(search): Improve boolean logic and remove useless checks

* feat(search): Remove useless early return from onSubmit

* feat(search): Use tabs component in searchbox

* feat(search): Improve readability

* feat(search): Add secondary label to tabs

* feat(search): Remove search empty state
  • Loading branch information
Baccega authored Apr 11, 2024
1 parent 8c03467 commit 6581249
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 91 deletions.
14 changes: 0 additions & 14 deletions components/Common/Search/States/WithEmptyState.tsx

This file was deleted.

117 changes: 87 additions & 30 deletions components/Common/Search/States/WithSearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ import {
ChevronLeftIcon,
} from '@heroicons/react/24/outline';
import type { Results, Nullable } from '@orama/orama';
import classNames from 'classnames';
import { useState, useRef, useEffect } from 'react';
import type { FC } from 'react';

import styles from '@/components/Common/Search/States/index.module.css';
import { WithAllResults } from '@/components/Common/Search/States/WithAllResults';
import { WithEmptyState } from '@/components/Common/Search/States/WithEmptyState';
import { WithError } from '@/components/Common/Search/States/WithError';
import { WithNoResults } from '@/components/Common/Search/States/WithNoResults';
import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy';
import { WithSearchResult } from '@/components/Common/Search/States/WithSearchResult';
import { useClickOutside } from '@/hooks/react-client';
import Tabs from '@/components/Common/Tabs';
import { useClickOutside, useKeyboardCommands } from '@/hooks/react-client';
import { useRouter } from '@/navigation.mjs';
import { DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs';
import { search as oramaSearch, getInitialFacets } from '@/next.orama.mjs';
import type { SearchDoc } from '@/types';
import { searchHitToLinkPath } from '@/util/searchUtils';

type Facets = { [key: string]: number };

Expand All @@ -31,6 +31,7 @@ type SearchBoxProps = { onClose: () => void };
export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<SearchResults>(null);
const [selectedResult, setSelectedResult] = useState<number>();
const [selectedFacet, setSelectedFacet] = useState<number>(0);
const [searchError, setSearchError] = useState<Nullable<Error>>(null);

Expand All @@ -56,10 +57,19 @@ export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
.catch(setSearchError);
};

useClickOutside(searchBoxRef, () => {
const reset = () => {
setSearchTerm('');
setSearchResults(null);
setSelectedResult(undefined);
setSelectedFacet(0);
};

const handleClose = () => {
reset();
onClose();
});
};

useClickOutside(searchBoxRef, handleClose);

useEffect(() => {
searchInputRef.current?.focus();
Expand All @@ -78,19 +88,54 @@ export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
[searchTerm, selectedFacet]
);

const reset = () => {
setSearchTerm('');
setSearchResults(null);
setSelectedFacet(0);
useKeyboardCommands(cmd => {
if (searchError || !searchResults || searchResults.count <= 0) {
return;
}

switch (true) {
case cmd === 'down' && selectedResult === undefined:
setSelectedResult(0);
break;
case cmd === 'down' &&
selectedResult != undefined &&
selectedResult < searchResults.count &&
selectedResult < DEFAULT_ORAMA_QUERY_PARAMS.limit - 1:
setSelectedResult(selectedResult + 1);
break;
case cmd === 'up' && selectedResult != undefined && selectedResult != 0:
setSelectedResult(selectedResult - 1);
break;
case cmd === 'enter':
handleEnter();
break;
default:
}
});

const handleEnter = () => {
if (!searchResults || !selectedResult) {
return;
}

const selectedHit = searchResults.hits[selectedResult];

if (!selectedHit) {
return;
}

handleClose();
router.push(searchHitToLinkPath(selectedHit));
};

const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

handleClose();
router.push(`/search?q=${searchTerm}&section=${selectedFacetName}`);
onClose();
};

const changeFacet = (idx: number) => setSelectedFacet(idx);
const changeFacet = (idx: string) => setSelectedFacet(Number(idx));

const filterBySection = () => {
if (selectedFacet === 0) {
Expand Down Expand Up @@ -131,6 +176,16 @@ export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
<form onSubmit={onSubmit}>
<input
ref={searchInputRef}
aria-activedescendant={
selectedResult !== undefined
? `search-hit-${selectedResult}`
: undefined
}
aria-autocomplete="list"
aria-controls="fulltext-results-container"
aria-expanded={Boolean(!searchError && searchResults?.count)}
autoComplete="off"
role="combobox"
type="search"
className={styles.searchBoxInput}
onChange={event => setSearchTerm(event.target.value)}
Expand All @@ -140,36 +195,38 @@ export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
</div>

<div className={styles.fulltextSearchSections}>
{Object.keys(facets).map((facetName, idx) => (
<button
key={facetName}
className={classNames(styles.fulltextSearchSection, {
[styles.fulltextSearchSectionSelected]: selectedFacet === idx,
})}
onClick={() => changeFacet(idx)}
>
{facetName}
<span className={styles.fulltextSearchSectionCount}>
({facets[facetName].toLocaleString('en')})
</span>
</button>
))}
<Tabs
activationMode="manual"
defaultValue="0"
autoFocus={true}
tabs={Object.keys(facets).map((facetName, idx) => ({
key: facetName,
label: facetName,
secondaryLabel: `(${facets[facetName].toLocaleString('en')})`,
value: idx.toString(),
}))}
onValueChange={changeFacet}
/>
</div>

<div className={styles.fulltextResultsContainer}>
<div
id="fulltext-results-container"
className={styles.fulltextResultsContainer}
role="listbox"
>
{searchError && <WithError />}

{!searchError && !searchTerm && <WithEmptyState />}

{!searchError && searchTerm && (
{!searchError && (
<>
{searchResults &&
searchResults.count > 0 &&
searchResults.hits.map(hit => (
searchResults.hits.map((hit, idx) => (
<WithSearchResult
key={hit.id}
hit={hit}
searchTerm={searchTerm}
selected={selectedResult === idx}
idx={idx}
/>
))}

Expand Down
21 changes: 16 additions & 5 deletions components/Common/Search/States/WithSearchResult.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,43 @@
import type { Result } from '@orama/orama';
import type { FC } from 'react';
import { useEffect, type FC, useRef } from 'react';

import { pathToBreadcrumbs } from '@/components/Common/Search/utils';
import Link from '@/components/Link';
import { BASE_URL } from '@/next.constants.mjs';
import { highlighter } from '@/next.orama.mjs';
import type { SearchDoc } from '@/types';
import { searchHitToLinkPath } from '@/util/searchUtils';

import styles from './index.module.css';

type SearchResultProps = {
hit: Result<SearchDoc>;
searchTerm: string;
selected: boolean;
idx: number;
};

export const WithSearchResult: FC<SearchResultProps> = props => {
const isAPIResult = props.hit.document.siteSection.toLowerCase() === 'docs';
const basePath = isAPIResult ? BASE_URL : '';
const path = `${basePath}/${props.hit.document.path}`;
const divRef = useRef<HTMLDivElement>(null);
const path = searchHitToLinkPath(props.hit);

useEffect(() => {
if (props.selected && divRef.current) {
divRef.current.scrollIntoView({ block: 'center' });
}
}, [props.selected]);

return (
<Link
id={`search-hit-${props.idx}`}
key={props.hit.id}
href={path}
className={styles.fulltextSearchResult}
role="option"
aria-selected={props.selected}
>
<div
className={styles.fulltextSearchResultTitle}
ref={divRef}
dangerouslySetInnerHTML={{
__html: highlighter
.highlight(props.hit.document.pageSectionTitle, props.searchTerm)
Expand Down
44 changes: 7 additions & 37 deletions components/Common/Search/States/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@
rounded-md
p-2
text-left
text-sm
hover:bg-neutral-300
dark:hover:bg-neutral-900;
text-sm;

&[aria-selected='true'],
&:hover {
@apply bg-neutral-300
dark:bg-neutral-900;
}
}

.fulltextSearchResultTitle {
Expand All @@ -105,44 +109,10 @@
.fulltextSearchSections {
@apply mb-1
mt-2
flex
gap-2
overflow-x-auto
p-2
text-xs
font-semibold
text-neutral-700
dark:text-neutral-600
md:px-4;
}

.fulltextSearchSection {
@apply rounded-lg
border-b
border-transparent
px-2
py-1
capitalize
hover:bg-neutral-200
dark:border-neutral-900
dark:border-b-transparent
dark:hover:bg-neutral-900;
}

.fulltextSearchSectionSelected {
@apply rounded-b-none
border-neutral-700
text-neutral-900
dark:border-neutral-700
dark:text-neutral-300;
}

.fulltextSearchSectionCount {
@apply ml-1
text-neutral-500
dark:text-neutral-800;
}

.seeAllFulltextSearchResults {
@apply m-auto
mb-2
Expand Down
11 changes: 11 additions & 0 deletions components/Common/Tabs/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,22 @@
text-neutral-800
dark:text-neutral-200;

.tabSecondaryLabel {
@apply pl-1
text-neutral-500
dark:text-neutral-800;
}

&[data-state='active'] {
@apply border-b-green-600
text-green-600
dark:border-b-green-400
dark:text-green-400;

.tabSecondaryLabel {
@apply text-green-800
dark:text-green-600;
}
}
}

Expand Down
14 changes: 12 additions & 2 deletions components/Common/Tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import type { FC, PropsWithChildren, ReactNode } from 'react';

import styles from './index.module.css';

type Tab = { key: string; label: string };
type Tab = {
key: string;
label: string;
secondaryLabel?: string;
value?: string;
};

type TabsProps = TabsPrimitive.TabsProps & {
tabs: Array<Tab>;
Expand All @@ -27,10 +32,15 @@ const Tabs: FC<PropsWithChildren<TabsProps>> = ({
{tabs.map(tab => (
<TabsPrimitive.Trigger
key={tab.key}
value={tab.key}
value={tab.value ?? tab.key}
className={classNames(styles.tabsTrigger, triggerClassName)}
>
{tab.label}
{tab.secondaryLabel ? (
<span className={styles.tabSecondaryLabel}>
{tab.secondaryLabel}
</span>
) : null}
</TabsPrimitive.Trigger>
))}

Expand Down
8 changes: 5 additions & 3 deletions hooks/react-client/useKeyboardCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type KeyboardCommandCallback = (key: KeyboardCommand) => void;

const useKeyboardCommands = (fn: KeyboardCommandCallback) => {
useEffect(() => {
document.addEventListener('keydown', event => {
const handleKeyDown = (event: KeyboardEvent) => {
// Detect ⌘ + k on Mac, Ctrl + k on Windows
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
Expand All @@ -27,9 +27,11 @@ const useKeyboardCommands = (fn: KeyboardCommandCallback) => {
fn('up');
break;
}
});
};

return () => document.removeEventListener('keydown', () => {});
document.addEventListener('keydown', handleKeyDown);

return () => document.removeEventListener('keydown', handleKeyDown);
}, [fn]);
};

Expand Down
Loading

0 comments on commit 6581249

Please sign in to comment.