feat: add keyboard navigation and global typing to StatusSelectorGroupedByTag

This commit is contained in:
Aleix Soler 2025-07-15 18:49:14 +02:00
parent c221d6b576
commit bf6a1e967d
2 changed files with 114 additions and 6 deletions

View file

@ -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<Props> = ({
onSelectedState,
}) => {
const [searchFilter, setSearchFilter] = useState("");
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filteredStatuses = searchFilter
? availableStatuses.filter((status) =>
@ -38,9 +41,105 @@ export const StatusSelectorGroupedByTag: React.FC<Props> = ({
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 (
<div>
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
style={{ outline: "none" }}
>
<SearchFilter
ref={searchRef}
value={searchFilter}
onFilterChange={(value) => setSearchFilter(value)}
/>
@ -62,6 +161,7 @@ export const StatusSelectorGroupedByTag: React.FC<Props> = ({
<StatusSelector
availableStatuses={filteredStatuses}
currentStatuses={currentStatuses}
focusedIndex={focusedIndex}
onToggleStatus={(status, selected) =>
selected
? handleSelectStatus(status)
@ -70,7 +170,6 @@ export const StatusSelectorGroupedByTag: React.FC<Props> = ({
/>
)}
</SettingItem>
<SettingItem name="Available statuses" vertical>
<div
className="note-status-chips"

View file

@ -1,14 +1,17 @@
import React, { useState } from "react";
import { NoteStatus } from "@/types/noteStatus";
interface StatusOptionProps {
status: NoteStatus;
isSelected: boolean;
isFocused: boolean;
onSelect: () => void;
}
export const StatusModalOption: React.FC<StatusOptionProps> = ({
status,
isSelected,
isFocused,
onSelect,
}) => {
const [isHovered, setIsHovered] = useState(false);
@ -39,9 +42,13 @@ export const StatusModalOption: React.FC<StatusOptionProps> = ({
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",
}}
>
<span
@ -90,12 +97,14 @@ export const StatusModalOption: React.FC<StatusOptionProps> = ({
export interface Props {
currentStatuses: NoteStatus[];
availableStatuses: NoteStatus[];
focusedIndex?: number;
onToggleStatus: (status: NoteStatus, selected: boolean) => void;
}
export const StatusSelector: React.FC<Props> = ({
currentStatuses,
availableStatuses,
focusedIndex = -1,
onToggleStatus,
}) => {
const handleSelectStatus = async (status: NoteStatus) => {
@ -104,7 +113,6 @@ export const StatusSelector: React.FC<Props> = ({
onToggleStatus(status, !selected);
};
// TODO: The StatusSelector must be splitted by its template
return (
<div
className="note-status-options"
@ -116,7 +124,7 @@ export const StatusSelector: React.FC<Props> = ({
background: "var(--background-primary)",
}}
>
{availableStatuses.map((status) => (
{availableStatuses.map((status, index) => (
<StatusModalOption
key={`${status.name}${status.description}${status.color}${status.icon}`}
status={status}
@ -125,6 +133,7 @@ export const StatusSelector: React.FC<Props> = ({
(s) => s.name === status.name,
) !== -1
}
isFocused={index === focusedIndex}
onSelect={() => handleSelectStatus(status)}
/>
))}