feat(base): keyboard navigation for single-select cell dropdown

This commit is contained in:
Philipinho
2026-04-18 14:58:19 +01:00
parent 4cefa40f5b
commit bb398bb7d6
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core"; import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import { import {
IBaseProperty, IBaseProperty,
SelectTypeOptions, SelectTypeOptions,
@@ -9,6 +10,7 @@ import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query"; import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
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";
const CHOICE_COLORS = [ const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo", "gray", "red", "pink", "grape", "violet", "indigo",
@@ -75,6 +77,21 @@ export function CellSelect({
[choices.length], [choices.length],
); );
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
const handleAddOption = useCallback(() => { const handleAddOption = useCallback(() => {
if (!trimmedSearch) return; if (!trimmedSearch) return;
const newChoice: Choice = { const newChoice: Choice = {
@@ -100,13 +117,24 @@ export function CellSelect({
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onCancel(); onCancel();
return;
} }
if (e.key === "Enter" && showAddOption) { if (handleNavKey(e)) return;
e.preventDefault(); if (e.key === "Enter") {
handleAddOption(); if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") handleSelect(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
} }
}, },
[onCancel, showAddOption, handleAddOption], [onCancel, handleNavKey, activeIndex, navItems, handleSelect, handleAddOption, showAddOption],
); );
if (isEditing) { if (isEditing) {
@@ -143,36 +171,58 @@ export function CellSelect({
mb={4} mb={4}
/> />
<div className={cellClasses.selectDropdown}> <div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice) => ( {filteredChoices.map((choice, idx) => {
<div const isSelected = choice.id === selectedId;
key={choice.id} return (
className={`${cellClasses.selectOption} ${ <div
choice.id === selectedId ? cellClasses.selectOptionActive : "" key={choice.id}
}`} ref={setOptionRef(idx)}
onClick={() => handleSelect(choice)} className={clsx(
> cellClasses.selectOption,
<span isSelected && cellClasses.selectOptionActive,
className={cellClasses.badge} idx === activeIndex && cellClasses.selectOptionKeyboardActive,
style={choiceColor(choice.color)} )}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
> >
{choice.name} <span
</span> className={cellClasses.badge}
</div> style={choiceColor(choice.color)}
))} >
{showAddOption && ( {choice.name}
<div </span>
className={cellClasses.addOptionRow} </div>
onClick={handleAddOption} );
> })}
<span className={cellClasses.addOptionLabel}>Add option:</span> {showAddOption && (() => {
<span const idx = filteredChoices.length;
className={cellClasses.badge} return (
style={choiceColor(addOptionColor)} <div
ref={setOptionRef(idx)}
className={clsx(
cellClasses.addOptionRow,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
> >
{trimmedSearch} <span className={cellClasses.addOptionLabel}>Add option:</span>
</span> <span
</div> className={cellClasses.badge}
)} style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
);
})()}
</div> </div>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>