From b73b2601c09c959906197191576f81e57d793189 Mon Sep 17 00:00:00 2001 From: Artem <139005232+CyborgNinjaHat@users.noreply.github.com> Date: Tue, 12 May 2026 17:08:08 +0000 Subject: [PATCH] feat: implement autocomplete component --- src/App.tsx | 77 ++++------------ src/components/Alert/Alert.tsx | 19 ++++ src/components/Alert/index.ts | 1 + src/components/Autocomplete/Autocomplete.tsx | 88 +++++++++++++++++++ src/components/Autocomplete/index.ts | 1 + .../AutocompleteInput/AutocompleteInput.tsx | 27 ++++++ src/components/AutocompleteInput/index.ts | 1 + .../AutocompleteList/AutocompleteList.tsx | 29 ++++++ src/components/AutocompleteList/index.ts | 1 + .../AutocompleteListItem.tsx | 31 +++++++ src/components/AutocompleteListItem/index.ts | 1 + src/types/Nullable.ts | 1 + src/types/Person.ts | 6 +- 13 files changed, 220 insertions(+), 63 deletions(-) create mode 100644 src/components/Alert/Alert.tsx create mode 100644 src/components/Alert/index.ts create mode 100644 src/components/Autocomplete/Autocomplete.tsx create mode 100644 src/components/Autocomplete/index.ts create mode 100644 src/components/AutocompleteInput/AutocompleteInput.tsx create mode 100644 src/components/AutocompleteInput/index.ts create mode 100644 src/components/AutocompleteList/AutocompleteList.tsx create mode 100644 src/components/AutocompleteList/index.ts create mode 100644 src/components/AutocompleteListItem/AutocompleteListItem.tsx create mode 100644 src/components/AutocompleteListItem/index.ts create mode 100644 src/types/Nullable.ts diff --git a/src/App.tsx b/src/App.tsx index a88cd7a6d..6dc06ef16 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,73 +1,28 @@ -import React from 'react'; -import './App.scss'; +import { useState } from 'react'; +import { Autocomplete } from './components/Autocomplete'; +import { Nullable } from './types/Nullable'; +import { Person } from './types/Person'; import { peopleFromServer } from './data/people'; +import './App.scss'; + +export const App = () => { + const [selectedPerson, setSelectedPerson] = useState>(null); -export const App: React.FC = () => { - const { name, born, died } = peopleFromServer[0]; + const title = selectedPerson + ? `${selectedPerson.name} (${selectedPerson.born} - ${selectedPerson.died})` + : 'No selected person'; return (

- {`${name} (${born} - ${died})`} + {title}

-
-
- -
- -
-
-
-

Pieter Haverbeke

-
- -
-

Pieter Bernard Haverbeke

-
- -
-

Pieter Antone Haverbeke

-
- -
-

Elisabeth Haverbeke

-
- -
-

Pieter de Decker

-
- -
-

Petronella de Decker

-
- -
-

Elisabeth Hercke

-
-
-
-
- -
-

No matching suggestions

-
+
); diff --git a/src/components/Alert/Alert.tsx b/src/components/Alert/Alert.tsx new file mode 100644 index 000000000..3be484c3d --- /dev/null +++ b/src/components/Alert/Alert.tsx @@ -0,0 +1,19 @@ +interface Props { + message: string; +} + +export const Alert = ({ message }: Props) => ( +
+

{message}

+
+); diff --git a/src/components/Alert/index.ts b/src/components/Alert/index.ts new file mode 100644 index 000000000..79e3b155f --- /dev/null +++ b/src/components/Alert/index.ts @@ -0,0 +1 @@ +export * from './Alert'; diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 000000000..fb13e9415 --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import debounce from 'lodash.debounce'; +import { AutocompleteInput } from '../AutocompleteInput'; +import { AutocompleteList } from '../AutocompleteList'; +import { Alert } from '../Alert'; +import { Nullable } from '../../types/Nullable'; +import { Person } from '../../types/Person'; + +const filterPeople = (people: Person[], query: string) => { + const normalizedQuery = query.trim().toLowerCase(); + + if (normalizedQuery !== '') { + return people.filter(person => + person.name.toLowerCase().includes(normalizedQuery), + ); + } + + return people; +}; + +interface Props { + people: Person[]; + onSelected: (person: Nullable) => void; + delay?: number; +} + +export const Autocomplete = ({ people, onSelected, delay = 300 }: Props) => { + const [query, setQuery] = useState(''); + const [appliedQuery, setAppliedQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + const applyQuery = useRef( + debounce((value: string) => setAppliedQuery(value), delay), + ).current; + + useEffect(() => () => applyQuery.cancel(), [applyQuery]); + + const handleQueryChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value.trimStart().replace(/\s+/g, ' '); + + setQuery(value); + applyQuery.cancel(); + applyQuery(value); + onSelected(null); + }, + [applyQuery, onSelected], + ); + + const handlePersonSelect = useCallback( + (person: Person) => { + setQuery(person.name); + setAppliedQuery(person.name); + setIsOpen(false); + onSelected(person); + }, + [onSelected], + ); + + const handleFocus = useCallback(() => setIsOpen(true), []); + const handleBlur = useCallback(() => setIsOpen(false), []); + + const filteredPeople = useMemo( + () => filterPeople(people, appliedQuery), + [people, appliedQuery], + ); + + return ( +
+ + {isOpen && + (filteredPeople.length > 0 ? ( + + ) : ( + + ))} +
+ ); +}; diff --git a/src/components/Autocomplete/index.ts b/src/components/Autocomplete/index.ts new file mode 100644 index 000000000..a796c54c6 --- /dev/null +++ b/src/components/Autocomplete/index.ts @@ -0,0 +1 @@ +export * from './Autocomplete'; diff --git a/src/components/AutocompleteInput/AutocompleteInput.tsx b/src/components/AutocompleteInput/AutocompleteInput.tsx new file mode 100644 index 000000000..6a021004d --- /dev/null +++ b/src/components/AutocompleteInput/AutocompleteInput.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react'; + +interface Props { + query: string; + onQueryChange: (event: React.ChangeEvent) => void; + onFocus: () => void; + onBlur: () => void; +} + +export const AutocompleteInput = memo( + ({ query, onQueryChange, onFocus, onBlur }: Props) => ( +
+ +
+ ), +); + +AutocompleteInput.displayName = 'AutocompleteInput'; diff --git a/src/components/AutocompleteInput/index.ts b/src/components/AutocompleteInput/index.ts new file mode 100644 index 000000000..cbbda0910 --- /dev/null +++ b/src/components/AutocompleteInput/index.ts @@ -0,0 +1 @@ +export * from './AutocompleteInput'; diff --git a/src/components/AutocompleteList/AutocompleteList.tsx b/src/components/AutocompleteList/AutocompleteList.tsx new file mode 100644 index 000000000..d45093fbe --- /dev/null +++ b/src/components/AutocompleteList/AutocompleteList.tsx @@ -0,0 +1,29 @@ +import { memo } from 'react'; +import { AutocompleteListItem } from '../AutocompleteListItem'; +import { Person } from '../../types/Person'; + +interface Props { + people: Person[]; + onSelected: (person: Person) => void; +} + +export const AutocompleteList = memo(({ people, onSelected }: Props) => { + return ( +
+
+ {people.map(person => ( + + ))} +
+
+ ); +}); + +AutocompleteList.displayName = 'AutocompleteList'; diff --git a/src/components/AutocompleteList/index.ts b/src/components/AutocompleteList/index.ts new file mode 100644 index 000000000..235b6b758 --- /dev/null +++ b/src/components/AutocompleteList/index.ts @@ -0,0 +1 @@ +export * from './AutocompleteList'; diff --git a/src/components/AutocompleteListItem/AutocompleteListItem.tsx b/src/components/AutocompleteListItem/AutocompleteListItem.tsx new file mode 100644 index 000000000..fc2f34889 --- /dev/null +++ b/src/components/AutocompleteListItem/AutocompleteListItem.tsx @@ -0,0 +1,31 @@ +import { memo } from 'react'; +import classNames from 'classnames'; +import { Person } from '../../types/Person'; + +interface Props { + person: Person; + onSelected: (person: Person) => void; +} + +export const AutocompleteListItem = memo(({ person, onSelected }: Props) => { + const { name, sex } = person; + + return ( +
onSelected(person)} + > +

+ {name} +

+
+ ); +}); + +AutocompleteListItem.displayName = 'AutocompleteListItem'; diff --git a/src/components/AutocompleteListItem/index.ts b/src/components/AutocompleteListItem/index.ts new file mode 100644 index 000000000..324d916b4 --- /dev/null +++ b/src/components/AutocompleteListItem/index.ts @@ -0,0 +1 @@ +export * from './AutocompleteListItem'; diff --git a/src/types/Nullable.ts b/src/types/Nullable.ts new file mode 100644 index 000000000..aa32b2dec --- /dev/null +++ b/src/types/Nullable.ts @@ -0,0 +1 @@ +export type Nullable = T | null; diff --git a/src/types/Person.ts b/src/types/Person.ts index e00ba55a2..703efa833 100644 --- a/src/types/Person.ts +++ b/src/types/Person.ts @@ -1,9 +1,11 @@ +import { Nullable } from './Nullable'; + export interface Person { name: string; sex: 'm' | 'f'; born: number; died: number; - fatherName: string | null; - motherName: string | null; + fatherName: Nullable; + motherName: Nullable; slug: string; }