From 836a25cdbf434c86afa2ff5388473ced4fe152d2 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:03:04 +0100 Subject: [PATCH] feat(base): keyboard navigation for multi-select cell dropdown --- .../components/cells/cell-multi-select.tsx | 90 ++++++++++++++----- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/apps/client/src/features/base/components/cells/cell-multi-select.tsx b/apps/client/src/features/base/components/cells/cell-multi-select.tsx index 95e9d951..74fb3c37 100644 --- a/apps/client/src/features/base/components/cells/cell-multi-select.tsx +++ b/apps/client/src/features/base/components/cells/cell-multi-select.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { Popover, TextInput } from "@mantine/core"; +import clsx from "clsx"; import { IBaseProperty, SelectTypeOptions, @@ -9,12 +10,17 @@ import { choiceColor } from "@/features/base/components/cells/choice-color"; import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query"; import { v7 as uuid7 } from "uuid"; import cellClasses from "@/features/base/styles/cells.module.css"; +import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav"; const CHOICE_COLORS = [ "gray", "red", "pink", "grape", "violet", "indigo", "blue", "cyan", "teal", "green", "lime", "yellow", "orange", ]; +type NavItem = + | { kind: "choice"; choice: Choice } + | { kind: "add" }; + type CellMultiSelectProps = { value: unknown; property: IBaseProperty; @@ -78,6 +84,17 @@ export function CellMultiSelect({ [choices.length], ); + const navItems = useMemo( + () => [ + ...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(() => { if (!trimmedSearch) return; const newChoice: Choice = { @@ -104,18 +121,30 @@ export function CellMultiSelect({ if (e.key === "Escape") { e.preventDefault(); onCancel(); + return; } - if (e.key === "Enter" && showAddOption) { - e.preventDefault(); - handleAddOption(); + if (handleNavKey(e)) return; + if (e.key === "Enter") { + if (activeIndex >= 0 && activeIndex < navItems.length) { + e.preventDefault(); + const item = navItems[activeIndex]; + if (item.kind === "choice") handleToggle(item.choice); + else handleAddOption(); + return; + } + if (showAddOption) { + e.preventDefault(); + handleAddOption(); + } } }, - [onCancel, showAddOption, handleAddOption], + [onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption], ); const MAX_VISIBLE = 3; if (isEditing) { + const addOptionIdx = filteredChoices.length; return (
- {filteredChoices.map((choice) => ( -
handleToggle(choice)} - > - { + const isSelected = selectedSet.has(choice.id); + return ( +
setActiveIndex(idx)} + onMouseDown={(e) => { + // Keep focus on the search input so click doesn't blur + close popover. + e.preventDefault(); + }} + onClick={() => handleToggle(choice)} > - {choice.name} - -
- ))} + + {choice.name} + +
+ ); + })} {showAddOption && (
setActiveIndex(addOptionIdx)} + onMouseDown={(e) => { + e.preventDefault(); + }} onClick={handleAddOption} > Add option: