A multi-select keeps one selected collection and derives the searchable options that remain. Build the skills picker in React so users can filter, add, and remove chips with a mouse or keyboard without creating duplicate state.
Implement the App component in App.tsx. It receives no props and renders the provided eight skills with React and TypeScript selected initially.
Vue, Angular, Svelte, Node, GraphQL, CSS in source order.g leaves Angular and GraphQL; clicking GraphQL adds its chip, clears the query, and restores all remaining options.Backspace removes the last chip. Removing any chip returns that skill to its original source-order position.ArrowDown / ArrowUp wrap through filtered options; Enter selects the active option and Escape clears the query and highlight.selected, query, and activeIndex; derive available and filtered options during render.aria-controls, and point aria-activedescendant at the highlighted option. Every remove button needs its skill in the accessible name.You keep selection as state, derive every visible option, and let the input retain focus while aria-activedescendant names the keyboard highlight.
Chips and options look like two collections, but storing both creates two versions of the same fact. The complete behavior also shares one filtered list across mouse selection, arrow navigation, Enter, and screen-reader output. Derive that list once from selected and query.
Every skill from ALL is in exactly one place: selected skills render as chips; everything else can enter the option list. The query narrows only that derived option side. Removing a chip changes selected, so the skill naturally returns in ALL order.
const [selected, setSelected] = useState(['React', 'TypeScript']);
const [available, setAvailable] = useState(['Vue', 'Angular', 'Svelte']);
function add(option: string) {
setSelected([...selected, option]);
setAvailable(available.filter((item) => item !== option));
}
This makes every action coordinate two state updates. Removal has to reconstruct the original ordering, filtering introduces a third list, and a missed update creates duplicates. It also gives the keyboard handler a stale list unless every branch updates all three.
import { useRef, useState } from 'react';
import './styles.css';
const ALL = ['React', 'Vue', 'Angular', 'Svelte', 'Node', 'TypeScript', 'GraphQL', 'CSS'];
const optionId = (option: string) => `skill-option-${option.toLowerCase()}`;
export default function App() {
const [selected, setSelected] = useState(['React', 'TypeScript']);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const normalized = query.trim().toLowerCase();
const filtered = ALL.filter(
(option) =>
!selected.includes(option) &&
option.toLowerCase().includes(normalized),
);
const activeId = activeIndex >= 0 ? optionId(filtered[activeIndex]) : undefined;
function focusInput() {
requestAnimationFrame(() => inputRef.current?.focus());
}
function add(option: string) {
setSelected((current) =>
current.includes(option) ? current : [...current, option],
);
setQuery('');
setActiveIndex(-1);
focusInput();
}
function remove(option: string) {
setSelected((current) => current.filter((item) => item !== option));
setActiveIndex(-1);
focusInput();
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'ArrowDown' && filtered.length) {
event.preventDefault();
setActiveIndex((index) => (index + 1) % filtered.length);
} else if (event.key === 'ArrowUp' && filtered.length) {
event.preventDefault();
setActiveIndex((index) => (index <= 0 ? filtered.length - 1 : index - 1));
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
add(filtered[activeIndex]);
} else if (event.key === 'Backspace' && query === '' && selected.length) {
remove(selected[selected.length - 1]);
} else if (event.key === 'Escape') {
setQuery('');
setActiveIndex(-1);
}
}
return (
<main className="container">
<h1>Skills</h1>
<p className="hint">Choose the tools you use.</p>
<div className="control">
{selected.map((option) => (
<span className="chip" key={option}>
{option}
<button type="button" aria-label={`Remove ${option}`} onClick={() => remove(option)}>×</button>
</span>
))}
<input
ref={inputRef}
className="input"
value={query}
placeholder="Add a skill…"
aria-label="Add a skill"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="skill-options"
aria-activedescendant={activeId}
onChange={(event) => {
setQuery(event.target.value);
setActiveIndex(-1);
}}
onKeyDown={onKeyDown}
/>
</div>
<ul id="skill-options" className="options" role="listbox" aria-label="Available skills">
{filtered.map((option, index) => (
<li
id={optionId(option)}
className={`option${index === activeIndex ? ' active' : ''}`}
role="option"
aria-selected="false"
key={option}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => add(option)}
>{option}</li>
))}
{!filtered.length && <li className="empty">No matching skills</li>}
</ul>
<p className="help">↑↓ navigate · Enter select · Backspace remove</p>
<p className="sr-only" aria-live="polite">
{filtered.length} skills available. {selected.length} selected.
</p>
</main>
);
}
selected, query, and activeIndex are the only stored values. filtered always starts from ALL, excludes selected skills, and applies the query, so click and keyboard selection consume the same ordering. Functional selection updates prevent stale closures, while the duplicate guard makes add safe from any caller.
Type g: the normalized query is g, so filtered becomes ['Angular', 'GraphQL'] and the highlight resets. ArrowDown selects index 0; another ArrowDown selects index 1 and points aria-activedescendant at skill-option-graphql. Enter appends GraphQL, clears the query and highlight, and restores input focus.
available in state — removing a chip must then guess where to reinsert it. Filter ALL so source order restores itself.aria-activedescendant.options, value, and onChange props while preserving the same derived-state rules.A custom hook owns the three state values and derives its option list on every render. The view stays declarative and both pointer and keyboard paths call the same add and remove commands.
import { useRef, useState } from 'react';
import './styles.css';
const ALL = ['React', 'Vue', 'Angular', 'Svelte', 'Node', 'TypeScript', 'GraphQL', 'CSS'];
const optionId = (option: string) => `skill-option-${option.toLowerCase()}`;
function useSkillPicker() {
const [selected, setSelected] = useState(['React', 'TypeScript']);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const normalized = query.trim().toLowerCase();
const filtered = ALL.filter((option) => !selected.includes(option) && option.toLowerCase().includes(normalized));
const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus());
const add = (option: string) => {
setSelected((current) => current.includes(option) ? current : [...current, option]);
setQuery(''); setActiveIndex(-1); focusInput();
};
const remove = (option: string) => {
setSelected((current) => current.filter((item) => item !== option));
setActiveIndex(-1); focusInput();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' && filtered.length) { event.preventDefault(); setActiveIndex((index) => (index + 1) % filtered.length); }
else if (event.key === 'ArrowUp' && filtered.length) { event.preventDefault(); setActiveIndex((index) => index <= 0 ? filtered.length - 1 : index - 1); }
else if (event.key === 'Enter' && activeIndex >= 0) { event.preventDefault(); add(filtered[activeIndex]); }
else if (event.key === 'Backspace' && query === '' && selected.length) remove(selected[selected.length - 1]);
else if (event.key === 'Escape') { setQuery(''); setActiveIndex(-1); }
};
return { selected, query, setQuery, activeIndex, setActiveIndex, filtered, inputRef, add, remove, onKeyDown };
}
export default function App() {
const picker = useSkillPicker();
const activeId = picker.activeIndex >= 0 ? optionId(picker.filtered[picker.activeIndex]) : undefined;
return <main className="container">
<h1>Skills</h1><p className="hint">Choose the tools you use.</p>
<div className="control">
{picker.selected.map((option) => <span className="chip" key={option}>{option}<button type="button" aria-label={`Remove ${option}`} onClick={() => picker.remove(option)}>×</button></span>)}
<input ref={picker.inputRef} className="input" value={picker.query} placeholder="Add a skill…" aria-label="Add a skill" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="skill-options" aria-activedescendant={activeId} onChange={(event) => { picker.setQuery(event.target.value); picker.setActiveIndex(-1); }} onKeyDown={picker.onKeyDown} />
</div>
<ul id="skill-options" className="options" role="listbox" aria-label="Available skills">
{picker.filtered.map((option, index) => <li id={optionId(option)} className={`option${index === picker.activeIndex ? ' active' : ''}`} role="option" aria-selected="false" key={option} onMouseEnter={() => picker.setActiveIndex(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => picker.add(option)}>{option}</li>)}
{!picker.filtered.length && <li className="empty">No matching skills</li>}
</ul>
<p className="help">↑↓ navigate · Enter select · Backspace remove</p>
<p className="sr-only" aria-live="polite">{picker.filtered.length} skills available. {picker.selected.length} selected.</p>
</main>;
}A reducer makes query, highlight, selection, and removal transitions explicit. Available choices remain derived, so restoring a removed value still requires no insertion bookkeeping.
import { useReducer, useRef } from 'react';
import './styles.css';
const ALL = ['React', 'Vue', 'Angular', 'Svelte', 'Node', 'TypeScript', 'GraphQL', 'CSS'];
const optionId = (option: string) => `skill-option-${option.toLowerCase()}`;
type State = { selected: string[]; query: string; activeIndex: number };
type Action = { type: 'query'; value: string } | { type: 'active'; value: number } | { type: 'add'; value: string } | { type: 'remove'; value: string } | { type: 'clear' };
function reducer(state: State, action: Action): State {
if (action.type === 'query') return { ...state, query: action.value, activeIndex: -1 };
if (action.type === 'active') return { ...state, activeIndex: action.value };
if (action.type === 'add') return { selected: state.selected.includes(action.value) ? state.selected : [...state.selected, action.value], query: '', activeIndex: -1 };
if (action.type === 'remove') return { ...state, selected: state.selected.filter((item) => item !== action.value), activeIndex: -1 };
return { ...state, query: '', activeIndex: -1 };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { selected: ['React', 'TypeScript'], query: '', activeIndex: -1 });
const inputRef = useRef<HTMLInputElement>(null);
const filtered = ALL.filter((option) => !state.selected.includes(option) && option.toLowerCase().includes(state.query.trim().toLowerCase()));
const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus());
const add = (option: string) => { dispatch({ type: 'add', value: option }); focusInput(); };
const remove = (option: string) => { dispatch({ type: 'remove', value: option }); focusInput(); };
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
let next = state.activeIndex;
if (event.key === 'ArrowDown' && filtered.length) next = (next + 1) % filtered.length;
else if (event.key === 'ArrowUp' && filtered.length) next = next <= 0 ? filtered.length - 1 : next - 1;
else if (event.key === 'Enter' && next >= 0) { event.preventDefault(); add(filtered[next]); return; }
else if (event.key === 'Backspace' && state.query === '' && state.selected.length) { remove(state.selected[state.selected.length - 1]); return; }
else if (event.key === 'Escape') { dispatch({ type: 'clear' }); return; }
else return;
event.preventDefault(); dispatch({ type: 'active', value: next });
};
const activeId = state.activeIndex >= 0 ? optionId(filtered[state.activeIndex]) : undefined;
return <main className="container">
<h1>Skills</h1><p className="hint">Choose the tools you use.</p>
<div className="control">
{state.selected.map((option) => <span className="chip" key={option}>{option}<button type="button" aria-label={`Remove ${option}`} onClick={() => remove(option)}>×</button></span>)}
<input ref={inputRef} className="input" value={state.query} placeholder="Add a skill…" aria-label="Add a skill" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="skill-options" aria-activedescendant={activeId} onChange={(event) => dispatch({ type: 'query', value: event.target.value })} onKeyDown={onKeyDown} />
</div>
<ul id="skill-options" className="options" role="listbox" aria-label="Available skills">
{filtered.map((option, index) => <li id={optionId(option)} className={`option${index === state.activeIndex ? ' active' : ''}`} role="option" aria-selected="false" key={option} onMouseEnter={() => dispatch({ type: 'active', value: index })} onMouseDown={(event) => event.preventDefault()} onClick={() => add(option)}>{option}</li>)}
{!filtered.length && <li className="empty">No matching skills</li>}
</ul>
<p className="help">↑↓ navigate · Enter select · Backspace remove</p>
<p className="sr-only" aria-live="polite">{filtered.length} skills available. {state.selected.length} selected.</p>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A multi-select keeps one selected collection and derives the searchable options that remain. Build the skills picker in React so users can filter, add, and remove chips with a mouse or keyboard without creating duplicate state.
Implement the App component in App.tsx. It receives no props and renders the provided eight skills with React and TypeScript selected initially.
Vue, Angular, Svelte, Node, GraphQL, CSS in source order.g leaves Angular and GraphQL; clicking GraphQL adds its chip, clears the query, and restores all remaining options.Backspace removes the last chip. Removing any chip returns that skill to its original source-order position.ArrowDown / ArrowUp wrap through filtered options; Enter selects the active option and Escape clears the query and highlight.selected, query, and activeIndex; derive available and filtered options during render.aria-controls, and point aria-activedescendant at the highlighted option. Every remove button needs its skill in the accessible name.You keep selection as state, derive every visible option, and let the input retain focus while aria-activedescendant names the keyboard highlight.
Chips and options look like two collections, but storing both creates two versions of the same fact. The complete behavior also shares one filtered list across mouse selection, arrow navigation, Enter, and screen-reader output. Derive that list once from selected and query.
Every skill from ALL is in exactly one place: selected skills render as chips; everything else can enter the option list. The query narrows only that derived option side. Removing a chip changes selected, so the skill naturally returns in ALL order.
const [selected, setSelected] = useState(['React', 'TypeScript']);
const [available, setAvailable] = useState(['Vue', 'Angular', 'Svelte']);
function add(option: string) {
setSelected([...selected, option]);
setAvailable(available.filter((item) => item !== option));
}
This makes every action coordinate two state updates. Removal has to reconstruct the original ordering, filtering introduces a third list, and a missed update creates duplicates. It also gives the keyboard handler a stale list unless every branch updates all three.
import { useRef, useState } from 'react';
import './styles.css';
const ALL = ['React', 'Vue', 'Angular', 'Svelte', 'Node', 'TypeScript', 'GraphQL', 'CSS'];
const optionId = (option: string) => `skill-option-${option.toLowerCase()}`;
export default function App() {
const [selected, setSelected] = useState(['React', 'TypeScript']);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const normalized = query.trim().toLowerCase();
const filtered = ALL.filter(
(option) =>
!selected.includes(option) &&
option.toLowerCase().includes(normalized),
);
const activeId = activeIndex >= 0 ? optionId(filtered[activeIndex]) : undefined;
function focusInput() {
requestAnimationFrame(() => inputRef.current?.focus());
}
function add(option: string) {
setSelected((current) =>
current.includes(option) ? current : [...current, option],
);
setQuery('');
setActiveIndex(-1);
focusInput();
}
function remove(option: string) {
setSelected((current) => current.filter((item) => item !== option));
setActiveIndex(-1);
focusInput();
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'ArrowDown' && filtered.length) {
event.preventDefault();
setActiveIndex((index) => (index + 1) % filtered.length);
} else if (event.key === 'ArrowUp' && filtered.length) {
event.preventDefault();
setActiveIndex((index) => (index <= 0 ? filtered.length - 1 : index - 1));
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
add(filtered[activeIndex]);
} else if (event.key === 'Backspace' && query === '' && selected.length) {
remove(selected[selected.length - 1]);
} else if (event.key === 'Escape') {
setQuery('');
setActiveIndex(-1);
}
}
return (
<main className="container">
<h1>Skills</h1>
<p className="hint">Choose the tools you use.</p>
<div className="control">
{selected.map((option) => (
<span className="chip" key={option}>
{option}
<button type="button" aria-label={`Remove ${option}`} onClick={() => remove(option)}>×</button>
</span>
))}
<input
ref={inputRef}
className="input"
value={query}
placeholder="Add a skill…"
aria-label="Add a skill"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="skill-options"
aria-activedescendant={activeId}
onChange={(event) => {
setQuery(event.target.value);
setActiveIndex(-1);
}}
onKeyDown={onKeyDown}
/>
</div>
<ul id="skill-options" className="options" role="listbox" aria-label="Available skills">
{filtered.map((option, index) => (
<li
id={optionId(option)}
className={`option${index === activeIndex ? ' active' : ''}`}
role="option"
aria-selected="false"
key={option}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => add(option)}
>{option}</li>
))}
{!filtered.length && <li className="empty">No matching skills</li>}
</ul>
<p className="help">↑↓ navigate · Enter select · Backspace remove</p>
<p className="sr-only" aria-live="polite">
{filtered.length} skills available. {selected.length} selected.
</p>
</main>
);
}
selected, query, and activeIndex are the only stored values. filtered always starts from ALL, excludes selected skills, and applies the query, so click and keyboard selection consume the same ordering. Functional selection updates prevent stale closures, while the duplicate guard makes add safe from any caller.
Type g: the normalized query is g, so filtered becomes ['Angular', 'GraphQL'] and the highlight resets. ArrowDown selects index 0; another ArrowDown selects index 1 and points aria-activedescendant at skill-option-graphql. Enter appends GraphQL, clears the query and highlight, and restores input focus.
available in state — removing a chip must then guess where to reinsert it. Filter ALL so source order restores itself.aria-activedescendant.options, value, and onChange props while preserving the same derived-state rules.A custom hook owns the three state values and derives its option list on every render. The view stays declarative and both pointer and keyboard paths call the same add and remove commands.
import { useRef, useState } from 'react';
import './styles.css';
const ALL = ['React', 'Vue', 'Angular', 'Svelte', 'Node', 'TypeScript', 'GraphQL', 'CSS'];
const optionId = (option: string) => `skill-option-${option.toLowerCase()}`;
function useSkillPicker() {
const [selected, setSelected] = useState(['React', 'TypeScript']);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const normalized = query.trim().toLowerCase();
const filtered = ALL.filter((option) => !selected.includes(option) && option.toLowerCase().includes(normalized));
const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus());
const add = (option: string) => {
setSelected((current) => current.includes(option) ? current : [...current, option]);
setQuery(''); setActiveIndex(-1); focusInput();
};
const remove = (option: string) => {
setSelected((current) => current.filter((item) => item !== option));
setActiveIndex(-1); focusInput();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' && filtered.length) { event.preventDefault(); setActiveIndex((index) => (index + 1) % filtered.length); }
else if (event.key === 'ArrowUp' && filtered.length) { event.preventDefault(); setActiveIndex((index) => index <= 0 ? filtered.length - 1 : index - 1); }
else if (event.key === 'Enter' && activeIndex >= 0) { event.preventDefault(); add(filtered[activeIndex]); }
else if (event.key === 'Backspace' && query === '' && selected.length) remove(selected[selected.length - 1]);
else if (event.key === 'Escape') { setQuery(''); setActiveIndex(-1); }
};
return { selected, query, setQuery, activeIndex, setActiveIndex, filtered, inputRef, add, remove, onKeyDown };
}
export default function App() {
const picker = useSkillPicker();
const activeId = picker.activeIndex >= 0 ? optionId(picker.filtered[picker.activeIndex]) : undefined;
return <main className="container">
<h1>Skills</h1><p className="hint">Choose the tools you use.</p>
<div className="control">
{picker.selected.map((option) => <span className="chip" key={option}>{option}<button type="button" aria-label={`Remove ${option}`} onClick={() => picker.remove(option)}>×</button></span>)}
<input ref={picker.inputRef} className="input" value={picker.query} placeholder="Add a skill…" aria-label="Add a skill" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="skill-options" aria-activedescendant={activeId} onChange={(event) => { picker.setQuery(event.target.value); picker.setActiveIndex(-1); }} onKeyDown={picker.onKeyDown} />
</div>
<ul id="skill-options" className="options" role="listbox" aria-label="Available skills">
{picker.filtered.map((option, index) => <li id={optionId(option)} className={`option${index === picker.activeIndex ? ' active' : ''}`} role="option" aria-selected="false" key={option} onMouseEnter={() => picker.setActiveIndex(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => picker.add(option)}>{option}</li>)}
{!picker.filtered.length && <li className="empty">No matching skills</li>}
</ul>
<p className="help">↑↓ navigate · Enter select · Backspace remove</p>
<p className="sr-only" aria-live="polite">{picker.filtered.length} skills available. {picker.selected.length} selected.</p>
</main>;
}A reducer makes query, highlight, selection, and removal transitions explicit. Available choices remain derived, so restoring a removed value still requires no insertion bookkeeping.
import { useReducer, useRef } from 'react';
import './styles.css';
const ALL = ['React', 'Vue', 'Angular', 'Svelte', 'Node', 'TypeScript', 'GraphQL', 'CSS'];
const optionId = (option: string) => `skill-option-${option.toLowerCase()}`;
type State = { selected: string[]; query: string; activeIndex: number };
type Action = { type: 'query'; value: string } | { type: 'active'; value: number } | { type: 'add'; value: string } | { type: 'remove'; value: string } | { type: 'clear' };
function reducer(state: State, action: Action): State {
if (action.type === 'query') return { ...state, query: action.value, activeIndex: -1 };
if (action.type === 'active') return { ...state, activeIndex: action.value };
if (action.type === 'add') return { selected: state.selected.includes(action.value) ? state.selected : [...state.selected, action.value], query: '', activeIndex: -1 };
if (action.type === 'remove') return { ...state, selected: state.selected.filter((item) => item !== action.value), activeIndex: -1 };
return { ...state, query: '', activeIndex: -1 };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { selected: ['React', 'TypeScript'], query: '', activeIndex: -1 });
const inputRef = useRef<HTMLInputElement>(null);
const filtered = ALL.filter((option) => !state.selected.includes(option) && option.toLowerCase().includes(state.query.trim().toLowerCase()));
const focusInput = () => requestAnimationFrame(() => inputRef.current?.focus());
const add = (option: string) => { dispatch({ type: 'add', value: option }); focusInput(); };
const remove = (option: string) => { dispatch({ type: 'remove', value: option }); focusInput(); };
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
let next = state.activeIndex;
if (event.key === 'ArrowDown' && filtered.length) next = (next + 1) % filtered.length;
else if (event.key === 'ArrowUp' && filtered.length) next = next <= 0 ? filtered.length - 1 : next - 1;
else if (event.key === 'Enter' && next >= 0) { event.preventDefault(); add(filtered[next]); return; }
else if (event.key === 'Backspace' && state.query === '' && state.selected.length) { remove(state.selected[state.selected.length - 1]); return; }
else if (event.key === 'Escape') { dispatch({ type: 'clear' }); return; }
else return;
event.preventDefault(); dispatch({ type: 'active', value: next });
};
const activeId = state.activeIndex >= 0 ? optionId(filtered[state.activeIndex]) : undefined;
return <main className="container">
<h1>Skills</h1><p className="hint">Choose the tools you use.</p>
<div className="control">
{state.selected.map((option) => <span className="chip" key={option}>{option}<button type="button" aria-label={`Remove ${option}`} onClick={() => remove(option)}>×</button></span>)}
<input ref={inputRef} className="input" value={state.query} placeholder="Add a skill…" aria-label="Add a skill" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="skill-options" aria-activedescendant={activeId} onChange={(event) => dispatch({ type: 'query', value: event.target.value })} onKeyDown={onKeyDown} />
</div>
<ul id="skill-options" className="options" role="listbox" aria-label="Available skills">
{filtered.map((option, index) => <li id={optionId(option)} className={`option${index === state.activeIndex ? ' active' : ''}`} role="option" aria-selected="false" key={option} onMouseEnter={() => dispatch({ type: 'active', value: index })} onMouseDown={(event) => event.preventDefault()} onClick={() => add(option)}>{option}</li>)}
{!filtered.length && <li className="empty">No matching skills</li>}
</ul>
<p className="help">↑↓ navigate · Enter select · Backspace remove</p>
<p className="sr-only" aria-live="polite">{filtered.length} skills available. {state.selected.length} selected.</p>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.