Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
4b500fe864 feat(views): auto-highlight first result when searching in picker 2026-04-13 18:30:16 +08:00
Jiang Bohan
10cae3274c fix(views): hide Unassigned option when search filter is active
When the user types a search query in the assignee picker, the
Unassigned option is no longer pinned at the top — it only shows
when there is no active filter.
2026-04-13 18:23:18 +08:00
Jiang Bohan
75cad59c49 feat(views): add keyboard navigation and auto-select to PropertyPicker
Add arrow key (up/down) navigation and Enter key selection to the
searchable PropertyPicker dropdown. When the search narrows results to
a single match, pressing Enter auto-selects it without needing to
arrow-navigate first. Fixes GitHub issue #793.
2026-04-13 18:13:31 +08:00
2 changed files with 74 additions and 13 deletions

View File

@@ -107,17 +107,19 @@ export function AssigneePicker({
)
}
>
{/* Unassigned option */}
<PickerItem
selected={!assigneeType && !assigneeId}
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
setOpen(false);
}}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</PickerItem>
{/* Unassigned option — hidden when search is active */}
{!query && (
<PickerItem
selected={!assigneeType && !assigneeId}
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
setOpen(false);
}}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</PickerItem>
)}
{/* Members */}
{filteredMembers.length > 0 && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { Check } from "lucide-react";
import {
Popover,
@@ -8,6 +8,9 @@ import {
PopoverContent,
} from "@multica/ui/components/ui/popover";
const HIGHLIGHT_CLASS = "bg-accent";
const ITEM_SELECTOR = "button[data-picker-item]:not(:disabled)";
// ---------------------------------------------------------------------------
// PropertyPicker — generic Popover shell with optional search
// ---------------------------------------------------------------------------
@@ -36,18 +39,71 @@ export function PropertyPicker({
children: React.ReactNode;
}) {
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const listRef = useRef<HTMLDivElement>(null);
const getItems = useCallback(() => {
if (!listRef.current) return [];
return Array.from(
listRef.current.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR),
);
}, []);
// Apply/remove highlight class via DOM when index changes
useEffect(() => {
const items = getItems();
for (const item of items) {
item.classList.remove(HIGHLIGHT_CLASS);
}
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.classList.add(HIGHLIGHT_CLASS);
}
}, [highlightedIndex, getItems, children]); // re-run when children change (filtered list updates)
const handleOpenChange = useCallback(
(v: boolean) => {
onOpenChange(v);
if (!v) {
setQuery("");
setHighlightedIndex(-1);
onSearchChange?.("");
}
},
[onOpenChange, onSearchChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const items = getItems();
if (items.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev < items.length - 1 ? prev + 1 : 0;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev > 0 ? prev - 1 : items.length - 1;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
} else if (e.key === "Enter") {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.click();
} else if (items.length === 1) {
// Auto-select when only one result
items[0]?.click();
}
}
},
[getItems, highlightedIndex],
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
@@ -64,15 +120,17 @@ export function PropertyPicker({
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
onSearchChange?.(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
aria-label="Filter options"
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
)}
<div className="p-1 max-h-60 overflow-y-auto">{children}</div>
<div ref={listRef} className="p-1 max-h-60 overflow-y-auto">{children}</div>
</PopoverContent>
</Popover>
);
@@ -98,6 +156,7 @@ export function PickerItem({
return (
<button
type="button"
data-picker-item
disabled={disabled}
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${disabled ? "opacity-50 cursor-not-allowed" : hoverClassName ?? "hover:bg-accent"} transition-colors`}