-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add FilterSearch component #56
Changes from 8 commits
3645f12
f703365
b29683a
40061a7
6dc618b
bbd1111
13091cc
4e6e19b
151f5b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,12 +6,18 @@ export interface Option { | |
} | ||
|
||
interface Props { | ||
options: Option[], | ||
onClickOption?: (option: Option) => void, | ||
options: { | ||
results: Option[], | ||
label?: string | ||
}[], | ||
onClickOption?: (option: Option, sectionIndex: number, optionIndex: number) => void, | ||
focusedSectionIndex: number | undefined, | ||
focusedOptionIndex: number | undefined, | ||
optionIdPrefix: string, | ||
cssClasses: { | ||
optionContainer: string, | ||
optionSection: string, | ||
sectionLabel: string, | ||
option: string, | ||
focusedOption: string | ||
} | ||
|
@@ -23,22 +29,39 @@ interface Props { | |
export default function Dropdown({ | ||
options, | ||
onClickOption = () => {}, | ||
focusedSectionIndex, | ||
focusedOptionIndex, | ||
optionIdPrefix, | ||
cssClasses | ||
}: Props): JSX.Element | null { | ||
function renderOption(option: Option, index: number) { | ||
function renderSection(section: { results: Option[], label?: string }, sectionIndex: number) { | ||
return ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to enforce that each section must consist of an array of results? We could make it more generic by allowing an array of sections to be passed in which define their own render function similarly to how options can be passed in. I believe Tom mentioned a use case such as Visual Autocomplete where we'd want sections, but they wouldn't necessarily be laid out this way. Have you checked with Tom on the use cases here? |
||
<div | ||
className={cssClasses.optionSection} | ||
key={`section-${sectionIndex}`}> | ||
{section.label && | ||
<div className={cssClasses.sectionLabel}> | ||
{section.label} | ||
</div> | ||
} | ||
{section.results.map((option, optionIndex) => renderOption(option, sectionIndex, optionIndex))} | ||
</div> | ||
) | ||
} | ||
|
||
function renderOption(option: Option, sectionIndex: number, optionIndex: number) { | ||
const className = classNames(cssClasses.option, { | ||
[cssClasses.focusedOption]: index === focusedOptionIndex | ||
[cssClasses.focusedOption]: sectionIndex === focusedSectionIndex && optionIndex === focusedOptionIndex | ||
}) | ||
return ( | ||
<div | ||
key={index} | ||
key={`${sectionIndex}-${optionIndex}`} | ||
className={className} | ||
id={`${optionIdPrefix}-${index}`} | ||
onClick={() => onClickOption(option)}> | ||
id={`${optionIdPrefix}-${sectionIndex}-${optionIndex}`} | ||
onClick={() => onClickOption(option, sectionIndex, optionIndex)}> | ||
{option.render()} | ||
</div>) | ||
</div> | ||
) | ||
} | ||
|
||
if (options.length < 1) { | ||
|
@@ -47,7 +70,7 @@ export default function Dropdown({ | |
|
||
return ( | ||
<div className={cssClasses.optionContainer}> | ||
{options.map((option, index) => renderOption(option, index))} | ||
{options.map((section, sectionIndex) => renderSection(section, sectionIndex))} | ||
nmanu1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import { FilterSearchResponse } from "@yext/answers-headless"; | ||
import { SearchParameterField } from "@yext/answers-headless"; | ||
import { useRef, useState } from "react"; | ||
import { useAnswersActions } from '@yext/answers-headless-react'; | ||
import InputDropdown from "./InputDropdown"; | ||
import renderWithHighlighting from "./utils/renderWithHighlighting"; | ||
import { Option } from "./Dropdown"; | ||
|
||
const SCREENREADER_INSTRUCTIONS = 'When autocomplete results are available, use up and down arrows to review and enter to select.' | ||
|
||
export interface FilterSearchProps { | ||
title: string, | ||
sectioned: boolean, | ||
searchFields: Omit<SearchParameterField, 'fetchEntities'>[], | ||
screenReaderInstructionsId: string | ||
} | ||
|
||
export default function FilterSearch (props: FilterSearchProps): JSX.Element { | ||
const { title, sectioned, searchFields, screenReaderInstructionsId } = props; | ||
const answersActions = useAnswersActions(); | ||
const [input, setInput] = useState(''); | ||
const [results, updateResults] = useState<FilterSearchResponse|undefined>(); | ||
const requestId = useRef(0); | ||
const responseId = useRef(0); | ||
const searchParamFields = searchFields.map((searchField) => { | ||
return { ...searchField, fetchEntities: false } | ||
}); | ||
|
||
async function executeFilterSearch (inputValue: string) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. abstracting out this logic so that we don't duplicate it makes sense to me |
||
const currentId = ++requestId.current; | ||
const results = await answersActions.executeFilterSearch(inputValue, sectioned, searchParamFields); | ||
if (currentId >= responseId.current) { | ||
responseId.current++; | ||
updateResults(results); | ||
} | ||
} | ||
|
||
let options: { results: Option[], label?: string }[] = []; | ||
if (results) { | ||
results.sections.forEach(section => { | ||
oshi97 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let results: Option[] = []; | ||
section.results.forEach(result => { | ||
results.push({ | ||
value: result.value, | ||
render: () => renderWithHighlighting(result) | ||
}); | ||
}); | ||
options.push({ | ||
results: results, | ||
label: section.label | ||
}); | ||
}); | ||
} | ||
|
||
return ( | ||
<div className='FilterSearch'> | ||
<h1>{title}</h1> | ||
<InputDropdown | ||
inputValue={input} | ||
placeholder='this is filter search...' | ||
screenReaderInstructions={SCREENREADER_INSTRUCTIONS} | ||
screenReaderInstructionsId={screenReaderInstructionsId} | ||
options={options} | ||
optionIdPrefix='Autocomplete__option' | ||
onlySubmitOnOption={true} | ||
onSubmit={(optionValue, sectionIndex, optionIndex) => { | ||
if (sectionIndex !== undefined && optionIndex !== undefined && results) { | ||
const option = results.sections[sectionIndex].results[optionIndex]; | ||
if (option.filter) { | ||
answersActions.setFilterOption({ ...option.filter, selected: true }); | ||
answersActions.executeVerticalQuery(); | ||
} | ||
} | ||
}} | ||
updateInputValue={newInput => { | ||
setInput(newInput); | ||
}} | ||
updateDropdown={(input) => { | ||
executeFilterSearch(input); | ||
}} | ||
cssClasses={{ | ||
optionContainer: 'Autocomplete', | ||
nmanu1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
optionSection: 'Autocomplete__optionSection', | ||
sectionLabel: 'Autocomplete__sectionLabel', | ||
option: 'Autocomplete__option', | ||
focusedOption: 'Autocomplete__option--focused', | ||
inputElement: 'FilterSearch__input', | ||
inputContainer: 'FilterSearch__inputContainer' | ||
}} | ||
/> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,14 +8,17 @@ interface Props { | |
placeholder?: string, | ||
screenReaderInstructions: string, | ||
screenReaderInstructionsId: string, | ||
options: Option[], | ||
options: { results: Option[], label?: string }[], | ||
optionIdPrefix: string, | ||
onSubmit?: (value: string) => void, | ||
onlySubmitOnOption: boolean, | ||
onSubmit?: (value: string, sectionIndex?: number, optionIndex?: number) => void, | ||
updateInputValue: (value: string) => void, | ||
updateDropdown: () => void, | ||
updateDropdown: (input: string) => void, | ||
renderButtons?: () => JSX.Element | null, | ||
cssClasses: { | ||
optionContainer: string, | ||
optionSection: string, | ||
sectionLabel: string, | ||
option: string, | ||
focusedOption: string, | ||
inputElement: string, | ||
|
@@ -24,23 +27,24 @@ interface Props { | |
} | ||
|
||
interface State { | ||
focusedSectionIndex?: number, | ||
focusedOptionIndex?: number, | ||
shouldDisplayDropdown: boolean | ||
} | ||
|
||
type Action = | ||
| { type: 'HideOptions' } | ||
| { type: 'ShowOptions' } | ||
| { type: 'FocusOption', newIndex?: number } | ||
| { type: 'FocusOption', newSectionIndex?: number, newOptionIndex?: number } | ||
|
||
function reducer(state: State, action: Action): State { | ||
switch (action.type) { | ||
case 'HideOptions': | ||
return { focusedOptionIndex: undefined, shouldDisplayDropdown: false } | ||
return { focusedSectionIndex: undefined, focusedOptionIndex: undefined, shouldDisplayDropdown: false } | ||
case 'ShowOptions': | ||
return { focusedOptionIndex: undefined, shouldDisplayDropdown: true } | ||
return { focusedSectionIndex: undefined, focusedOptionIndex: undefined, shouldDisplayDropdown: true } | ||
case 'FocusOption': | ||
return { focusedOptionIndex: action.newIndex, shouldDisplayDropdown: true } | ||
return { focusedSectionIndex: action.newSectionIndex, focusedOptionIndex: action.newOptionIndex, shouldDisplayDropdown: true } | ||
} | ||
} | ||
|
||
|
@@ -54,22 +58,25 @@ export default function InputDropdown({ | |
screenReaderInstructionsId, | ||
options, | ||
optionIdPrefix, | ||
onlySubmitOnOption, | ||
onSubmit = () => {}, | ||
updateInputValue, | ||
updateDropdown, | ||
renderButtons = () => null, | ||
cssClasses | ||
}: Props): JSX.Element | null { | ||
const [{ | ||
focusedSectionIndex, | ||
focusedOptionIndex, | ||
shouldDisplayDropdown, | ||
}, dispatch] = useReducer(reducer, { | ||
focusedSectionIndex: undefined, | ||
focusedOptionIndex: undefined, | ||
shouldDisplayDropdown: false, | ||
}); | ||
const focusOptionId = focusedOptionIndex === undefined | ||
? undefined | ||
: `${optionIdPrefix}-${focusedOptionIndex}`; | ||
: `${optionIdPrefix}-${focusedSectionIndex}-${focusedOptionIndex}`; | ||
|
||
const [latestUserInput, setLatestUserInput] = useState(inputValue); | ||
const [screenReaderKey, setScreenReaderKey] = useState(0); | ||
|
@@ -80,9 +87,31 @@ export default function InputDropdown({ | |
setScreenReaderKey(0); | ||
} | ||
|
||
const screenReaderPhrases: string[] = []; | ||
if (options.length < 1) { | ||
const phrase = processTranslation({ | ||
phrase: `0 autocomplete option found.`, | ||
pluralForm: `0 autocomplete options found.`, | ||
count: 0 | ||
}); | ||
screenReaderPhrases.push(phrase); | ||
} else { | ||
options.forEach(section => { | ||
const optionInfo = section.label? `${section.results.length} ${section.label}` : `${section.results.length}`; | ||
const phrase = processTranslation({ | ||
phrase: `${optionInfo} autocomplete option found.`, | ||
pluralForm: `${optionInfo} autocomplete options found.`, | ||
count: section.results.length | ||
}); | ||
screenReaderPhrases.push(phrase); | ||
}); | ||
} | ||
const screenReaderText = screenReaderPhrases.join(' '); | ||
|
||
function handleDocumentClick(evt: MouseEvent) { | ||
const target = evt.target as HTMLElement; | ||
if (!target.isSameNode(inputRef.current)) { | ||
if (!(target.isSameNode(inputRef.current) || target.classList.contains(cssClasses.optionContainer) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cssClasses object may contain a list of classes, so classList.contains wouldn't work in that scenario. Can we check in some other way that doesn't rely on the classList? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The other way I thought of would involve useRef and forwardRef, but I think there would need to be a reference for each section label |
||
|| target.classList.contains(cssClasses.optionSection) || target.classList.contains(cssClasses.sectionLabel))) { | ||
dispatch({ type: 'HideOptions' }); | ||
} | ||
} | ||
|
@@ -97,24 +126,41 @@ export default function InputDropdown({ | |
evt.preventDefault(); | ||
} | ||
|
||
const isFirstSectionFocused = focusedSectionIndex === 0; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding sections support is adding a lot of complexity to this logic. If we support either an array of options or a sections object, then we could have separate functions where one handles the logic for simple options, and the other handles the logic for sections. |
||
const isLastSectionFocused = focusedSectionIndex === options.length - 1; | ||
const isFirstOptionFocused = focusedOptionIndex === 0; | ||
const isLastOptionFocused = focusedOptionIndex === options.length - 1; | ||
if (evt.key === 'Enter') { | ||
updateInputValue(inputValue); | ||
onSubmit(inputValue); | ||
const isLastOptionFocused = | ||
focusedSectionIndex !== undefined && | ||
focusedOptionIndex === options[focusedSectionIndex].results.length - 1; | ||
if (evt.key === 'Enter' && (!onlySubmitOnOption || focusedOptionIndex !== undefined)) { | ||
onSubmit(inputValue, focusedSectionIndex, focusedOptionIndex); | ||
dispatch({ type: 'HideOptions' }); | ||
} else if (evt.key === 'Escape' || evt.key === 'Tab') { | ||
dispatch({ type: 'HideOptions' }); | ||
} else if (evt.key === 'ArrowDown' && options.length > 0 && !isLastOptionFocused) { | ||
const newIndex = focusedOptionIndex !== undefined ? focusedOptionIndex + 1 : 0; | ||
dispatch({ type: 'FocusOption', newIndex }); | ||
const newValue = options[newIndex]?.value; | ||
} else if (evt.key === 'ArrowDown' && options.length > 0 && !(isLastSectionFocused && isLastOptionFocused)) { | ||
yen-tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let newSectionIndex, newOptionIndex; | ||
if (isLastOptionFocused) { | ||
newSectionIndex = focusedSectionIndex !== undefined ? focusedSectionIndex + 1 : 0; | ||
newOptionIndex = 0; | ||
} else { | ||
newSectionIndex = focusedSectionIndex !== undefined ? focusedSectionIndex : 0; | ||
newOptionIndex = focusedOptionIndex !== undefined ? focusedOptionIndex + 1 : 0; | ||
} | ||
dispatch({ type: 'FocusOption', newSectionIndex, newOptionIndex }); | ||
const newValue = options[newSectionIndex].results[newOptionIndex].value; | ||
updateInputValue(newValue); | ||
} else if (evt.key === 'ArrowUp' && focusedOptionIndex !== undefined) { | ||
const newIndex = isFirstOptionFocused ? undefined : focusedOptionIndex - 1; | ||
dispatch({ type: 'FocusOption', newIndex }); | ||
const newValue = newIndex !== undefined | ||
? options[newIndex]?.value | ||
} else if (evt.key === 'ArrowUp' && focusedSectionIndex !== undefined && focusedOptionIndex !== undefined) { | ||
let newSectionIndex, newOptionIndex; | ||
if (isFirstOptionFocused) { | ||
newSectionIndex = isFirstSectionFocused ? undefined : focusedSectionIndex - 1; | ||
newOptionIndex = newSectionIndex !== undefined ? options[newSectionIndex].results.length - 1 : undefined; | ||
} else { | ||
newSectionIndex = focusedSectionIndex; | ||
newOptionIndex = focusedOptionIndex - 1; | ||
} | ||
dispatch({ type: 'FocusOption', newSectionIndex, newOptionIndex }); | ||
const newValue = newSectionIndex !== undefined && newOptionIndex !== undefined | ||
? options[newSectionIndex].results[newOptionIndex].value | ||
: latestUserInput; | ||
updateInputValue(newValue); | ||
} | ||
|
@@ -131,13 +177,13 @@ export default function InputDropdown({ | |
dispatch({ type: 'ShowOptions' }); | ||
setLatestUserInput(value); | ||
updateInputValue(value); | ||
updateDropdown(); | ||
updateDropdown(value); | ||
setScreenReaderKey(screenReaderKey + 1); | ||
}} | ||
onClick={() => { | ||
updateDropdown(); | ||
updateDropdown(inputValue); | ||
dispatch({ type: 'ShowOptions' }); | ||
if (options.length || inputValue) { | ||
if (options[0]?.results.length || inputValue) { | ||
setScreenReaderKey(screenReaderKey + 1); | ||
} | ||
}} | ||
|
@@ -153,24 +199,18 @@ export default function InputDropdown({ | |
instructionsId={screenReaderInstructionsId} | ||
instructions={screenReaderInstructions} | ||
announcementKey={screenReaderKey} | ||
announcementText={screenReaderKey ? | ||
processTranslation({ | ||
phrase: `${options.length} autocomplete option found.`, | ||
pluralForm: `${options.length} autocomplete options found.`, | ||
count: options.length | ||
}) | ||
: '' | ||
} | ||
announcementText={screenReaderKey ? screenReaderText : ''} | ||
/> | ||
{shouldDisplayDropdown && | ||
<Dropdown | ||
options={options} | ||
optionIdPrefix={optionIdPrefix} | ||
onClickOption={option => { | ||
onClickOption={(option, sectionIndex, optionIndex) => { | ||
updateInputValue(option.value); | ||
onSubmit(option.value) | ||
onSubmit(option.value, sectionIndex, optionIndex) | ||
dispatch({ type: 'HideOptions' }) | ||
}} | ||
focusedSectionIndex={focusedSectionIndex} | ||
focusedOptionIndex={focusedOptionIndex} | ||
cssClasses={cssClasses} | ||
/> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might consider moving these functions to the top level of the file. I don't think it matters in this case, but react uses reference equality in a number of places, and here new
renderSection()
andrenderOption()
functions are used for each render ofDropdown
. If we move them to the top level the functions will have stable references. AGAIN I don't think matters here because we are not passing these functions in as props anywhere or using them in as memo dependencies etc. but I also don't have a compiler in my brain