diff --git a/.storybook/preview.js b/.storybook/preview.js index f8eebcee..6b8e2a1b 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -68,6 +68,8 @@ export const parameters = { ['Documentation', 'Live', 'Without style', 'Class based'], 'Range', ['Documentation', 'Live', 'Without style', 'Class based'], + 'StaticAutocomplete', + ['Documentation', 'Live', 'Without style', 'Class based'], 'Toggle', ['Documentation', 'Live', 'Without style', 'Class based'], 'Tooltip', diff --git a/example/src/components/StaticAutocompleteDemo.tsx b/example/src/components/StaticAutocompleteDemo.tsx new file mode 100644 index 00000000..41adb0eb --- /dev/null +++ b/example/src/components/StaticAutocompleteDemo.tsx @@ -0,0 +1,187 @@ +import * as React from 'react'; +import { StaticAutocomplete } from '@capgeminiuk/dcx-react-library'; + +const handleSearch = (value: string, options: string[]) => { + return options + .filter((optionsName) => + optionsName.toLowerCase().includes(value.toLowerCase()) + ) + .sort((a, b) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }); +}; + +export const StaticAutocompleteDemo = () => { + const [selected, setSelected] = React.useState(''); + const handleSelected = (value: string) => setSelected(value); + + const [serverOptions, setServerOptions] = React.useState([]); + + const handleOnChange = (value: string, _options: string[]) => { + let result: string[] = [] + switch (value) { + case 'p': + result = [ + 'Papaya', + 'Persimmon', + 'Paw Paw', + 'Prickly Pear', + 'Peach', + 'Pomegranate', + 'Pineapple', + ]; + break; + case 'pe': + result = ['Persimmon', 'Peach']; + break; + case 'per': + result = ['Persimmon']; + break; + default: + result = ['no results']; + } + + setServerOptions(result); + return result; + }; + + const [status, setStatus] = React.useState(''); + const change = (length: number, property: string, position: number) => { + let newText = ''; + if (length === 0) { + newText = 'No search results'; + } else if (length > 0) { + newText = `${length} result${length > 1 ? 's are' : ' is'} available. ${property} ${position} of ${length} is highlighted`; + } + setStatus(newText); + }; + + return ( + <> + + change(length, property, position) + } + accessibilityStatus={status} + accessibilityHintText="When autocomplete results are available use up and down arrows to + review and enter to select. Touch device users, explore by touch or + with swipe gestures." + /> + selected: {selected} + +

Server fetch

+ + selected: {selected} + +

With conditional prompt

+ true} + promptMessage="Enter a valid date before typing here" + debounceMs={100} + hintText="click inside the input to see the prompt" + hintClass="hintClass" + resultUlClass="resultUlClass" + resultlLiClass="resultlLiClass" + resultNoOptionClass="resultNoOptionClass" + resultActiveClass="resultActiveClass" + notFoundText="No fruit found" + /> +

With min-chars prompt

+ +

With search and alphabet sort

