From bb398bb7d67d09c46c2557bc0a34a3afe4ff7e1c Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:58:19 +0100 Subject: [PATCH] feat(base): keyboard navigation for single-select cell dropdown --- .../base/components/cells/cell-select.tsx | 114 +++++++++++++----- 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/apps/client/src/features/base/components/cells/cell-select.tsx b/apps/client/src/features/base/components/cells/cell-select.tsx index 77096c89..de765bc3 100644 --- a/apps/client/src/features/base/components/cells/cell-select.tsx +++ b/apps/client/src/features/base/components/cells/cell-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,6 +10,7 @@ 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", @@ -75,6 +77,21 @@ export function CellSelect({ [choices.length], ); + type NavItem = + | { kind: "choice"; choice: Choice } + | { kind: "add" }; + + 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 = { @@ -100,13 +117,24 @@ export function CellSelect({ 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") handleSelect(item.choice); + else handleAddOption(); + return; + } + if (showAddOption) { + e.preventDefault(); + handleAddOption(); + } } }, - [onCancel, showAddOption, handleAddOption], + [onCancel, handleNavKey, activeIndex, navItems, handleSelect, handleAddOption, showAddOption], ); if (isEditing) { @@ -143,36 +171,58 @@ export function CellSelect({ mb={4} />
- {filteredChoices.map((choice) => ( -
handleSelect(choice)} - > - { + const isSelected = choice.id === selectedId; + return ( +
setActiveIndex(idx)} + onMouseDown={(e) => { + // Keep focus on the search input so click doesn't blur + close popover. + e.preventDefault(); + }} + onClick={() => handleSelect(choice)} > - {choice.name} - -
- ))} - {showAddOption && ( -
- Add option: - + {choice.name} + +
+ ); + })} + {showAddOption && (() => { + const idx = filteredChoices.length; + return ( +
setActiveIndex(idx)} + onMouseDown={(e) => { + e.preventDefault(); + }} + onClick={handleAddOption} > - {trimmedSearch} - -
- )} + Add option: + + {trimmedSearch} + +
+ ); + })()}