Skip to content

Commit 6581249

Browse files
authored
feat: Improve keyboard interactions in searchbox (#6537)
* 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
1 parent 8c03467 commit 6581249

File tree

8 files changed

+148
-91
lines changed

8 files changed

+148
-91
lines changed

Diff for: components/Common/Search/States/WithEmptyState.tsx

-14
This file was deleted.

Diff for: components/Common/Search/States/WithSearchBox.tsx

+87-30
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,22 @@ import {
55
ChevronLeftIcon,
66
} from '@heroicons/react/24/outline';
77
import type { Results, Nullable } from '@orama/orama';
8-
import classNames from 'classnames';
98
import { useState, useRef, useEffect } from 'react';
109
import type { FC } from 'react';
1110

1211
import styles from '@/components/Common/Search/States/index.module.css';
1312
import { WithAllResults } from '@/components/Common/Search/States/WithAllResults';
14-
import { WithEmptyState } from '@/components/Common/Search/States/WithEmptyState';
1513
import { WithError } from '@/components/Common/Search/States/WithError';
1614
import { WithNoResults } from '@/components/Common/Search/States/WithNoResults';
1715
import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy';
1816
import { WithSearchResult } from '@/components/Common/Search/States/WithSearchResult';
19-
import { useClickOutside } from '@/hooks/react-client';
17+
import Tabs from '@/components/Common/Tabs';
18+
import { useClickOutside, useKeyboardCommands } from '@/hooks/react-client';
2019
import { useRouter } from '@/navigation.mjs';
2120
import { DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs';
2221
import { search as oramaSearch, getInitialFacets } from '@/next.orama.mjs';
2322
import type { SearchDoc } from '@/types';
23+
import { searchHitToLinkPath } from '@/util/searchUtils';
2424

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

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

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

59-
useClickOutside(searchBoxRef, () => {
60+
const reset = () => {
61+
setSearchTerm('');
62+
setSearchResults(null);
63+
setSelectedResult(undefined);
64+
setSelectedFacet(0);
65+
};
66+
67+
const handleClose = () => {
6068
reset();
6169
onClose();
62-
});
70+
};
71+
72+
useClickOutside(searchBoxRef, handleClose);
6373

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

81-
const reset = () => {
82-
setSearchTerm('');
83-
setSearchResults(null);
84-
setSelectedFacet(0);
91+
useKeyboardCommands(cmd => {
92+
if (searchError || !searchResults || searchResults.count <= 0) {
93+
return;
94+
}
95+
96+
switch (true) {
97+
case cmd === 'down' && selectedResult === undefined:
98+
setSelectedResult(0);
99+
break;
100+
case cmd === 'down' &&
101+
selectedResult != undefined &&
102+
selectedResult < searchResults.count &&
103+
selectedResult < DEFAULT_ORAMA_QUERY_PARAMS.limit - 1:
104+
setSelectedResult(selectedResult + 1);
105+
break;
106+
case cmd === 'up' && selectedResult != undefined && selectedResult != 0:
107+
setSelectedResult(selectedResult - 1);
108+
break;
109+
case cmd === 'enter':
110+
handleEnter();
111+
break;
112+
default:
113+
}
114+
});
115+
116+
const handleEnter = () => {
117+
if (!searchResults || !selectedResult) {
118+
return;
119+
}
120+
121+
const selectedHit = searchResults.hits[selectedResult];
122+
123+
if (!selectedHit) {
124+
return;
125+
}
126+
127+
handleClose();
128+
router.push(searchHitToLinkPath(selectedHit));
85129
};
86130

87131
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
88132
e.preventDefault();
133+
134+
handleClose();
89135
router.push(`/search?q=${searchTerm}&section=${selectedFacetName}`);
90-
onClose();
91136
};
92137

93-
const changeFacet = (idx: number) => setSelectedFacet(idx);
138+
const changeFacet = (idx: string) => setSelectedFacet(Number(idx));
94139

95140
const filterBySection = () => {
96141
if (selectedFacet === 0) {
@@ -131,6 +176,16 @@ export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
131176
<form onSubmit={onSubmit}>
132177
<input
133178
ref={searchInputRef}
179+
aria-activedescendant={
180+
selectedResult !== undefined
181+
? `search-hit-${selectedResult}`
182+
: undefined
183+
}
184+
aria-autocomplete="list"
185+
aria-controls="fulltext-results-container"
186+
aria-expanded={Boolean(!searchError && searchResults?.count)}
187+
autoComplete="off"
188+
role="combobox"
134189
type="search"
135190
className={styles.searchBoxInput}
136191
onChange={event => setSearchTerm(event.target.value)}
@@ -140,36 +195,38 @@ export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
140195
</div>
141196

142197
<div className={styles.fulltextSearchSections}>
143-
{Object.keys(facets).map((facetName, idx) => (
144-
<button
145-
key={facetName}
146-
className={classNames(styles.fulltextSearchSection, {
147-
[styles.fulltextSearchSectionSelected]: selectedFacet === idx,
148-
})}
149-
onClick={() => changeFacet(idx)}
150-
>
151-
{facetName}
152-
<span className={styles.fulltextSearchSectionCount}>
153-
({facets[facetName].toLocaleString('en')})
154-
</span>
155-
</button>
156-
))}
198+
<Tabs
199+
activationMode="manual"
200+
defaultValue="0"
201+
autoFocus={true}
202+
tabs={Object.keys(facets).map((facetName, idx) => ({
203+
key: facetName,
204+
label: facetName,
205+
secondaryLabel: `(${facets[facetName].toLocaleString('en')})`,
206+
value: idx.toString(),
207+
}))}
208+
onValueChange={changeFacet}
209+
/>
157210
</div>
158211

159-
<div className={styles.fulltextResultsContainer}>
212+
<div
213+
id="fulltext-results-container"
214+
className={styles.fulltextResultsContainer}
215+
role="listbox"
216+
>
160217
{searchError && <WithError />}
161218

162-
{!searchError && !searchTerm && <WithEmptyState />}
163-
164-
{!searchError && searchTerm && (
219+
{!searchError && (
165220
<>
166221
{searchResults &&
167222
searchResults.count > 0 &&
168-
searchResults.hits.map(hit => (
223+
searchResults.hits.map((hit, idx) => (
169224
<WithSearchResult
170225
key={hit.id}
171226
hit={hit}
172227
searchTerm={searchTerm}
228+
selected={selectedResult === idx}
229+
idx={idx}
173230
/>
174231
))}
175232

Diff for: components/Common/Search/States/WithSearchResult.tsx

+16-5
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,43 @@
11
import type { Result } from '@orama/orama';
2-
import type { FC } from 'react';
2+
import { useEffect, type FC, useRef } from 'react';
33

44
import { pathToBreadcrumbs } from '@/components/Common/Search/utils';
55
import Link from '@/components/Link';
6-
import { BASE_URL } from '@/next.constants.mjs';
76
import { highlighter } from '@/next.orama.mjs';
87
import type { SearchDoc } from '@/types';
8+
import { searchHitToLinkPath } from '@/util/searchUtils';
99

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

1212
type SearchResultProps = {
1313
hit: Result<SearchDoc>;
1414
searchTerm: string;
15+
selected: boolean;
16+
idx: number;
1517
};
1618

1719
export const WithSearchResult: FC<SearchResultProps> = props => {
18-
const isAPIResult = props.hit.document.siteSection.toLowerCase() === 'docs';
19-
const basePath = isAPIResult ? BASE_URL : '';
20-
const path = `${basePath}/${props.hit.document.path}`;
20+
const divRef = useRef<HTMLDivElement>(null);
21+
const path = searchHitToLinkPath(props.hit);
22+
23+
useEffect(() => {
24+
if (props.selected && divRef.current) {
25+
divRef.current.scrollIntoView({ block: 'center' });
26+
}
27+
}, [props.selected]);
2128

2229
return (
2330
<Link
31+
id={`search-hit-${props.idx}`}
2432
key={props.hit.id}
2533
href={path}
2634
className={styles.fulltextSearchResult}
35+
role="option"
36+
aria-selected={props.selected}
2737
>
2838
<div
2939
className={styles.fulltextSearchResultTitle}
40+
ref={divRef}
3041
dangerouslySetInnerHTML={{
3142
__html: highlighter
3243
.highlight(props.hit.document.pageSectionTitle, props.searchTerm)

Diff for: components/Common/Search/States/index.module.css

+7-37
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,13 @@
8484
rounded-md
8585
p-2
8686
text-left
87-
text-sm
88-
hover:bg-neutral-300
89-
dark:hover:bg-neutral-900;
87+
text-sm;
88+
89+
&[aria-selected='true'],
90+
&:hover {
91+
@apply bg-neutral-300
92+
dark:bg-neutral-900;
93+
}
9094
}
9195

9296
.fulltextSearchResultTitle {
@@ -105,44 +109,10 @@
105109
.fulltextSearchSections {
106110
@apply mb-1
107111
mt-2
108-
flex
109-
gap-2
110-
overflow-x-auto
111112
p-2
112-
text-xs
113-
font-semibold
114-
text-neutral-700
115-
dark:text-neutral-600
116113
md:px-4;
117114
}
118115

119-
.fulltextSearchSection {
120-
@apply rounded-lg
121-
border-b
122-
border-transparent
123-
px-2
124-
py-1
125-
capitalize
126-
hover:bg-neutral-200
127-
dark:border-neutral-900
128-
dark:border-b-transparent
129-
dark:hover:bg-neutral-900;
130-
}
131-
132-
.fulltextSearchSectionSelected {
133-
@apply rounded-b-none
134-
border-neutral-700
135-
text-neutral-900
136-
dark:border-neutral-700
137-
dark:text-neutral-300;
138-
}
139-
140-
.fulltextSearchSectionCount {
141-
@apply ml-1
142-
text-neutral-500
143-
dark:text-neutral-800;
144-
}
145-
146116
.seeAllFulltextSearchResults {
147117
@apply m-auto
148118
mb-2

Diff for: components/Common/Tabs/index.module.css

+11
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,22 @@
1818
text-neutral-800
1919
dark:text-neutral-200;
2020

21+
.tabSecondaryLabel {
22+
@apply pl-1
23+
text-neutral-500
24+
dark:text-neutral-800;
25+
}
26+
2127
&[data-state='active'] {
2228
@apply border-b-green-600
2329
text-green-600
2430
dark:border-b-green-400
2531
dark:text-green-400;
32+
33+
.tabSecondaryLabel {
34+
@apply text-green-800
35+
dark:text-green-600;
36+
}
2637
}
2738
}
2839

Diff for: components/Common/Tabs/index.tsx

+12-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import type { FC, PropsWithChildren, ReactNode } from 'react';
44

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

7-
type Tab = { key: string; label: string };
7+
type Tab = {
8+
key: string;
9+
label: string;
10+
secondaryLabel?: string;
11+
value?: string;
12+
};
813

914
type TabsProps = TabsPrimitive.TabsProps & {
1015
tabs: Array<Tab>;
@@ -27,10 +32,15 @@ const Tabs: FC<PropsWithChildren<TabsProps>> = ({
2732
{tabs.map(tab => (
2833
<TabsPrimitive.Trigger
2934
key={tab.key}
30-
value={tab.key}
35+
value={tab.value ?? tab.key}
3136
className={classNames(styles.tabsTrigger, triggerClassName)}
3237
>
3338
{tab.label}
39+
{tab.secondaryLabel ? (
40+
<span className={styles.tabSecondaryLabel}>
41+
{tab.secondaryLabel}
42+
</span>
43+
) : null}
3444
</TabsPrimitive.Trigger>
3545
))}
3646

Diff for: hooks/react-client/useKeyboardCommands.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type KeyboardCommandCallback = (key: KeyboardCommand) => void;
66

77
const useKeyboardCommands = (fn: KeyboardCommandCallback) => {
88
useEffect(() => {
9-
document.addEventListener('keydown', event => {
9+
const handleKeyDown = (event: KeyboardEvent) => {
1010
// Detect ⌘ + k on Mac, Ctrl + k on Windows
1111
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
1212
event.preventDefault();
@@ -27,9 +27,11 @@ const useKeyboardCommands = (fn: KeyboardCommandCallback) => {
2727
fn('up');
2828
break;
2929
}
30-
});
30+
};
3131

32-
return () => document.removeEventListener('keydown', () => {});
32+
document.addEventListener('keydown', handleKeyDown);
33+
34+
return () => document.removeEventListener('keydown', handleKeyDown);
3335
}, [fn]);
3436
};
3537

0 commit comments

Comments
 (0)