+ + + ); +}; diff --git a/example/src/components/index.ts b/example/src/components/index.ts index ef3717d0..17ce595b 100644 --- a/example/src/components/index.ts +++ b/example/src/components/index.ts @@ -26,3 +26,4 @@ export { AccordionDemo } from './AccordionDemo'; export * from './library-candidates'; export { ButtonGroupDemo } from './ButtonGroupDemo'; export { CardDemo } from './CardDemo'; +export { StaticAutocompleteDemo } from './StaticAutocompleteDemo'; diff --git a/example/src/index.tsx b/example/src/index.tsx index 869be7ef..53117ca9 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -26,6 +26,7 @@ import { AccordionDemo, ButtonGroupDemo, CardDemo, + StaticAutocompleteDemo, } from './components'; import './global-styles.scss'; import { Login } from './pages/Login'; @@ -75,6 +76,7 @@ const App = () => ( } /> } /> } /> + } /> diff --git a/example/src/pages/HomePage.tsx b/example/src/pages/HomePage.tsx index 938c3815..e03cb521 100644 --- a/example/src/pages/HomePage.tsx +++ b/example/src/pages/HomePage.tsx @@ -20,6 +20,9 @@ export const Home = () => (
  • AutocompleteDemo
  • +
  • + StaticAutocompleteDemo +
  • ButtonDemo
  • diff --git a/example/src/pages/Register.tsx b/example/src/pages/Register.tsx index 5cd3b1ab..267d0bba 100644 --- a/example/src/pages/Register.tsx +++ b/example/src/pages/Register.tsx @@ -239,6 +239,7 @@ export const Register = () => { }} validation={usernameValidation} errorPosition={ErrorPosition.BOTTOM} + hiddenErrorText="" /> @@ -284,6 +285,7 @@ export const Register = () => { onClick: updatePasswordInput, }, }} + hiddenErrorText="" /> diff --git a/src/index.ts b/src/index.ts index 4475bc0a..a3ecb198 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,3 +37,4 @@ export * from './codesnippet'; export * from './highlight'; export * from './buttonGroup'; export * from './card'; +export * from './staticAutocomplete'; diff --git a/src/staticAutocomplete/StaticAutocomplete.tsx b/src/staticAutocomplete/StaticAutocomplete.tsx new file mode 100644 index 00000000..b47af8f7 --- /dev/null +++ b/src/staticAutocomplete/StaticAutocomplete.tsx @@ -0,0 +1,611 @@ +import React, { useRef, useState } from 'react'; +import { FormInput } from '../formInput'; +import { FormSelect } from '../formSelect'; +import { ErrorMessage, Hint, useHydrated, debounce } from '../common'; +import { ResultList } from '../autocomplete/ResultList'; +import { VisuallyHidden } from '../common/components/commonTypes'; +import { FormSelectProps } from '../formSelect/FormSelect'; + +type staticAutocompleteProps = { + /** + * you need to provide a list of values to filter + */ + options: any[]; + /** + * it will add a dynamic id to every option provided. It will concatenate an index for each item + * @example + * optionsId: 'dcx-option-id will result as dcx-option-id-1, dcx-option-id-2, etc + */ + optionsId?: string; + /** + * specify the number of character to press before the option are displayed + */ + minCharsBeforeSearch?: number; + /** + * the messsage to display if the specified number of minimum characters (minCharsBeforeSearch) + * has not yet been entered by the user + */ + minCharsMessage?: string; + /** + * a predicate function for determining whether to display a promptMessage + * when the search input receives focus + */ + promptCondition?: () => boolean; + /** + * the message to display if the promptCondition has been met + */ + promptMessage?: string; + /** + * the value for the id attribute of the prompt container + */ + promptId?: string; + /** + * a CSS class for styling the prompt + */ + promptClassName?: string; + /** + * will allow to delay the filter results (the value is in ms) + */ + debounceMs?: number; + /** + * will pass the default value + */ + defaultValue?: string; + /** + * you can specify a description label to provide additional information + */ + hintText?: string; + /** + * allow to specify extra properties on the input + */ + inputProps?: any; + /** + * you can style the look and feel of your hint text + */ + hintClass?: string; + /** + * if you want to pass an id to the hint + */ + hintId?: string; + /** + * if you want to pass an id to the result UL list + */ + resultId?: string; + /** + * if you want to pass a style class to the result UL list + */ + resultUlClass?: string; + /** + * if you want to pass a style class to the result UL container + */ + resultUlStyle?: React.CSSProperties; + /** + * if you want to pass a style class to the result LI list (it will automatically add --odd,--even to the className to help you style the alternating rows) + */ + resultlLiClass?: string; + /** + * if you want to pass a style class to the result UL container + */ + resultLiStyle?: React.CSSProperties; + /** + * if you want to pass a style class to no result list + */ + resultNoOptionClass?: string; + /** + * if you want to style the element selected in the result list + */ + resultActiveClass?: string; + /** + * if you want to style the search element + */ + searchContainerStyle?: React.CSSProperties; + /** + * optional text for not found element + */ + notFoundText?: string; + /** + * event that return the selected value{} + */ + onSelected?: (value: string) => void; + /** + * Specifies if that field needs to be filled or not + */ + required?: boolean; + /** + * allow to specify a class for the container + */ + containerClassName?: string; + /** + * if a label is provided, it will be displayed + */ + labelText?: string; + /** + * if a label is provided, it will provide the ability to style it + */ + labelClassName?: string; + /** + * allow to customise the label with all the properties needed + **/ + labelProps?: React.LabelHTMLAttributes; + /** + * it will pass an id to the input or select element(in case of progressive enhancement) + */ + id?: string; + /** + * will display an error message in three different positions (BEFORE_LABEL, BOTTOM, AFTER_LABEL and AFTER_HINT) + */ + errorPosition?: StaticAutocompleteErrorPosition; + /** + * error message text + */ + errorMessageText?: string; + /** + * error className + */ + errorMessageClassName?: string; + /** + * error id + **/ + errorId?: string; + /** + * visually hidden text of the error + */ + errorVisuallyHiddenText?: VisuallyHidden; + /** + * if you want to pass a name for the input or for the select (in case of progressive enhancement) + */ + name?: string; + /** + * it will pass extra select element(in case of progressive enhancement) + */ + selectProps?: FormSelectProps; + /** + * generic parameter to pass whatever element before the input + **/ + prefix?: { + content?: JSX.Element | string; + properties: React.HTMLAttributes; + }; + /** + * generic parameter to pass whatever element after the input + **/ + suffix?: { + content?: JSX.Element | string; + properties: React.HTMLAttributes; + }; + /** + * tab index value to focus on the input + */ + tabIndex?: number; + /** + * search function to decide how the autocomplete component finds results + */ + search?: (value: string, options: any) => string[]; + /** + * A value to display to the users the current state of the autocomplete, this is used for accessibility and is displayed in a hidden element above the input. + * This should be used to display information such as how may results are being shown on screen, which element has been highlighted and what position it is in the reuslt list + */ + accessibilityStatus?: string; + /** + * Text visible only to screen readers to give a hint on how to use the component + */ + accessibilityHintText?: string; + /** + * Returns the current value of length, optionText and position when the user types or interact with the keyboard (keyUp, KeyDown). + * length: The length of the results shown after the user has started typing + * optionText: The currently highlighted text in the result list, this will return the value the user is shown + * position: The position of the currently highlighted option in the results list + * @example + * One usage of this method could be to update the value of the accessibilityStatus + */ + statusUpdate?: (length: number, optionText: string, position: number) => void; + /** + * if this property is passed the Autocomplete component will NOT display the select by default but + * will render whatever custom component is passed + */ + customNonJSComp?: JSX.Element; +}; + +export enum StaticAutocompleteErrorPosition { + BEFORE_LABEL = 'before-label', + BOTTOM = 'bottom', + AFTER_LABEL = 'after-label', + AFTER_HINT = 'after-hint', +} + +export const StaticAutocomplete = ({ + options, + optionsId, + minCharsBeforeSearch = 1, + minCharsMessage = '', + promptCondition = () => false, + promptMessage = '', + promptId = 'input-prompt', + promptClassName, + debounceMs = 0, + inputProps, + defaultValue = '', + hintText, + hintClass, + hintId, + notFoundText, + resultId, + resultActiveClass, + resultUlClass, + resultUlStyle, + resultlLiClass, + resultLiStyle, + resultNoOptionClass, + searchContainerStyle, + onSelected, + required = false, + containerClassName, + labelText, + labelClassName, + labelProps, + id, + errorPosition, + errorMessageText = '', + errorMessageClassName, + errorId, + errorVisuallyHiddenText, + name, + selectProps, + prefix, + suffix, + tabIndex, + search, + accessibilityStatus = '', + accessibilityHintText = '', + statusUpdate, + customNonJSComp = undefined, +}: staticAutocompleteProps) => { + const [activeOption, setActiveOption] = useState(0); + const [filterList, setFilterList] = useState([]); + const [showOptions, setShowOptions] = useState(false); + const [showPrompt, setShowPrompt] = useState(false); + const [userInput, setUserInput] = useState(defaultValue); + const [currentAutocompleteStatus, setCurrentAutocompleteStatus] = + useState(true); + const resultRef = useRef(null) as React.MutableRefObject< + HTMLLIElement[] + >; + const [accessibilityStatusA, setAccessibilityStatusA] = useState(''); + const [accessibilityStatusB, setAccessibilityStatusB] = useState(''); + let hydrated = useHydrated(); + + const displayComp = () => { + if (hydrated) { + return formInput; + } else { + if (customNonJSComp !== undefined) { + return customNonJSComp; + } else { + return ( + + ); + } + } + }; + + const showPromptMessage = (inputValue = userInput): boolean => + inputValue.trim().length === 0 && + promptCondition() && + promptMessage.length > 0; + + const showMinCharsMessage = (inputValue = userInput): boolean => + !showPromptMessage() && + inputValue.trim().length < minCharsBeforeSearch && + minCharsMessage.length > 0; + + const displayResultList = (inputValue = userInput): boolean => + showOptions && inputValue.trim().length >= minCharsBeforeSearch; + + const handlePrompt = ( + _evt: React.FormEvent, + inputValue = userInput + ) => { + const canShowPrompt = + !displayResultList(inputValue) && + (showMinCharsMessage(inputValue) || showPromptMessage(inputValue)); + + if (!showPrompt && canShowPrompt) { + setShowPrompt(true); + } else if (showPrompt && !canShowPrompt) { + setShowPrompt(false); + } + }; + + const delayResult = React.useMemo( + () => + debounce((value) => { + const filtered: string[] = search + ? search(value, options) + : options.filter((optionsName: string) => + optionsName.toLowerCase().includes(value.toLowerCase()) + ); + setActiveOption(0); + setFilterList(filtered); + setShowOptions(true); + statusUpdate && statusUpdate(filtered.length, filtered[0], 1); + }, debounceMs), + [debounceMs, options, currentAutocompleteStatus] + ); + + const delayedFilterResults = React.useCallback(delayResult, [delayResult]); + + const handleChange = (evt: React.FormEvent) => { + // prevent user input if promptCondition() is true + if (promptCondition()) { + return; + } + + const { value } = evt.currentTarget; + setUserInput(value); + handlePrompt(evt, value); + // if the user input is blank, close the options list and set the accessibility status to blank + if (value === '') { + setShowOptions(false); + statusUpdate && statusUpdate(-1, '', 0); + } else { + delayedFilterResults(value); + } + }; + + React.useEffect(() => { + setAccessibilityStatus(accessibilityStatus); + }, [accessibilityStatus]); + + React.useEffect(() => { + setUserInput(defaultValue); + }, [defaultValue]); + + const handleClick = (evt: React.FormEvent) => { + const optionName = evt.currentTarget.innerHTML; + setActiveOption(0); + setFilterList([]); + setShowOptions(false); + setUserInput(optionName); + statusUpdate && statusUpdate(-1, '', 0); + if (onSelected) onSelected(optionName); + }; + + const onKeyDown = (evt: React.KeyboardEvent) => { + if (!showOptions) { + return; + } + + if (evt.code === 'Enter') { + evt.preventDefault(); + setActiveOption(0); + setShowOptions(false); + + if (filterList.length > 0) { + setUserInput(filterList[activeOption]); + statusUpdate && statusUpdate(-1, '', 0); + if (onSelected) onSelected(filterList[activeOption]); + } + } else if (evt.code === 'ArrowUp') { + if (activeOption === 0) { + return; + } + const newActiveOption = activeOption - 1; + setActiveOption(newActiveOption); + const prevItem = resultRef.current && resultRef.current[newActiveOption]; + prevItem && + prevItem.scrollIntoView({ block: 'nearest', inline: 'start' }); + statusUpdate && + statusUpdate( + filterList.length, + filterList[newActiveOption], + newActiveOption + 1 + ); + } else if (evt.code === 'ArrowDown') { + if (activeOption === filterList.length - 1) { + return; + } + const newActiveOption = activeOption + 1; + setActiveOption(newActiveOption); + const nextItem = resultRef.current && resultRef.current[newActiveOption]; + nextItem && + nextItem.scrollIntoView({ block: 'nearest', inline: 'start' }); + statusUpdate && + statusUpdate( + filterList.length, + filterList[newActiveOption], + newActiveOption + 1 + ); + } else if (evt.code === 'Escape') { + setShowOptions(false); + } + }; + + const onBlur = (event: React.FocusEvent) => { + setShowPrompt(false); + let focusingOnInput = false; + let focusingOnOptions = false; + if (event.relatedTarget !== null) { + // checks to see if the element comming into focus is the specific input element + focusingOnInput = event.relatedTarget.id === id; + // checks to see if the element comming into focus is an option + focusingOnOptions = Object.keys(resultRef.current) + .map((value: string) => resultRef.current[parseInt(value, 10)]) + .includes(event.relatedTarget as HTMLLIElement); + } + if (!(focusingOnInput || focusingOnOptions)) { + setShowOptions(false); + } + }; + + const setAccessibilityStatus = (newStatus: string) => { + if (currentAutocompleteStatus) { + setAccessibilityStatusA(''); + setAccessibilityStatusB(newStatus); + } else { + setAccessibilityStatusA(newStatus); + setAccessibilityStatusB(''); + } + // Alternates between the two status elements to make sure the change is seen for screen readers + setCurrentAutocompleteStatus(!currentAutocompleteStatus); + }; + + const getActivedescendantId = () => { + if (resultRef.current === null && showOptions) { + return `${optionsId}--1`; + } else if (resultRef.current && resultRef.current[activeOption]) { + return resultRef.current[activeOption].id; + } else { + return null; + } + }; + const formInput: JSX.Element = ( + <> + + {showPrompt && ( +
    + {showPromptMessage() && promptMessage} + {showMinCharsMessage() && minCharsMessage} +
    + )} + + ); + + const searchEl: JSX.Element = ( +
    + {errorPosition && + errorPosition === StaticAutocompleteErrorPosition.BEFORE_LABEL && ( + + )} + {labelText && ( + + )} + {errorPosition && + errorPosition === StaticAutocompleteErrorPosition.AFTER_LABEL && ( + + )} + {hintText && ( + + )} + {errorPosition && + errorPosition === StaticAutocompleteErrorPosition.AFTER_HINT && ( + + )} +
    +
    + {accessibilityStatusA} +
    +
    + {accessibilityStatusB} +
    +
    + {displayComp()} +
    + ); + + return ( + <> +
    + {searchEl} + {displayResultList() && ( + + )} + + {accessibilityHintText} + +
    + + ); +}; diff --git a/src/staticAutocomplete/__tests__/StaticAutocomplete.test.tsx b/src/staticAutocomplete/__tests__/StaticAutocomplete.test.tsx new file mode 100644 index 00000000..eac86133 --- /dev/null +++ b/src/staticAutocomplete/__tests__/StaticAutocomplete.test.tsx @@ -0,0 +1,1448 @@ +import React from 'react'; +import { + fireEvent, + render, + screen, + waitFor, + act, +} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { StaticAutocomplete, StaticAutocompleteErrorPosition } from '../'; +import userEvent from '@testing-library/user-event'; +import * as hooks from '../../common/utils/clientOnly'; +import { FormInput } from '../../formInput'; + +const DummyDynamicAutoComplete = () => { + const handleInputChange = (value: string, _options: string[]) => { + let result: string[] = [] + switch (value) { + case 'p': + result = [ + 'Papaya', + 'Persimmon', + 'Paw Paw', + 'Prickly Pear', + 'Peach', + 'Pomegranate', + 'Pineapple', + ]; + break; + case 'pe': + result = ['Persimmon', 'Peach']; + break; + case 'per': + result = ['Persimmon']; + break; + default: + result = ['no results']; + } + + setServerOptions(result); + return result; + }; + const [serverOptions, setServerOptions] = React.useState([]); + return ( + {}} + hintText="search the list of fruits dynamically" + search={ handleInputChange } + debounceMs={100} + notFoundText=" " + /> + ); +}; + +describe('StaticAutocomplete', () => { + beforeAll(() => { + window.HTMLLIElement.prototype.scrollIntoView = jest.fn(); + }); + + it('should select the default value if progressive enhancement is enable', () => { + //@ts-ignore + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => false); + render( + + ); + const option: any = screen.getByRole('option', { name: 'isaac' }); + expect(option.selected).toBe(true); + }); + + it('should allow to specify a containerClass name ', () => { + const { container } = render( + + ); + const containerClass = container.querySelector('.containerClass'); + expect(containerClass).toBeInTheDocument(); + }); + + it('should allow to specify a label text ', () => { + render( + + ); + const label: any = screen.getByText('labelText'); + expect(label.className).toBe('labelClass'); + }); + + it('should allow to specify a label className', () => { + render( + + ); + const label: any = screen.getByText('labelText'); + expect(label).toBeInTheDocument(); + }); + + it('should allow to specify a label id', () => { + render( + + ); + const label: any = screen.getByText('labelText'); + expect(label.id).toBe('labelid'); + }); + + it('should display an error', () => { + render( + + ); + const label: any = screen.getByText('errorMessageText'); + expect(label.className).toBe('dcx-error-message errorMessageClass'); + expect(label.id).toBe('errorId'); + expect(label).toBeInTheDocument(); + }); + + it('should display an error after the label', () => { + render( + + ); + const label: any = screen.getByText('errorMessageText'); + expect(label.className).toBe('dcx-error-message errorMessageClass'); + expect(label.id).toBe('errorId'); + expect(label).toBeInTheDocument(); + }); + + it('should display an error before the label', () => { + render( + + ); + const label: any = screen.getByText('errorMessageText'); + expect(label.className).toBe('dcx-error-message errorMessageClass'); + expect(label.id).toBe('errorId'); + expect(label).toBeInTheDocument(); + }); + + it('should allow to specify an id for the input', () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + render(); + const input: any = screen.getByRole('combobox'); + expect(input.id).toBe('myId'); + }); + + it('should allow to specify an id for the select in case of progressive enhnancment', () => { + //@ts-ignore + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => false); + render(); + const select: any = screen.getByRole('combobox'); + expect(select.id).toBe('myId'); + }); + + it('should display select if progresive enhancement', () => { + //@ts-ignore + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => false); + + render( + + ); + const formSelect: any = screen.getByRole('combobox'); + expect(formSelect.name).toBe('select'); + expect(formSelect).toBeInTheDocument(); + }); + + it('should display the formInput content', () => { + //@ts-ignore + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + render(); + + const input: any = screen.getByRole('combobox'); + expect(input.name).toBe('autocompleteSearch'); + expect(input.type).toBe('text'); + expect(input.value).toBe(''); + expect(input).toBeInTheDocument(); + }); + + it('should display the formInput default value', () => { + render( + + ); + + const input: any = screen.getByRole('combobox'); + expect(input.name).toBe('autocompleteSearch'); + expect(input.type).toBe('text'); + expect(input.value).toBe('daniele'); + expect(input).toBeInTheDocument(); + }); + + it('When defaultValue prop is changed, Autocomplete component updates.', async () => { + const DummyChangeState = () => { + const [defaultValue, setDefaultValue] = React.useState('Apple'); + const handleClick = (value: string) => { + setDefaultValue(value); + }; + return ( + <> + handleClick(value)} + /> + + + ); + }; + + render(); + await act(async () => { + await waitFor(() => { + const input: any = screen.getByRole('combobox'); + expect(input.value).toBe('Apple'); + }); + }); + const button = screen.getByRole('button'); + userEvent.click(button); + await waitFor(() => { + const input: any = screen.getByRole('combobox'); + expect(input.value).toBe('Orange'); + }); + }); + + it('should display available options', async () => { + const user = userEvent.setup(); + + render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'da'); + const listItems: any = screen.getAllByRole('option'); + await waitFor(() => { + expect(listItems[0].innerHTML).toBe('daniele'); + }); + }); + + it('should not display available options', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'test value'); + + const el: any = container.querySelector('li'); + expect(el).not.toBeInTheDocument(); + }); + + it('should allow to select the first item', async () => { + const user = userEvent.setup(); + render(); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'd'); + await waitFor(() => { + const item: any = screen + .getAllByRole('option') + .filter((listitem: any) => listitem.textContent === 'daniele'); + fireEvent.click(item[0]); + expect(input.value).toBe('daniele'); + }); + }); + + it("should allow to select the first item even if it's uppercase", async () => { + const user = userEvent.setup(); + + render(); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'D'); + await waitFor(() => { + const item: any = screen + .getAllByRole('option') + .filter((listitem: any) => listitem.textContent === 'daniele'); + fireEvent.click(item[0]); + expect(input.value).toBe('daniele'); + }); + }); + + it('should allow to select the first item even if the list of choices is uppercase', async () => { + const user = userEvent.setup(); + render(); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'd'); + await waitFor(() => { + const item: any = screen + .getAllByRole('option') + .filter((listitem: any) => listitem.textContent === 'Daniele'); + fireEvent.click(item[0]); + expect(input.value).toBe('Daniele'); + }); + }); + + it('should call the onSelected function if the function is provided', async () => { + const user = userEvent.setup(); + const handleOnSelected = jest.fn(); + render( + + ); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'd'); + await waitFor(() => { + const item: any = screen + .getAllByRole('option') + .filter((listitem: any) => listitem.textContent === 'daniele'); + fireEvent.click(item[0]); + expect(handleOnSelected).toBeCalled(); + }); + }); + + it('should set the input to an empty string when there is no text in the input and on pressing Enter', async () => { + render(); + + const input: any = screen.getByRole('combobox'); + + fireEvent.keyDown(input, { code: 'Enter', keyCode: 13 }); + + expect(input.value).toBe(''); + }); + + it('should highlight the selected option(s) on keyDown', async () => { + const user = userEvent.setup(); + render(); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'da'); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'Enter', keyCode: 13 }); + expect(input.value).toBe('darren'); + }); + + it('should highlight the selected option(s) on keyUp', async () => { + const user = userEvent.setup(); + + render(); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'da'); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowUp' }); + fireEvent.keyDown(input, { code: 'Enter', keyCode: 13 }); + expect(input.value).toBe('daniele'); + }); + + it('should display the next item when you scroll with the keyboard', async () => { + const user = userEvent.setup(); + + render( + + ); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'p'); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + + const listItems: HTMLLIElement[] = screen.getAllByRole('option'); + const exactItem = screen.queryByText(/Pineapple 5/i); + + expect(listItems[10]).toBeVisible(); + expect(exactItem).toBeVisible(); + }); + + it('should higlight the first one as active if you try to keyUp', async () => { + const user = userEvent.setup(); + render( + + ); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'da'); + fireEvent.keyDown(input, { code: 'ArrowUp' }); + + const listItems: any = screen.getAllByRole('option'); + expect(listItems[0].className).toBe('activeClass'); + }); + + it('should highlight the last one as active if you try to keyDown', async () => { + const user = userEvent.setup(); + render( + + ); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'da'); + + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'ArrowDown' }); + + const listItems: any = screen.getAllByRole('option'); + expect(listItems[1].className).toBe('activeClass'); + }); + + it('should call the selected function after the selection', async () => { + const handleSelected = jest.fn(); + const user = userEvent.setup(); + render( + + ); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'da'); + + fireEvent.keyDown(input, { code: 'ArrowDown' }); + fireEvent.keyDown(input, { code: 'Enter', keyCode: 13 }); + expect(handleSelected).toBeCalled(); + }); + + it('should display an hint label if specified', () => { + const hintClass = 'hintClass'; + + const { container } = render( + + ); + const hint: any = container.querySelector(`.${hintClass}`); + expect(hint?.innerHTML).toBe('search names'); + }); + + it('should display an hint class if specified', () => { + const hintClass = 'labelClass'; + + const { container } = render( + + ); + const hint: any = container.querySelector(`.${hintClass}`); + expect(hint).not.toBeNull(); + }); + + it('should display an hint id if specified', () => { + render( + + ); + const hint: any = screen.getByText('search names'); + expect(hint.id).toBe('hintid'); + }); + + it('should display the results after typing 2 character', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'd'); + + const ulEl: any = container.querySelector('ul'); + expect(ulEl).toBeNull(); + await user.type(input, 'a'); + + const listItems: any = screen.getAllByRole('option'); + expect(listItems.length).toBe(2); + }); + + it('should populate the list dynamically - i.e. fetch from the server', async () => { + const user = userEvent.setup(); + render(); + const input: any = screen.getByRole('combobox'); + await user.type(input, 'p'); + await waitFor(() => { + const listItemsFirst: any = screen.getAllByRole('option'); + + expect(listItemsFirst.length).toBe(7); + }); + await user.type(input, 'e'); + await waitFor(() => { + const listItemsSecond: any = screen.getAllByRole('option'); + expect(listItemsSecond.length).toBe(2); + }); + }); + + it('should check that required attribute is defaulted to false', () => { + render(); + const input: any = screen.getByRole('combobox'); + expect(input.required).toBe(false); + }); + + it('should check that if required is set to true, input child is rendered with the attribute', () => { + render(); + const input: any = screen.getByRole('combobox'); + expect(input.required).toBe(true); + }); + + it('should contains the input name if specified', () => { + render(); + const input: any = screen.getByRole('combobox'); + expect(input.name).toBe('inputName'); + }); + + it('should contains the select name if specified and progressive enhancment', () => { + //@ts-ignore + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => false); + render( + + ); + const formSelect: any = screen.getByRole('combobox'); + expect(formSelect.name).toBe('selectName'); + }); + + it('should hide the results list if pressing the Escape key', async () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + + const user = userEvent.setup(); + + render(); + + const input = screen.getByRole('combobox'); + + await act(() => user.click(input)); + + let resultList = screen.queryByRole('listbox'); + expect(resultList).toBe(null); + + await act(() => user.type(input, 'da')); + + resultList = screen.queryByRole('listbox'); + expect(resultList).not.toBeNull(); + + fireEvent.keyDown(input, { code: 'Escape' }); + + resultList = screen.queryByRole('listbox'); + expect(resultList).toBe(null); + }); + + it('should display a prompt if receiving focus and the minimum number of characters have not yet been entered', async () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + + const user = userEvent.setup(); + const minCharsBeforeSearch = 2; + const minCharsMessage = `Please type at least ${minCharsBeforeSearch} character(s) to see the available options`; + const promptId = 'input-prompt'; + + const { container } = render( + + ); + + const input: any = screen.getByRole('combobox'); + + expect(input).not.toHaveAttribute('aria-describedby'); + + await act(() => user.click(input)); + + expect(input).toHaveAttribute('aria-describedby'); + + let prompt: any = container.querySelector(`#${promptId}`); + + expect(prompt.innerHTML).toBe(minCharsMessage); + + act(() => { + fireEvent.blur(input); + }); + + expect(input).not.toHaveAttribute('aria-describedby'); + + prompt = container.querySelector(`#${promptId}`); + + expect(prompt).toBeNull(); + }); + + it('should hide the prompt after the required minimum number of characters have been entered', async () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + + const user = userEvent.setup(); + const minCharsBeforeSearch = 1; + const minCharsMessage = `Please type at least ${minCharsBeforeSearch} character(s) to see the available options`; + const promptId = 'input-prompt'; + + const { container } = render( + + ); + + const input: any = screen.getByRole('combobox'); + + expect(input).not.toHaveAttribute('aria-describedby'); + + await act(() => user.click(input)); + + expect(input).toHaveAttribute('aria-describedby'); + + await act(() => user.type(input, 'da')); + + expect(input).not.toHaveAttribute('aria-describedby'); + + const prompt: any = container.querySelector(`#${promptId}`); + + expect(prompt).toBeNull(); + }); + + it('should display a conditional prompt before any characters have been entered', async () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + + const user = userEvent.setup(); + const promptMessage = 'Enter a valid date before typing here'; + const promptId = 'input-prompt'; + + // contrived predicate for testing purposes + let age = 3; + const predicate = () => age < 5; + + const { container } = render( + + ); + + const input: any = screen.getByRole('combobox'); + + expect(input).not.toHaveAttribute('aria-describedby'); + + await act(() => user.click(input)); + + expect(input).toHaveAttribute('aria-describedby'); + + let prompt: any = container.querySelector(`#${promptId}`); + + expect(prompt.innerHTML).toBe(promptMessage); + + // check if user input is prevented and the prompt is still visible + await act(() => user.type(input, 'd')); + + expect(input.value).toContain(''); + expect(input).toHaveAttribute('aria-describedby'); + + prompt = container.querySelector(`#${promptId}`); + + expect(prompt.innerHTML).toBe(promptMessage); + + // check if user input is allowed and the prompt is hidden when promptCondition becomes false + age = 7; + + await user.type(input, 'd'); + + expect(input.value).toContain('d'); + expect(input).not.toHaveAttribute('aria-describedby'); + + prompt = container.querySelector(`#${promptId}`); + + expect(prompt).toBeNull(); + }); + + it('conditional prompt should take precedence over the minChars if both props are provided', async () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + + const user = userEvent.setup(); + const minCharsBeforeSearch = 2; + const minCharsMessage = `Please type at least ${minCharsBeforeSearch} character(s) to see the available options`; + const promptMessage = 'Enter a valid date before typing here'; + const promptId = 'input-prompt'; + + const { container } = render( + true} + promptMessage={promptMessage} + promptId={promptId} + /> + ); + + const input: any = screen.getByRole('combobox'); + + expect(input).not.toHaveAttribute('aria-describedby'); + + await act(() => user.click(input)); + + expect(input).toHaveAttribute('aria-describedby'); + + const prompt: any = container.querySelector(`#${promptId}`); + + expect(prompt.innerHTML).toBe(promptMessage); + }); + + it('should contain the dynamic id for every item in the list if listId is present', async () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + + const user = userEvent.setup(); + const { container } = render( + + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'da'); + await waitFor(() => { + const el: any = container.querySelector('li'); + expect(el.id).toBe('dcx-option-id--1'); + }); + }); + + it('should not contain an id item if listId is not specified', async () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + + const user = userEvent.setup(); + const { container } = render( + + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'da'); + await waitFor(() => { + const el: any = container.querySelector('li'); + expect(el.id).toBe(''); + }); + }); + + it('should have a null tabIndex value by default', () => { + render(); + + const input: any = screen.getByRole('combobox'); + expect(input.getAttribute('tabindex')).toBeNull(); + }); + + it('should accept tabIndex attribute', () => { + render(); + + const input: any = screen.getByRole('combobox'); + expect(input.getAttribute('tabindex')).toBe('1'); + }); + + it('should accept an option list of objects', async () => { + const user = userEvent.setup(); + + const options: any = [ + { + firstName: 'Isaac', + surname: 'Babalola', + postion: 'Senior Developer', + }, + { + firstName: 'Daniele', + surname: 'Zurico', + position: 'Head of Full Stack Development', + }, + { + firstName: 'Healy', + surname: 'Ingenious', + position: 'Project Manager', + }, + ]; + + const quickSearch = (query: string, options: any[]) => { + const optionName = (option: any) => + `${option.firstName} ${option.surname} (${option.position})`; + const queryStr = query.toLowerCase(); + return options + .filter( + (option: any) => + optionName(option).toLowerCase().indexOf(queryStr) !== -1 + ) + .map((option: any) => { + const commonRank = 0; + let rank; + if (option.position.toLowerCase().indexOf(queryStr) !== -1) rank = 1; + else if (option.firstName.toLowerCase().indexOf(queryStr) !== -1) + rank = 10 + commonRank; + else if (option.surnameName.toLowerCase().indexOf(queryStr) !== -1) + rank = 20 + commonRank; + option.rank = rank || 100; + return option; + }) + .sort((a, b) => { + if (a.rank < b.rank) { + return -1; + } + + if (a.rank > b.rank) { + return 1; + } + + if (a.position < b.position) { + return -1; + } + + if (a.position > b.position) { + return 1; + } + + return 0; + }) + .map((option: any) => optionName(option)); + }; + + render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'he'); + const listItems: any = screen.getAllByRole('option'); + await waitFor(() => { + expect(listItems[0].innerHTML).toBe( + 'Daniele Zurico (Head of Full Stack Development)' + ); + expect(listItems[1].innerHTML).toBe('Healy Ingenious (Project Manager)'); + expect(listItems).toHaveLength(2); + }); + }); + + it('should render the autocomplete with all of the accessible elements', () => { + const status = ''; + const change = jest.fn(); + const hint = + 'When autocomplete results are available use up and down arrows to review and enter to select.'; + render( + + change(length, property, position) + } + accessibilityStatus={status} + accessibilityHintText={hint} + /> + ); + const statusElements = screen.getAllByRole('status'); + expect(statusElements.length).toBe(2); + expect(statusElements[0].id).toBe('autocomplete-status-fruitTest-A'); + expect(statusElements[1].id).toBe('autocomplete-status-fruitTest-B'); + const hiddenHintElm = document.getElementById( + 'autocomplete-fruitTest-assistiveHint' + ); + expect(hiddenHintElm?.innerHTML).toBe(hint); + const inputElm = screen.getByRole('combobox'); + expect(inputElm.getAttribute('aria-expanded')).toBe('false'); + expect(inputElm.getAttribute('aria-owns')).toBe('fruit-options-container'); + expect(inputElm.getAttribute('aria-activedescendant')).toBeNull(); + }); + + it('should update the accessibility status and alternate between the two options', async () => { + let status = ''; + const change = (length: number, property: string, position: number) => { + status = `${length} result${length > 1 ? 's are' : ' is'} available. ${property} ${position} of ${length} is highlighted`; + }; + const hint = + 'When autocomplete results are available use up and down arrows to review and enter to select.'; + const user = userEvent.setup(); + const component = (statusText: string) => ( + { + change(length, property, position); + }} + accessibilityStatus={statusText} + accessibilityHintText={hint} + /> + ); + + const { rerender } = render(component(status)); + + let statusElements = screen.getAllByRole('status'); + expect(statusElements.length).toBe(2); + expect(statusElements[0].innerHTML).toBe(''); + expect(statusElements[1].innerHTML).toBe(''); + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'p'); + + rerender(component(status)); + + statusElements = screen.getAllByRole('status'); + expect(statusElements[0].innerHTML).toBe( + '7 results are available. Papaya 1 of 7 is highlighted' + ); + expect(statusElements[1].innerHTML).toBe(''); + + await user.type(inputElm, 'a'); + + rerender(component(status)); + statusElements = screen.getAllByRole('status'); + expect(statusElements[0].innerHTML).toBe(''); + expect(statusElements[1].innerHTML).toBe( + '2 results are available. Papaya 1 of 2 is highlighted' + ); + }); + + it('should call status update when using the arrow up or down keys', async () => { + const status = 'status message'; + const hint = + 'When autocomplete results are available use up and down arrows to review and enter to select.'; + const user = userEvent.setup(); + const change = jest.fn(); + render( + { + change(length, property, position); + }} + accessibilityStatus={status} + accessibilityHintText={hint} + /> + ); + + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'p'); + fireEvent.keyDown(inputElm, { code: 'ArrowDown' }); + expect(change.mock.calls[1][0]).toBe(7); + expect(change.mock.calls[1][1]).toBe('Persimmon'); + expect(change.mock.calls[1][2]).toBe(2); + + fireEvent.keyDown(inputElm, { code: 'ArrowDown' }); + expect(change.mock.calls[2][0]).toBe(7); + expect(change.mock.calls[2][1]).toBe('Paw Paw'); + expect(change.mock.calls[2][2]).toBe(3); + + fireEvent.keyDown(inputElm, { code: 'ArrowDown' }); + expect(change.mock.calls[3][0]).toBe(7); + expect(change.mock.calls[3][1]).toBe('Prickly Pear'); + expect(change.mock.calls[3][2]).toBe(4); + + fireEvent.keyDown(inputElm, { code: 'ArrowUp' }); + expect(change.mock.calls[4][0]).toBe(7); + expect(change.mock.calls[4][1]).toBe('Paw Paw'); + expect(change.mock.calls[4][2]).toBe(3); + + fireEvent.keyDown(inputElm, { code: 'ArrowUp' }); + expect(change.mock.calls[5][0]).toBe(7); + expect(change.mock.calls[5][1]).toBe('Persimmon'); + expect(change.mock.calls[5][2]).toBe(2); + }); + + it('should call status update when the user hits enter on an option', async () => { + const status = 'status message'; + const hint = + 'When autocomplete results are available use up and down arrows to review and enter to select.'; + const user = userEvent.setup(); + const change = jest.fn(); + render( + { + change(length, property, position); + }} + accessibilityStatus={status} + accessibilityHintText={hint} + /> + ); + + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'p'); + + fireEvent.keyDown(inputElm, { code: 'Enter', keycode: 13 }); + expect(change.mock.calls[1][0]).toBe(-1); + expect(change.mock.calls[1][1]).toBe(''); + expect(change.mock.calls[1][2]).toBe(0); + }); + + it('should call status update when the user clicks on an option', async () => { + const status = 'status message'; + const hint = + 'When autocomplete results are available use up and down arrows to review and enter to select.'; + const user = userEvent.setup(); + const change = jest.fn(); + render( + { + change(length, property, position); + }} + accessibilityStatus={status} + accessibilityHintText={hint} + /> + ); + + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'p'); + + fireEvent.keyDown(inputElm, { code: 'ArrowDown' }); + const listItems = screen.getAllByRole('option'); + fireEvent.click(listItems[0]); + expect(change.mock.calls[2][0]).toBe(-1); + expect(change.mock.calls[2][1]).toBe(''); + expect(change.mock.calls[2][2]).toBe(0); + }); + + it('should call status update when the user clears what they have been typing', async () => { + const status = 'status message'; + const hint = + 'When autocomplete results are available use up and down arrows to review and enter to select.'; + const user = userEvent.setup(); + const change = jest.fn(); + render( + { + change(length, property, position); + }} + accessibilityStatus={status} + accessibilityHintText={hint} + /> + ); + + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'pap'); + expect(change.mock.calls[2][0]).toBe(1); + expect(change.mock.calls[2][1]).toBe('Papaya'); + expect(change.mock.calls[2][2]).toBe(1); + + await user.clear(inputElm); + expect(change.mock.calls[3][0]).toBe(-1); + expect(change.mock.calls[3][1]).toBe(''); + expect(change.mock.calls[3][2]).toBe(0); + }); + + it('should make sure status container is in the correct position', async () => { + const change = jest.fn(); + const hint = + 'When autocomplete results are available use up and down arrows to review and enter to select.'; + const { container } = render( + { + change(length, property, position); + }} + accessibilityStatus="status message" + accessibilityHintText={hint} + /> + ); + + let statusElements = screen.getAllByRole('status'); + expect(statusElements.length).toBe(2); + expect( + container.querySelector('label + div > div[role="status"]') + ).toBeInTheDocument(); + }); + + it('should close the options list on blur', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'p'); + expect(screen.getAllByRole('option').length).toBe(7); + + act(() => { + fireEvent.blur(inputElm); + }); + const options: any = container.querySelector('li'); + expect(options).not.toBeInTheDocument(); + }); + + it('should not close the options if the user clicks inside the input', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'p'); + expect(screen.getAllByRole('option').length).toBe(7); + userEvent.click(inputElm); + const options: any = container.querySelector('li'); + expect(options).toBeInTheDocument(); + }); + + it('should select the correct option and then close the options list', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + const inputElm = screen.getByRole('combobox'); + await user.type(inputElm, 'p'); + expect(screen.getAllByRole('option').length).toBe(7); + await userEvent.click(screen.getAllByRole('option')[2]); + const options: any = container.querySelector('li'); + expect(options).not.toBeInTheDocument(); + expect(inputElm.getAttribute('value')).toEqual('Paw Paw'); + }); + + it('should close the options list when the user clicks on an uninteractive element', async () => { + const user = userEvent.setup(); + const { container } = render( + <> + +
    test
    + + ); + + const inputElm = screen.getByRole('combobox'); + const testElm = screen.getByText('test'); + await user.type(inputElm, 'p'); + expect(screen.getAllByRole('option').length).toBe(7); + await userEvent.click(testElm); + const options: any = container.querySelector('li'); + expect(options).not.toBeInTheDocument(); + // Shows that an option was not selected + expect(inputElm.getAttribute('value')).toEqual('p'); + }); + + it('should close the options list when the user clicks on another input element', async () => { + const user = userEvent.setup(); + const { container } = render( + <> + + + + ); + + const inputElm = screen.getByRole('combobox'); + const otherInputElm = screen.getAllByRole('textbox')[1]; + await user.type(inputElm, 'p'); + expect(screen.getAllByRole('option').length).toBe(7); + await userEvent.click(otherInputElm); + const options: any = container.querySelector('li'); + expect(options).not.toBeInTheDocument(); + // Shows that an option was not selected + expect(inputElm.getAttribute('value')).toEqual('p'); + }); + + it('should render a custom component when JS is disabled and the prop customNonJSComp is used', () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => false); + render( + + } + /> + ); + const inputBox: any = screen.getByRole('textbox'); + expect(inputBox).toBeDefined(); + }); + + it('should render the autcomplete when JS is enabled and customNonJSComp attribute is passed', () => { + jest.spyOn(hooks, 'useHydrated').mockImplementation(() => true); + render( + + } + /> + ); + const comboBox: any = screen.getByRole('combobox'); + expect(comboBox).toBeDefined(); + }); +}); diff --git a/src/staticAutocomplete/index.ts b/src/staticAutocomplete/index.ts new file mode 100644 index 00000000..f017ff13 --- /dev/null +++ b/src/staticAutocomplete/index.ts @@ -0,0 +1 @@ +export * from './StaticAutocomplete'; diff --git a/stories/StaticAutoComplete/ClassBased.stories.js b/stories/StaticAutoComplete/ClassBased.stories.js new file mode 100644 index 00000000..88bbee2d --- /dev/null +++ b/stories/StaticAutoComplete/ClassBased.stories.js @@ -0,0 +1,434 @@ +import { StaticAutocomplete } from '../../src/staticAutocomplete'; +import './style.css'; + +/** + * In this section we're using the StaticAutocomplete component providing the GovUk style passing the relative `className`. Feel free to use your own css and style the formInput as you prefer + */ +export default { + title: 'DCXLibrary/Form/StaticAutocomplete/Class based', + component: StaticAutocomplete, + parameters: { + options: { + showPanel: true, + }, + }, + tags: ['autodocs'], +}; + +const options = [ + 'Afghanistan', + 'Albania', + 'Algeria', + 'Andorra', + 'Angola', + 'Anguilla', + 'Antigua & Barbuda', + 'Argentina', + 'Armenia', + 'Aruba', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Bahamas', + 'Bahrain', + 'Bangladesh', + 'Barbados', + 'Belarus', + 'Belgium', + 'Belize', + 'Benin', + 'Bermuda', + 'Bhutan', + 'Bolivia', + 'Bosnia & Herzegovina', + 'Botswana', + 'Brazil', + 'British Virgin Islands', + 'Brunei', + 'Bulgaria', + 'Burkina Faso', + 'Burundi', + 'Cambodia', + 'Cameroon', + 'Cape Verde', + 'Cayman Islands', + 'Chad', + 'Chile', + 'China', + 'Colombia', + 'Congo', + 'Cook Islands', + 'Costa Rica', + 'Cote D Ivoire', + 'Croatia', + 'Cruise Ship', + 'Cuba', + 'Cyprus', + 'Czech Republic', + 'Denmark', + 'Djibouti', + 'Dominica', + 'Dominican Republic', + 'Ecuador', + 'Egypt', + 'El Salvador', + 'Equatorial Guinea', + 'Estonia', + 'Ethiopia', + 'Falkland Islands', + 'Faroe Islands', + 'Fiji', + 'Finland', + 'France', + 'French Polynesia', + 'French West Indies', + 'Gabon', + 'Gambia', + 'Georgia', + 'Germany', + 'Ghana', + 'Gibraltar', + 'Greece', + 'Greenland', + 'Grenada', + 'Guam', + 'Guatemala', + 'Guernsey', + 'Guinea', + 'Guinea Bissau', + 'Guyana', + 'Haiti', + 'Honduras', + 'Hong Kong', + 'Hungary', + 'Iceland', + 'India', + 'Indonesia', + 'Iran', + 'Iraq', + 'Ireland', + 'Isle of Man', + 'Israel', + 'Italy', + 'Jamaica', + 'Japan', + 'Jersey', + 'Jordan', + 'Kazakhstan', + 'Kenya', + 'Kuwait', + 'Kyrgyz Republic', + 'Laos', + 'Latvia', + 'Lebanon', + 'Lesotho', + 'Liberia', + 'Libya', + 'Liechtenstein', + 'Lithuania', + 'Luxembourg', + 'Macau', + 'Macedonia', + 'Madagascar', + 'Malawi', + 'Malaysia', + 'Maldives', + 'Mali', + 'Malta', + 'Mauritania', + 'Mauritius', + 'Mexico', + 'Moldova', + 'Monaco', + 'Mongolia', + 'Montenegro', + 'Montserrat', + 'Morocco', + 'Mozambique', + 'Namibia', + 'Nepal', + 'Netherlands', + 'Netherlands Antilles', + 'New Caledonia', + 'New Zealand', + 'Nicaragua', + 'Niger', + 'Nigeria', + 'Norway', + 'Oman', + 'Pakistan', + 'Palestine', + 'Panama', + 'Papua New Guinea', + 'Paraguay', + 'Peru', + 'Philippines', + 'Poland', + 'Portugal', + 'Puerto Rico', + 'Qatar', + 'Reunion', + 'Romania', + 'Russia', + 'Rwanda', + 'Saint Pierre & Miquelon', + 'Samoa', + 'San Marino', + 'Satellite', + 'Saudi Arabia', + 'Senegal', + 'Serbia', + 'Seychelles', + 'Sierra Leone', + 'Singapore', + 'Slovakia', + 'Slovenia', + 'South Africa', + 'South Korea', + 'Spain', + 'Sri Lanka', + 'St Kitts & Nevis', + 'St Lucia', + 'St Vincent', + 'St. Lucia', + 'Sudan', + 'Suriname', + 'Swaziland', + 'Sweden', + 'Switzerland', + 'Syria', + 'Taiwan', + 'Tajikistan', + 'Tanzania', + 'Thailand', + "Timor L'Este", + 'Togo', + 'Tonga', + 'Trinidad & Tobago', + 'Tunisia', + 'Turkey', + 'Turkmenistan', + 'Turks & Caicos', + 'Uganda', + 'Ukraine', + 'United Arab Emirates', + 'United Kingdom', + 'Uruguay', + 'Uzbekistan', + 'Venezuela', + 'Vietnam', + 'Virgin Islands (US)', + 'Yemen', + 'Zambia', + 'Zimbabwe', +]; + +export const Basic = { + name: 'Basic', + args: { + id: 'basic', + options: options, + minCharsBeforeSearch: 1, + debounceMs: 100, + containerClassName: 'govuk-form-group', + labelProps: 'govuk-label', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClass: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +export const Hint = { + name: 'Hint', + args: { + options: options, + minCharsBeforeSearch: 1, + debounceMs: 100, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClass: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +/** + * Before the StaticAutocomplete appear you need to type at least 2 character + */ +export const MinChars = { + name: 'MinChars', + args: { + options: options, + minCharsBeforeSearch: 2, + debounceMs: 100, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClass: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +export const Defaultvalue = { + name: 'Default value', + args: { + options: options, + minCharsBeforeSearch: 2, + debounceMs: 100, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClass: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + defaultValue: 'United Kingdom', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +export const NoResult = { + name: 'No result found', + args: { + options: options, + minCharsBeforeSearch: 1, + debounceMs: 100, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + defaultValue: 'United Kingdom', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClass: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +export const Placeholder = { + name: 'Placeholder', + args: { + options: options, + minCharsBeforeSearch: 1, + debounceMs: 100, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + inputProps: { + className: 'govuk-input', + placeholder: 'Search for a country', + }, + resultUlClass: 'autocomplete__menu', + resultlLiClass: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +export const WithLabel = { + name: 'With label', + args: { + options: options, + minCharsBeforeSearch: 1, + debounceMs: 100, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClass: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + labelText: 'Search for a country', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +export const WithError = { + name: 'With error', + render: function ({ onClick, ...args }) { + return ( +
    + + + + ); + }, + args: { + options: options, + minCharsBeforeSearch: 1, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClas: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + errorPostion: 'after-hint', + errorMessageText: 'an error occured', + errorMessageClassName: 'govuk-error-message', + labelText: 'Search for a country', + errorVisuallyHidden: { + text: 'Error:', + className: 'govuk-visually-hidden', + }, + id: 'country-search', + resultActiveClass: 'autocomplete__option--focused', + }, +}; + +export const WithCustomSearch = { + name: 'With custom search function', + render: function ({ onClick, ...args }) { + const handleSearch = (value, options) => { + return options + .filter((optionsName) => + optionsName.toLowerCase().includes(value.toLowerCase()) + ) + .sort((a, b) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }); + }; + return ( +
    + + + + ); + }, + args: { + options: options, + minCharsBeforeSearch: 1, + hintText: 'Select your country', + hintClass: 'autocomplete__label', + inputProps: { className: 'govuk-input' }, + resultUlClass: 'autocomplete__menu', + resultlLiClas: 'autocomplete__option', + resultNoOptionClass: 'resultNoOptionClass', + notFoundText: 'No country found', + errorPostion: 'after-hint', + errorMessageText: 'an error occured', + errorMessageClassName: 'govuk-error-message', + labelText: 'Search for a country', + errorVisuallyHidden: { + text: 'Error:', + className: 'govuk-visually-hidden', + }, + id: 'country-search', + resultActiveClass: 'autocomplete__option--focused', + }, +}; diff --git a/stories/StaticAutoComplete/Documentation.mdx b/stories/StaticAutoComplete/Documentation.mdx new file mode 100644 index 00000000..25e2b4b7 --- /dev/null +++ b/stories/StaticAutoComplete/Documentation.mdx @@ -0,0 +1,182 @@ +import { Meta, Story, Canvas, ArgTypes } from '@storybook/addon-docs'; +import * as StaticAutocompleteStories from './UnStyled.stories'; + + + +# StaticAutocomplete + +An Input autocomplete component ready to use in your form. StaticAutocomplete is UI/UX agnostic so you need to provide your style to have the look and feel you prefer. +The staticAutocomplete is a progressive component that will automatically detect if your browser supports javascript or not. +If javascript is disabled: + +- it will render as an select (default behaviour) +- if the _customNonJSComp_ attribute is passed, it will display whatever you prefer based on your requirements (input, etc). + +When you import the staticAutocomplete component without providing any className or style associated will looks as following: + + + +An example with all the available properties is: + +```js + change(length, option, position)} + accessibilityStatus={status} + accessibilityHintText="When autocomplete results are available use up and down arrows to + review and enter to select. Touch device users, explore by touch or + with swipe gestures." + customNonJSComp={ + + } +/> +``` + +# StaticAutocomplete with Dynamic values (options) + +StaticAutocomplete will work with dynamic values in case you need to provide the results from an exteral source like a server API. +The usage is quite similar to the previous documentation but you'll use the method **search** to populate dyanically the **options** list + +```js +export const StaticAutocompleteDemo = () => { + const [selected, setSelected] = React.useState(''); + const handleSelected = (value: string) => setSelected(value); + + const [serverOptions, setServerOptions] = React.useState([]); + + const handleInputChange = (value: string, _options: string[]) => { + let result: string[] = [] + switch (value) { + case 'p': + result = [ + 'Papaya', + 'Persimmon', + 'Paw Paw', + 'Prickly Pear', + 'Peach', + 'Pomegranate', + 'Pineapple', + ]; + break; + case 'pe': + result = ['Persimmon', 'Peach']; + break; + case 'per': + result = ['Persimmon']; + break; + default: + result = ['no results']; + } + + setServerOptions(result); + return result; + }; + + return ( + + ); +}; +``` + + +# Accessible StaticAutocomplete + +To make the staticAutocomplete fully accessible you will need to add 3 properties and a function to handle the changes. +The properties are: + +- accessibilityStatus - A value to display to the users the current state of the staticAutocomplete, this is used for accessibility and is displayed in a hidden element above the input. + This should be used to display information such as how may results are being shown on screen, which element has been highlighted and what position it is in the reuslt list +- accessibilityHintText - Gives the user a generic view of how to use the component +- statusUpdate - When the user types or makes an action, it returns information abuot the staticAutocomplete so the accessibilityStatus attribute can be updated with updated information. + After this function is called you can update the accessibilityStatus attribute for example, + `"${length} result${length > 1 ? 's are' : ' is'} available. ${option} ${position} of ${length} is highlighted"` + Which to the user would look something like "7 Results are available. Papaya 1 of 7 is highlighted" + +An example of how to handle the statusUpdate function would be: + +```js +const [status, setStatus] = React.useState(''); +const change = (length: number, option: string, position: number) => { + let newText = ''; + if (length === 0) { + newText = 'No search results'; + } else if (length > 0) { + newText = `${length} result${length > 1 ? 's are' : ' is'} available. ${option} ${position} of ${length} is highlighted`; + } + setStatus(newText); +}; +``` + +By default the status has to be blank, this is to avoid confusion when the user either selects an item, deletes their previously typed text or when the component first loads. +Each propery coming back gives an insight into the state of the staticAutocomplete. Length is how many results are available for the user to see after typing and any filtering has been done, +property is the currently highlighted property in the staticAutocomplete. The user can change this by using the up or down arrow keys and position is where the currently highlighted property is located in the reslt list. + +# StaticAutocomplete with customNonJSComp + +When this property is passed the StaticAutocomplete component will NOT display the select by default but +will render whatever custom component is passed. This is only applicable when javascript is disabled. + +An example of how to pass value to customNonJSComp attribute. In this example a form input will be rendered when javascript is turned off. + +```js + + } +/> +``` + + diff --git a/stories/StaticAutoComplete/Live.stories.js b/stories/StaticAutoComplete/Live.stories.js new file mode 100644 index 00000000..1ab42159 --- /dev/null +++ b/stories/StaticAutoComplete/Live.stories.js @@ -0,0 +1,23 @@ +import { StaticAutocomplete } from '../../src/staticAutocomplete'; +import StaticAutocompleteLive from '../liveEdit/StaticAutocompleteLive'; + +export default { + title: 'DCXLibrary/Form/StaticAutocomplete/Live', + component: StaticAutocomplete, + + parameters: { + options: { + showPanel: false, + }, + viewMode: 'docs', + previewTabs: { + canvas: { + hidden: true, + }, + }, + }, +}; + +export const Live = { + render: () => , +}; diff --git a/stories/StaticAutoComplete/UnStyled.stories.js b/stories/StaticAutoComplete/UnStyled.stories.js new file mode 100644 index 00000000..9f2eb4f5 --- /dev/null +++ b/stories/StaticAutoComplete/UnStyled.stories.js @@ -0,0 +1,38 @@ +import { StaticAutocomplete } from '../../src/staticAutocomplete'; +/** + * The list of available options are: + * ['Papaya','Persimmon','Paw Paw','Prickly Pear','Peach',Pomegranate',Pineapple'] + */ +export default { + title: 'DCXLibrary/Form/StaticAutocomplete/Without style', + component: StaticAutocomplete, + parameters: { + options: { + showPanel: true, + }, + }, +}; + +export const Unstyled = { + args: { + options: [ + 'Papaya', + 'Persimmon', + 'Paw Paw', + 'Prickly Pear', + 'Peach', + 'Pomegranate', + 'Pineapple', + ], + defaultValue: 'Papaya', + minCharsBeforeSearch: 1, + debounceMs: 100, + hintText: 'search the list of fruits', + hintClass: 'hintClass', + resultUlClass: 'resultUlClass', + resultlLiClass: 'resultlLiClass', + resultNoOptionClass: 'resultNoOptionClass', + resultActiveClass: 'resultActiveClass', + notFoundText: 'No fruit found', + }, +}; diff --git a/stories/StaticAutoComplete/style.css b/stories/StaticAutoComplete/style.css new file mode 100644 index 00000000..6d1ed8e9 --- /dev/null +++ b/stories/StaticAutoComplete/style.css @@ -0,0 +1,8 @@ +.prompt { + position: absolute; + top: 100%; + z-index: 10; + background-color: #cccccc; + color: #000000; + padding: 0.3rem; +} diff --git a/stories/liveEdit/StaticAutocompleteLive.tsx b/stories/liveEdit/StaticAutocompleteLive.tsx new file mode 100644 index 00000000..8bd018ac --- /dev/null +++ b/stories/liveEdit/StaticAutocompleteLive.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live'; +import { StaticAutocomplete } from '../../src/staticAutocomplete'; + +const StaticAutocompleteDemo = ` +function StaticAutocompleteDemo() { + + const handleSelected = value => { + console.log(value) + } + + const handleSearch = (value, options) => { + return options + .filter(optionsName => optionsName.toLowerCase().includes(value.toLowerCase())) + .sort((a, b) => { + if (a < b) { return -1; } + if (a > b) { return 1; } + return 0 + }); + } + + const [status, setStatus] = React.useState(''); + const change = (length: number, property: string, position: number) => { + let newText = ''; + if (length === 0) { + newText = 'No search results'; + } else if (length > 0) { + newText = \`\${length} result\${length > 1 ? 's are' : ' is'} available. \${property} \${position} of \${length} is highlighted\`; + } + setStatus(newText); + }; + + return ( + + }} + suffix={{ + properties: {}, + content: <> + }} + resultUlClass="" + resultlLiClass="" + resultNoOptionClass="" + resultActiveClass="" + notFoundText="No fruit found" + onSelected={handleSelected} + containerClassName="fruit" + labelText="" + labelClassName="" + labelProps={{id:'labelid'}} + id="fruitTest" + errorPosition='below' + errorMessageText="" + errorMessageClassName="" + errorId="" + errorMessageVisuallyHidden={{ + text: "", + className: "", + }} + name="" + inputProps={{}} + selectProps={{}} + tabIndex={0} + search={handleSearch} + statusUpdate={(length, property, position) => + change(length, property, position) + } + accessibilityStatus={status} + accessibilityHintText="When autocomplete results are available use up and down arrows to + review and enter to select. Touch device users, explore by touch or + with swipe gestures." + /> + ) +} +`.trim(); + +const StaticAutocompleteLive = () => { + const scope = { StaticAutocomplete }; + return ( + +
    + + +
    + +
    + ); +}; + +export default StaticAutocompleteLive;