From f703365e6d39c61139678a49b24518a039974b86 Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Wed, 17 Nov 2021 09:03:29 -0500 Subject: [PATCH] FilterSearch --- sample-app/src/components/Dropdown.tsx | 40 +++++-- sample-app/src/components/FilterSearch.tsx | 68 ++++++------ sample-app/src/components/InputDropdown.tsx | 117 ++++++++++++++------ sample-app/src/components/SearchBar.tsx | 17 ++- sample-app/src/pages/VerticalSearchPage.tsx | 9 +- sample-app/src/sass/Autocomplete.scss | 7 +- 6 files changed, 170 insertions(+), 88 deletions(-) diff --git a/sample-app/src/components/Dropdown.tsx b/sample-app/src/components/Dropdown.tsx index d21571b4..0eda5f2e 100644 --- a/sample-app/src/components/Dropdown.tsx +++ b/sample-app/src/components/Dropdown.tsx @@ -6,12 +6,15 @@ 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 +26,41 @@ 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 ( +
+ {section.label && +
+ {section.label} +
+ } + {section.results.map((option, optionIndex) => renderOption(option, sectionIndex, optionIndex))} +
+ ) + } + + function renderOption(option: Option, sectionIndex: number, optionIndex: number) { const className = classNames(cssClasses.option, { - [cssClasses.focusedOption]: index === focusedOptionIndex + [cssClasses.focusedOption]: sectionIndex === focusedSectionIndex && optionIndex === focusedOptionIndex }) return (
onClickOption(option)}> + id={`${optionIdPrefix}-${sectionIndex}-${optionIndex}`} + onClick={() => onClickOption(option, sectionIndex, optionIndex)}> {option.render()} -
) + + ) } if (options.length < 1) { @@ -47,7 +69,7 @@ export default function Dropdown({ return (
- {options.map((option, index) => renderOption(option, index))} + {options.map((section, sectionIndex) => renderSection(section, sectionIndex))}
); }; \ No newline at end of file diff --git a/sample-app/src/components/FilterSearch.tsx b/sample-app/src/components/FilterSearch.tsx index b38e2442..17febfdd 100644 --- a/sample-app/src/components/FilterSearch.tsx +++ b/sample-app/src/components/FilterSearch.tsx @@ -1,6 +1,6 @@ import { FilterSearchResponse } from "@yext/answers-core"; import { SearchParameterField } from "@yext/answers-core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useAnswersActions } from '@yext/answers-headless-react'; import InputDropdown from "./InputDropdown"; import renderWithHighlighting from "./utils/renderWithHighlighting"; @@ -15,74 +15,72 @@ export interface FilterSearchProps { screenReaderInstructionsId: string } - export default function FilterSearch (props: FilterSearchProps): JSX.Element { const { title, sectioned, searchFields, screenReaderInstructionsId } = props; const answersActions = useAnswersActions(); - const input = useRef(''); + const [input, setInput] = useState(''); const [results, updateResults] = useState(); const [, setMessage] = useState( '' ); const requestId = useRef(0); const responseId = useRef(0); - - - const executeSearch = useCallback(async () => { + async function executeFilterSearch (inputValue: string) { const currentId = ++requestId.current; - const results = await answersActions.executeFilterSearch(input.current, sectioned, searchFields); + const results = await answersActions.executeFilterSearch(inputValue, sectioned, searchFields); if (currentId >= responseId.current) { responseId.current++; updateResults(results); } - }, [answersActions, searchFields, sectioned]); - - useEffect(() => { - executeSearch(); - }, [executeSearch]); + } - let stuff: Option[] = []; + let options: {results: Option[], label?: string}[] = []; if (results) { - if(results.sectioned) { - results.sections.forEach(section => { - section.results.forEach(result => { - stuff.push({ - value: result.value, - render: () => renderWithHighlighting(result) - }); - }) - }); - } else { - stuff = results.results.map(result => { - return { + results.sections.forEach(section => { + let results: Option[] = []; + section.results.forEach(result => { + results.push({ value: result.value, render: () => renderWithHighlighting(result) - } + }); }); - } + options.push({ + results: results, + label: section.label + }); + }); } return (

{title}

{ - answersActions.executeVerticalQuery(); - setMessage('enter!'); + 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 }, 'someFiltersSearchId'); + answersActions.executeVerticalQuery(); + setMessage('enter!'); + } + } }} updateInputValue={newInput => { - input.current = newInput; + setInput(newInput); }} - updateDropdown={() => { - executeSearch(); + updateDropdown={(input) => { + executeFilterSearch(input); }} cssClasses={{ optionContainer: 'Autocomplete', + optionSection: 'Autocomplete__optionSection', + sectionLabel: 'Autocomplete__sectionLabel', option: 'Autocomplete__option', focusedOption: 'Autocomplete__option--focused', inputElement: 'FilterSearch__input', diff --git a/sample-app/src/components/InputDropdown.tsx b/sample-app/src/components/InputDropdown.tsx index b2cdf81e..630de1fb 100644 --- a/sample-app/src/components/InputDropdown.tsx +++ b/sample-app/src/components/InputDropdown.tsx @@ -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,6 +27,7 @@ interface Props { } interface State { + focusedSectionIndex?: number, focusedOptionIndex?: number, shouldDisplayDropdown: boolean } @@ -31,16 +35,16 @@ interface State { 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,6 +58,7 @@ export default function InputDropdown({ screenReaderInstructionsId, options, optionIdPrefix, + onlySubmitOnOption, onSubmit = () => {}, updateInputValue, updateDropdown, @@ -61,15 +66,17 @@ export default function InputDropdown({ 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,36 @@ 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 phrase = section.label ? + processTranslation({ + phrase: `${section.results.length} ${section.label} autocomplete option found.`, + pluralForm: `${section.results.length} ${section.label} autocomplete options found.`, + count: section.results.length + }) + : processTranslation({ + phrase: `${section.results.length} autocomplete option found.`, + pluralForm: `${section.results.length} 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) + || target.classList.contains(cssClasses.optionSection) || target.classList.contains(cssClasses.sectionLabel))) { dispatch({ type: 'HideOptions' }); } } @@ -97,24 +131,43 @@ export default function InputDropdown({ evt.preventDefault(); } + const isFirstSectionFocused = focusedSectionIndex === 0; + const isLastSectionFocused = focusedSectionIndex === options.length - 1; const isFirstOptionFocused = focusedOptionIndex === 0; - const isLastOptionFocused = focusedOptionIndex === options.length - 1; + const isLastOptionFocused = + focusedSectionIndex !== undefined && + focusedOptionIndex === options[focusedSectionIndex].results.length - 1; if (evt.key === 'Enter') { - updateInputValue(inputValue); - onSubmit(inputValue); - dispatch({ type: 'HideOptions' }); + if (!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)) { + 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 +184,13 @@ export default function InputDropdown({ dispatch({ type: 'ShowOptions' }); setLatestUserInput(value); updateInputValue(value); - updateDropdown(); + updateDropdown(value); setScreenReaderKey(screenReaderKey + 1); }} onClick={() => { dispatch({ type: 'ShowOptions' }); - updateDropdown(); - if (options.length || inputValue) { + updateDropdown(inputValue); + if (options[0]?.results.length || inputValue) { setScreenReaderKey(screenReaderKey + 1); } }} @@ -153,24 +206,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 && { + onClickOption={(option, sectionIndex, optionIndex) => { updateInputValue(option.value); - onSubmit(option.value) + onSubmit(option.value, sectionIndex, optionIndex) dispatch({ type: 'HideOptions' }) }} + focusedSectionIndex={focusedSectionIndex} focusedOptionIndex={focusedOptionIndex} cssClasses={cssClasses} /> diff --git a/sample-app/src/components/SearchBar.tsx b/sample-app/src/components/SearchBar.tsx index 20942ed9..6a339c43 100644 --- a/sample-app/src/components/SearchBar.tsx +++ b/sample-app/src/components/SearchBar.tsx @@ -59,13 +59,16 @@ export default function SearchBar({ placeholder, isVertical, screenReaderInstruc placeholder={placeholder} screenReaderInstructions={SCREENREADER_INSTRUCTIONS} screenReaderInstructionsId={screenReaderInstructionsId} - options={autocompleteResults.map(result => { - return { - value: result.value, - render: () => renderWithHighlighting(result) - } - })} + options={autocompleteResults.length > 0 ? [{ + results: autocompleteResults.map(result => { + return { + value: result.value, + render: () => renderWithHighlighting(result) + } + }) + }] : []} optionIdPrefix='Autocomplete__option' + onlySubmitOnOption={false} onSubmit={executeQuery} updateInputValue={value => { answersActions.setQuery(value); @@ -76,6 +79,8 @@ export default function SearchBar({ placeholder, isVertical, screenReaderInstruc renderButtons={renderSearchButton} cssClasses={{ optionContainer: 'Autocomplete', + optionSection: 'Autocomplete__optionSection', + sectionLabel: 'Autocomplete__sectionLabel', option: 'Autocomplete__option', focusedOption: 'Autocomplete__option--focused', inputElement: 'SearchBar__input', diff --git a/sample-app/src/pages/VerticalSearchPage.tsx b/sample-app/src/pages/VerticalSearchPage.tsx index 1b9be30e..a0822edd 100644 --- a/sample-app/src/pages/VerticalSearchPage.tsx +++ b/sample-app/src/pages/VerticalSearchPage.tsx @@ -59,6 +59,11 @@ const filterSearchFields = [{ fieldApiName: 'builtin.location', entityType: 'ce_person', fetchEntities: false +}, +{ + fieldApiName: 'name', + entityType: 'ce_person', + fetchEntities: false }]; export default function VerticalSearchPage(props: { @@ -77,8 +82,8 @@ export default function VerticalSearchPage(props: { return (
diff --git a/sample-app/src/sass/Autocomplete.scss b/sample-app/src/sass/Autocomplete.scss index 5f2f0084..984d8d36 100644 --- a/sample-app/src/sass/Autocomplete.scss +++ b/sample-app/src/sass/Autocomplete.scss @@ -1,11 +1,16 @@ .Autocomplete { margin: 0; background: white; - padding: 1em 0.5em; + padding: 1em 0.5em 0.5em 0.25em; border: 1px solid gray; + &__optionSection { + padding-bottom: 0.5em; + } + &__option { cursor: pointer; + padding-left: 0.5em; &--focused { background: aliceblue;