Skip to content
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

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions sample-app/src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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) {
Copy link
Contributor

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() and renderOption() functions are used for each render of Dropdown. 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

return (
Copy link
Member

Choose a reason for hiding this comment

The 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) {
Expand All @@ -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>
);
};
93 changes: 93 additions & 0 deletions sample-app/src/components/FilterSearch.tsx
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) {
Copy link
Contributor

@yen-tt yen-tt Nov 17, 2021

Choose a reason for hiding this comment

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

I think we can use the useAutocomplete hook but maybe call it useSynchronizedSearch (there's probably a better name) and takes in a function to execute either autocomplete or filtersearch.

Copy link
Member

Choose a reason for hiding this comment

The 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>
);
}
110 changes: 75 additions & 35 deletions sample-app/src/components/InputDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }
}
}

Expand All @@ -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);
Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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' });
}
}
Expand All @@ -97,24 +126,41 @@ export default function InputDropdown({
evt.preventDefault();
}

const isFirstSectionFocused = focusedSectionIndex === 0;
Copy link
Member

Choose a reason for hiding this comment

The 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);
}
Expand All @@ -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);
}
}}
Expand All @@ -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}
/>
Expand Down
Loading