From bf6a1e967dcf9048b1253600872abb5e2ba5734f Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Tue, 15 Jul 2025 18:49:14 +0200 Subject: [PATCH] feat: add keyboard navigation and global typing to StatusSelectorGroupedByTag --- .../StatusSelectorGroupedByTag.tsx | 105 +++++++++++++++++- components/atoms/StatusSelector.tsx | 15 ++- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/components/ChangeStatusModal/StatusSelectorGroupedByTag.tsx b/components/ChangeStatusModal/StatusSelectorGroupedByTag.tsx index 34ca831..3cab589 100644 --- a/components/ChangeStatusModal/StatusSelectorGroupedByTag.tsx +++ b/components/ChangeStatusModal/StatusSelectorGroupedByTag.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { NoteStatus } from "@/types/noteStatus"; import { SearchFilter } from "../atoms/SearchFilter"; import { StatusChip } from "../atoms/StatusChip"; @@ -23,6 +23,9 @@ export const StatusSelectorGroupedByTag: React.FC = ({ onSelectedState, }) => { const [searchFilter, setSearchFilter] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(-1); + const containerRef = useRef(null); + const searchRef = useRef(null); const filteredStatuses = searchFilter ? availableStatuses.filter((status) => @@ -38,9 +41,105 @@ export const StatusSelectorGroupedByTag: React.FC = ({ onSelectedState(frontmatterTagName, status, "select"); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev < filteredStatuses.length - 1 ? prev + 1 : 0, + ); + } + break; + case "ArrowUp": + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev > 0 ? prev - 1 : filteredStatuses.length - 1, + ); + } + break; + case "Tab": + if (!e.shiftKey) { + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev < filteredStatuses.length - 1 ? prev + 1 : 0, + ); + } + } else { + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev > 0 ? prev - 1 : filteredStatuses.length - 1, + ); + } + } + break; + case "Enter": + if ( + focusedIndex >= 0 && + focusedIndex < filteredStatuses.length + ) { + e.preventDefault(); + const status = filteredStatuses[focusedIndex]; + const isSelected = currentStatuses.some( + (s) => s.name === status.name, + ); + if (isSelected) { + handleRemoveStatus(status); + } else { + handleSelectStatus(status); + } + } + break; + case "Backspace": + e.preventDefault(); + setSearchFilter((prev) => prev.slice(0, -1)); + if (searchRef.current) { + searchRef.current.focus(); + } + break; + case "Escape": + e.preventDefault(); + setSearchFilter(""); + break; + default: + if ( + e.key.length === 1 && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + e.preventDefault(); + setSearchFilter((prev) => prev + e.key); + if (searchRef.current) { + searchRef.current.focus(); + } + } + break; + } + }; + + useEffect(() => { + setFocusedIndex(filteredStatuses.length > 0 ? 0 : -1); + }, [searchFilter, filteredStatuses.length]); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.focus(); + } + }, []); + return ( -
+
setSearchFilter(value)} /> @@ -62,6 +161,7 @@ export const StatusSelectorGroupedByTag: React.FC = ({ selected ? handleSelectStatus(status) @@ -70,7 +170,6 @@ export const StatusSelectorGroupedByTag: React.FC = ({ /> )} -
void; } export const StatusModalOption: React.FC = ({ status, isSelected, + isFocused, onSelect, }) => { const [isHovered, setIsHovered] = useState(false); @@ -39,9 +42,13 @@ export const StatusModalOption: React.FC = ({ borderBottom: "1px solid var(--background-modifier-border)", transition: "background-color 150ms ease", background: - isSelected || isHovered + isSelected || isHovered || isFocused ? "var(--background-modifier-hover)" : "", + outline: isFocused + ? "2px solid var(--interactive-accent)" + : "none", + outlineOffset: "-2px", }} > = ({ export interface Props { currentStatuses: NoteStatus[]; availableStatuses: NoteStatus[]; + focusedIndex?: number; onToggleStatus: (status: NoteStatus, selected: boolean) => void; } export const StatusSelector: React.FC = ({ currentStatuses, availableStatuses, + focusedIndex = -1, onToggleStatus, }) => { const handleSelectStatus = async (status: NoteStatus) => { @@ -104,7 +113,6 @@ export const StatusSelector: React.FC = ({ onToggleStatus(status, !selected); }; - // TODO: The StatusSelector must be splitted by its template return (
= ({ background: "var(--background-primary)", }} > - {availableStatuses.map((status) => ( + {availableStatuses.map((status, index) => ( = ({ (s) => s.name === status.name, ) !== -1 } + isFocused={index === focusedIndex} onSelect={() => handleSelectStatus(status)} /> ))}