diff --git a/README.md b/README.md index b8897503d..399be8f75 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ that will suggest people matching an entered text. - when the selected person is displayed in the title, but the value in the input changes, the selected person should be cleared and `No selected person` should be shown. ## Instructions + - Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_autocomplete/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://protocolo23.github.io/react_autocomplete/) and add it to the PR description. - Don't remove the `data-qa` attributes. It is required for tests. ## Troubleshooting diff --git a/package-lock.json b/package-lock.json index a57a24cb7..cceb15e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "devDependencies": { "@cypress/react18": "^2.0.1", "@faker-js/faker": "^8.4.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/lodash.debounce": "^4.0.9", @@ -1189,10 +1189,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index cda47c54b..2ae5e8de2 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@cypress/react18": "^2.0.1", "@faker-js/faker": "^8.4.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/lodash.debounce": "^4.0.9", diff --git a/src/App.tsx b/src/App.tsx index a88cd7a6d..731a0db35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,73 +1,32 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import './App.scss'; import { peopleFromServer } from './data/people'; +import { Autocomplete } from './components/Autocomplete/Autocomplete'; +import { Person } from './types/Person'; export const App: React.FC = () => { - const { name, born, died } = peopleFromServer[0]; + const [selectedPerson, setSelectedPerson] = useState(null); + + const handleSelect = useCallback((person: Person) => { + setSelectedPerson(person); + }, []); return (

- {`${name} (${born} - ${died})`} + {selectedPerson + ? `${selectedPerson.name} (${selectedPerson.born} - ${selectedPerson.died})` + : 'No selected person'}

- -
-
- -
- -
-
-
-

Pieter Haverbeke

-
- -
-

Pieter Bernard Haverbeke

-
- -
-

Pieter Antone Haverbeke

-
- -
-

Elisabeth Haverbeke

-
- -
-

Pieter de Decker

-
- -
-

Petronella de Decker

-
- -
-

Elisabeth Hercke

-
-
-
-
- -
-

No matching suggestions

-
+ { + setSelectedPerson(null); + }} + delay={300} + />
); diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 000000000..a571a8086 --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,139 @@ +import cn from 'classnames'; +import debounce from 'lodash.debounce'; +import React, { useCallback, useMemo, useState } from 'react'; +import { Person } from '../../types/Person'; + +type Props = { + people: Person[]; + onSelect: (person: Person) => void; + onChange: () => void; + delay?: number; +}; + +export const Autocomplete: React.FC = React.memo(function Autocomplete({ + people, + onSelect, + onChange, + delay = 300, +}) { + const [query, setQuery] = useState(''); + const [appliedQuery, setAppliedQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [highlightIndex, setHighlightIndex] = useState(-1); + + const applyQuery = useMemo(() => { + return debounce((value: string) => { + setAppliedQuery(value); + }, delay); + }, [delay]); + + const handleQueryChange = useCallback( + (event: React.ChangeEvent) => { + const input = event.target.value; + + setQuery(input); + + if (!input.trim()) { + return; + } + + applyQuery(input); + onChange(); + setIsOpen(true); + setHighlightIndex(-1); + }, + [applyQuery, onChange], + ); + + const suggestions = useMemo(() => { + if (!appliedQuery.trim()) { + return people; + } + + return people.filter(p => + p.name.toLowerCase().includes(appliedQuery.toLowerCase()), + ); + }, [appliedQuery, people]); + + const handleSelect = useCallback( + (person: Person) => { + setQuery(person.name); + setAppliedQuery(person.name); + setIsOpen(false); + onSelect(person); + }, + [onSelect], + ); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isOpen || suggestions.length === 0) { + return; + } + + if (event.key === 'ArrowDown') { + setHighlightIndex(prev => (prev + 1) % suggestions.length); + } else if (event.key === 'ArrowUp') { + setHighlightIndex(prev => + prev <= 0 ? suggestions.length - 1 : prev - 1, + ); + } else if (event.key === 'Enter' && highlightIndex >= 0) { + handleSelect(suggestions[highlightIndex]); + } + }; + + return ( +
+
+ setIsOpen(true)} + onKeyDown={handleKeyDown} + aria-expanded={isOpen} + aria-controls="suggestions-list" + data-cy="search-input" + /> +
+ + {isOpen && ( +
+
+ {suggestions.map((person, index) => ( +
handleSelect(person)} + data-cy="suggestion-item" + > +

{person.name}

+
+ ))} +
+
+ )} + + {suggestions.length === 0 && query.trim() !== '' && ( +
+

No matching suggestions

+
+ )} +
+ ); +});