Files
readest/apps/readest-app/src/components/command-palette/CommandPalette.tsx
T

316 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import clsx from 'clsx';
import React, { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { FiSearch } from 'react-icons/fi';
import { MdClose } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useCommandPalette } from './CommandPaletteProvider';
import { CommandSearchResult, getCategoryLabel, CommandCategory } from '@/services/commandRegistry';
import HighlightChars from './HighlightChars';
const CommandPalette: React.FC = () => {
const _ = useTranslation();
const { isOpen, close, query, setQuery, results, groupedResults, recentItems, executeCommand } =
useCommandPalette();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
// flatten results for keyboard navigation
const flattenedResults = useMemo(() => {
if (query.trim()) {
return results;
}
// show recent items when no query
return recentItems.map((item) => ({
item,
score: 0,
positions: new Set<number>(),
highlightIndices: new Set<number>(),
}));
}, [query, results, recentItems]);
// reset selection when results change - legitimate derived state reset
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- resetting UI state on data change is intended
setSelectedIndex(0);
}, [flattenedResults.length]);
// focus input and reset selection on open
useEffect(() => {
if (isOpen) {
previousActiveElementRef.current = document.activeElement as HTMLElement;
// eslint-disable-next-line react-hooks/set-state-in-effect -- resetting UI state on modal open is intended
setSelectedIndex(0);
setTimeout(() => {
inputRef.current?.focus();
}, 50);
} else {
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
previousActiveElementRef.current = null;
}
}
}, [isOpen]);
// scroll selected item into view
useEffect(() => {
if (!listRef.current) return;
const selectedItem = listRef.current.querySelector('[data-selected="true"]');
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, flattenedResults.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
{
const selected = flattenedResults[selectedIndex];
if (selected) {
executeCommand(selected.item);
}
}
break;
case 'Escape':
e.preventDefault();
close();
break;
case 'Tab':
// trap focus within the palette
e.preventDefault();
break;
}
},
[flattenedResults, selectedIndex, executeCommand, close],
);
const handleItemClick = useCallback(
(result: CommandSearchResult) => {
executeCommand(result.item);
},
[executeCommand],
);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
close();
}
},
[close],
);
if (!isOpen) return null;
const showEmptyState = query.trim() && flattenedResults.length === 0;
const showRecentState = !query.trim() && recentItems.length > 0;
const showNoRecentState = !query.trim() && recentItems.length === 0;
// group results by category for display
const orderedCategories: CommandCategory[] = ['settings', 'actions', 'navigation'];
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className='fixed inset-0 z-[100] flex items-start justify-center bg-black/50 pt-[15vh]'
onClick={handleBackdropClick}
>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
className='bg-base-100 mx-4 w-full max-w-lg overflow-hidden rounded-xl shadow-2xl'
role='dialog'
aria-modal='true'
aria-label={_('Command Palette')}
onKeyDown={handleKeyDown}
>
{/* search input */}
<div className='border-base-300 flex items-center border-b px-4'>
<FiSearch className='text-base-content/50 mr-3 h-5 w-5 shrink-0' />
<input
ref={inputRef}
type='text'
className='placeholder:text-base-content/50 h-12 w-full bg-transparent text-base outline-none'
placeholder={_('Search settings and actions...')}
value={query}
onChange={(e) => setQuery(e.target.value)}
spellCheck={false}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
/>
{query && (
<button
className='btn btn-ghost btn-circle btn-sm ml-2'
onClick={() => setQuery('')}
aria-label={_('Clear search')}
>
<MdClose className='h-4 w-4' />
</button>
)}
</div>
{/* results list */}
<div ref={listRef} className='max-h-80 overflow-y-auto p-2' role='listbox'>
{showEmptyState && (
<div className='text-base-content/60 px-4 py-8 text-center text-sm'>
{_('No results found for')} &quot;{query}&quot;
</div>
)}
{showNoRecentState && (
<div className='text-base-content/60 px-4 py-8 text-center text-sm'>
{_('Type to search settings and actions')}
</div>
)}
{showRecentState && (
<div className='mb-1'>
<div className='text-base-content/50 px-3 py-1.5 text-xs font-medium uppercase'>
{_('Recent')}
</div>
{flattenedResults.map((result, index) => (
<CommandResultItem
key={result.item.id}
result={result}
isSelected={index === selectedIndex}
onClick={() => handleItemClick(result)}
onMouseEnter={() => setSelectedIndex(index)}
_={_}
/>
))}
</div>
)}
{query.trim() && flattenedResults.length > 0 && (
<>
{orderedCategories.map((category) => {
const categoryResults = groupedResults[category];
if (categoryResults.length === 0) return null;
const startIndex = flattenedResults.findIndex((r) => r.item.category === category);
return (
<div key={category} className='mb-1'>
<div className='text-base-content/50 px-3 py-1.5 text-xs font-medium uppercase'>
{getCategoryLabel(_, category)}
</div>
{categoryResults.map((result, catIndex) => {
const globalIndex = startIndex + catIndex;
return (
<CommandResultItem
key={result.item.id}
result={result}
isSelected={globalIndex === selectedIndex}
onClick={() => handleItemClick(result)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
_={_}
/>
);
})}
</div>
);
})}
</>
)}
</div>
{/* footer with keyboard hints */}
<div className='border-base-300 text-base-content/50 flex items-center justify-between border-t px-4 py-2 text-xs'>
<div className='flex items-center gap-3'>
<span>
<kbd className='kbd kbd-xs'>↑↓</kbd> {_('navigate')}
</span>
<span>
<kbd className='kbd kbd-xs'></kbd> {_('select')}
</span>
<span>
<kbd className='kbd kbd-xs'>esc</kbd> {_('close')}
</span>
</div>
</div>
</div>
</div>
);
};
interface CommandResultItemProps {
result: CommandSearchResult;
isSelected: boolean;
onClick: () => void;
onMouseEnter: () => void;
_: (key: string) => string;
}
const CommandResultItem: React.FC<CommandResultItemProps> = ({
result,
isSelected,
onClick,
onMouseEnter,
_,
}) => {
const { item, highlightIndices, matchContext } = result;
const Icon = item.icon;
return (
<button
className={clsx(
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors',
isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-base-200',
)}
onClick={onClick}
onMouseEnter={onMouseEnter}
role='option'
aria-selected={isSelected}
data-selected={isSelected}
>
{Icon && (
<div className='text-base-content/60 flex h-8 w-8 shrink-0 items-center justify-center'>
<Icon className='h-5 w-5' />
</div>
)}
{!Icon && <div className='w-8 shrink-0' />}
<div className='min-w-0 flex-1'>
<div className='truncate text-sm'>
<HighlightChars str={_(item.labelKey)} indices={highlightIndices} />
</div>
{(item.section || matchContext) && (
<div className='text-base-content/50 truncate text-xs'>
{item.panel && <span>{_(item.panelLabel ?? item.panel)}</span>}
{item.panel && item.section && <span> </span>}
{item.section && <span>{_(item.section)}</span>}
{matchContext &&
matchContext !== item.section &&
matchContext !== item.panelLabel &&
matchContext !== item.panel && <span className='ml-2 italic'>({matchContext})</span>}
</div>
)}
</div>
{item.shortcut && item.shortcut.length > 0 && (
<div className='text-base-content/40 ml-auto shrink-0 text-xs'>
{item.shortcut.map((key, i) => (
<kbd key={i} className='kbd kbd-xs ml-1'>
{key}
</kbd>
))}
</div>
)}
</button>
);
};
export default CommandPalette;