Skip to content
Open

start #1168

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
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
71 changes: 12 additions & 59 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,73 +1,26 @@
import React from 'react';
import React, { useState } from 'react';
import './App.scss';
import { peopleFromServer } from './data/people';
import { Person } from './types/Person';
import Autocomplete from './components/Autocomplete';

export const App: React.FC = () => {
const { name, born, died } = peopleFromServer[0];
const [selected, setSelected] = useState<Person | null>(null);

return (
<div className="container">
<main className="section is-flex is-flex-direction-column">
<h1 className="title" data-cy="title">
{`${name} (${born} - ${died})`}
{selected
? `${selected.name} (${selected.born} - ${selected.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}
debounceMs={300}
onSelected={p => setSelected(p)}
/>
</main>
</div>
);
Expand Down
156 changes: 156 additions & 0 deletions src/components/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import debounce from 'lodash.debounce';
import { Person } from '../types/Person';

type Props = {
people: Person[];
debounceMs?: number;
onSelected?: (person: Person | null) => void;
};

export const Autocomplete: React.FC<Props> = ({
people,
debounceMs = 300,
onSelected,
}) => {
const [inputValue, setInputValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [suggestions, setSuggestions] = useState<Person[]>([]);

// Keep last queried string to avoid re-filtering when identical
const lastQueryRef = useRef<string | null>(null);
// Track whether the last action was a selection (so we can avoid clearing it immediately)
const selectedNameRef = useRef<string | null>(null);

// Filtering function
const doFilter = useCallback(
(query: string) => {
const q = query.trim().toLowerCase();

if (lastQueryRef.current === q) {
return; // don't run again if query hasn't changed
}

lastQueryRef.current = q;

if (q === '') {
setSuggestions(people);

return;
}

const filtered = people.filter(p => p.name.toLowerCase().includes(q));

setSuggestions(filtered);
},
[people],
);

// Debounced version
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedFilter = useCallback(
debounce((q: string) => doFilter(q), debounceMs),
[doFilter, debounceMs],
);

useEffect(() => {
return () => {
debouncedFilter.cancel();
};
}, [debouncedFilter]);

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;

setInputValue(v);

// if there was a selected person and the user changed the input, clear selection
if (selectedNameRef.current && v !== selectedNameRef.current) {
selectedNameRef.current = null;
onSelected?.(null);
}

// open dropdown for new input
setIsOpen(true);

// Trigger debounce filtering
debouncedFilter(v);
};

const onFocus = () => {
// when focused and empty show all
setIsOpen(true);
if (inputValue.trim() === '') {
// show all immediately
lastQueryRef.current = '';
setSuggestions(people);
} else {
debouncedFilter(inputValue);
}
};

const onSuggestionClick = (p: Person) => {
setInputValue(p.name);
setIsOpen(false);
selectedNameRef.current = p.name;
lastQueryRef.current = p.name.trim().toLowerCase();
setSuggestions([]);
onSelected?.(p);
};

const onBlur = () => {
// Delay closing to allow click event on suggestion to fire
requestAnimationFrame(() => {
setIsOpen(false);
});
};

return (
<div className={`dropdown ${isOpen ? 'is-active' : ''}`}>
<div className="dropdown-trigger">
<input
type="text"
placeholder="Enter a part of the name"
className="input"
data-cy="search-input"
value={inputValue}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>

{isOpen && (
<div className="dropdown-menu" role="menu" data-cy="suggestions-list">
<div className="dropdown-content">
{suggestions.length > 0 ? (
suggestions.map(p => (
<div
key={p.slug}
className="dropdown-item"
data-cy="suggestion-item"
onMouseDown={ev => ev.preventDefault()}
onClick={() => onSuggestionClick(p)}
>
<p className="has-text-link">{p.name}</p>
</div>
))
) : (
<div className="dropdown-item">
<div
className="notification is-danger is-light"
role="alert"
data-cy="no-suggestions-message"
>
<p className="has-text-danger">No matching suggestions</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};

export default Autocomplete;
Loading