Skip to content
Open

Develop #1181

Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<your_account>` with your Github username in the [DEMO LINK](https://<your_account>.github.io/react_autocomplete/) and add it to the PR description.
- Replace `<your_account>` 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
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 19 additions & 60 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<Person | null>(null);

const handleSelect = useCallback((person: Person) => {
setSelectedPerson(person);
}, []);

return (
<div className="container">
<main className="section is-flex is-flex-direction-column">
<h1 className="title" data-cy="title">
{`${name} (${born} - ${died})`}
{selectedPerson
? `${selectedPerson.name} (${selectedPerson.born} - ${selectedPerson.died})`
: 'No selected person'}
</h1>

<div className="dropdown is-active">
<div className="dropdown-trigger">
<input
type="text"
placeholder="Enter a part of the name"
className="input"
data-cy="search-input"
/>
</div>

<div className="dropdown-menu" role="menu" data-cy="suggestions-list">
<div className="dropdown-content">
<div className="dropdown-item" data-cy="suggestion-item">
<p className="has-text-link">Pieter Haverbeke</p>
</div>

<div className="dropdown-item" data-cy="suggestion-item">
<p className="has-text-link">Pieter Bernard Haverbeke</p>
</div>

<div className="dropdown-item" data-cy="suggestion-item">
<p className="has-text-link">Pieter Antone Haverbeke</p>
</div>

<div className="dropdown-item" data-cy="suggestion-item">
<p className="has-text-danger">Elisabeth Haverbeke</p>
</div>

<div className="dropdown-item" data-cy="suggestion-item">
<p className="has-text-link">Pieter de Decker</p>
</div>

<div className="dropdown-item" data-cy="suggestion-item">
<p className="has-text-danger">Petronella de Decker</p>
</div>

<div className="dropdown-item" data-cy="suggestion-item">
<p className="has-text-danger">Elisabeth Hercke</p>
</div>
</div>
</div>
</div>

<div
className="
notification
is-danger
is-light
mt-3
is-align-self-flex-start
"
role="alert"
data-cy="no-suggestions-message"
>
<p className="has-text-danger">No matching suggestions</p>
</div>
<Autocomplete
people={peopleFromServer}
onSelect={handleSelect}
onChange={() => {
setSelectedPerson(null);
}}
delay={300}
/>
</main>
</div>
);
Expand Down
130 changes: 130 additions & 0 deletions src/components/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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<Props> = 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<number>(-1);

const applyQuery = useMemo(() => {
return debounce((value: string) => {
setAppliedQuery(value);
}, delay);
}, [delay]);

const handleQueryChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
applyQuery(event.target.value);
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<HTMLInputElement>) => {
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 (
<div className={cn('dropdown', { 'is-active': isOpen })}>
<div className="dropdown-trigger">
<input
type="text"
placeholder="Enter a part of the name"
className="input"
value={query}
onChange={handleQueryChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
aria-expanded={isOpen}
aria-controls="suggestions-list"
/>
</div>

{isOpen && (
<div
id="suggestions-list"
className="dropdown-menu"
role="listbox"
data-cy="suggestions-list"
>
<div className="dropdown-content">
{suggestions.map((person, index) => (
<div
key={`${person.slug}-${index}`}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This violates the checklist item 'don't generate key on render'. The key ${person.slug}-${index} includes index which changes when items reorder, forcing React to re-render the DOM node instead of moving it. Use person.slug directly as the key since it's a stable identifier from the data.

role="option"
aria-selected={highlightIndex === index}
className={cn('dropdown-item', {
'is-active': highlightIndex === index,
})}
onClick={() => handleSelect(person)}
data-cy="suggestion-item"
>
<p className="has-text-link">{person.name}</p>
</div>
))}
</div>
</div>
)}

{suggestions.length === 0 && query.trim() !== '' && (
<div
className="notification is-danger is-light mt-3"
role="alert"
data-cy="no-suggestions-message"
>
<p className="has-text-danger">No matching suggestions</p>
</div>
)}
</div>
);
});
Loading