mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(base): keyboard navigation for single-select cell dropdown
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user