feat(base): keyboard navigation for person cell dropdown

This commit is contained in:
Philipinho
2026-04-18 14:52:09 +01:00
parent f8edb587e4
commit 2ca27f16a1
@@ -8,6 +8,7 @@ import {
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query"; import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/base/styles/cells.module.css"; import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
type CellPersonProps = { type CellPersonProps = {
value: unknown; value: unknown;
@@ -60,6 +61,8 @@ export function CellPerson({
) )
: members; : members;
const nav = useListKeyboardNav(filteredMembers.length, [search, isEditing]);
const handleSelect = useCallback( const handleSelect = useCallback(
(memberId: string) => { (memberId: string) => {
if (allowMultiple) { if (allowMultiple) {
@@ -99,13 +102,21 @@ export function CellPerson({
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onCancel(); onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex < 0 || nav.activeIndex >= filteredMembers.length) return;
e.preventDefault();
handleSelect(filteredMembers[nav.activeIndex].id);
return;
} }
if (e.key === "Backspace" && search === "" && personIds.length > 0) { if (e.key === "Backspace" && search === "" && personIds.length > 0) {
e.preventDefault(); e.preventDefault();
handleRemove(personIds[personIds.length - 1]); handleRemove(personIds[personIds.length - 1]);
} }
}, },
[onCancel, search, personIds, handleRemove], [onCancel, nav, filteredMembers, handleSelect, search, personIds, handleRemove],
); );
const selectedSet = new Set(personIds); const selectedSet = new Set(personIds);
@@ -170,25 +181,40 @@ export function CellPerson({
</div> </div>
)} )}
<div className={cellClasses.selectDropdown}> <div className={cellClasses.selectDropdown}>
{filteredMembers.map((member) => ( {filteredMembers.map((member, idx) => {
<div const isSelected = selectedSet.has(member.id);
key={member.id} const isKeyboardActive = idx === nav.activeIndex;
className={`${cellClasses.selectOption} ${ const className = [
selectedSet.has(member.id) ? cellClasses.selectOptionActive : "" cellClasses.selectOption,
}`} isSelected ? cellClasses.selectOptionActive : "",
onClick={() => handleSelect(member.id)} isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
> ]
<CustomAvatar .filter(Boolean)
avatarUrl={member.avatarUrl} .join(" ");
name={member.name} return (
size={24} <div
radius="xl" key={member.id}
/> ref={nav.setOptionRef(idx)}
<span className={cellClasses.personOptionName}> className={className}
{member.name} onMouseEnter={() => nav.setActiveIndex(idx)}
</span> onMouseDown={(e) => {
</div> // Keep focus on the search input so click doesn't blur + close popover.
))} e.preventDefault();
}}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={24}
radius="xl"
/>
<span className={cellClasses.personOptionName}>
{member.name}
</span>
</div>
);
})}
{filteredMembers.length === 0 && ( {filteredMembers.length === 0 && (
<div className={cellClasses.personDropdownHint}> <div className={cellClasses.personDropdownHint}>
No members found No members found