Better dropdown filtering

This commit is contained in:
Gregory Schier
2024-02-17 22:03:42 -08:00
parent e8fab85ce5
commit 20df2bf13a
4 changed files with 106 additions and 43 deletions

View File

@@ -23,11 +23,13 @@ import React, {
import { useKey, useWindowSize } from 'react-use';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKey } from '../../hooks/useHotKey';
import { getNodeText } from '../../lib/getNodeText';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { HotKey } from './HotKey';
import { Separator } from './Separator';
import { VStack } from './Stacks';
import { HStack, VStack } from './Stacks';
import { Icon } from './Icon';
export type DropdownItemSeparator = {
type: 'separator';
@@ -209,9 +211,10 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
}: MenuProps,
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
const [filter, setFilter] = useState<string>('');
const [containerWidth, setContainerWidth] = useState<number | null>(null);
// Calculate the max height so we can scroll
const initMenu = useCallback((el: HTMLDivElement | null) => {
@@ -224,20 +227,30 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const handleClose = useCallback(() => {
onClose();
setSelectedIndex(null);
setFilter('');
}, [onClose]);
// Close menu on space bar
const handleMenuKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === ' ') {
e.preventDefault();
handleClose();
}
},
[handleClose],
);
const handleMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const isCharacter = e.key.length === 1;
const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
if (isCharacter && !isSpecial) {
e.preventDefault();
setFilter((f) => f + e.key);
setSelectedIndex(0);
} else if (e.key === 'Backspace' && !isSpecial) {
e.preventDefault();
setFilter((f) => f.slice(0, -1));
}
};
useHotKey('popup.close', handleClose);
useHotKey('popup.close', () => {
if (filter !== '') {
setFilter('');
} else {
handleClose();
}
});
const handlePrev = useCallback(() => {
setSelectedIndex((currIndex) => {
@@ -309,6 +322,11 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
[handleClose, handleNext, handlePrev, handleSelect, items, selectedIndex],
);
const initContainerRef = (n: HTMLDivElement | null) => {
if (n == null) return null;
setContainerWidth(n.offsetWidth);
};
const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties;
triangleStyles: CSSProperties | null;
@@ -328,27 +346,36 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
bottom: upsideDown ? docRect.height - top : undefined,
right: onRight ? docRect.width - triggerShape?.right : undefined,
left: !onRight ? triggerShape?.left : undefined,
width: containerWidth ?? 'auto',
};
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight
? { right: width / 2, marginRight: '-0.2rem', ...size }
: { left: width / 2, marginLeft: '-0.2rem', ...size };
return { containerStyles, triangleStyles };
}, [triggerShape]);
}, [triggerShape, containerWidth]);
const filteredItems = useMemo(
() =>
items.filter(
(i) => filter === '' || getNodeText(i.label).toLowerCase().includes(filter.toLowerCase()),
),
[items, filter],
);
const handleFocus = useCallback(
(i: DropdownItem) => {
const index = items.findIndex((item) => item === i) ?? null;
const index = filteredItems.findIndex((item) => item === i) ?? null;
setSelectedIndex(index);
},
[items],
[filteredItems],
);
if (items.length === 0) return null;
return (
<>
{items.map(
{filteredItems.map(
(item) =>
item.type !== 'separator' &&
!item.hotKeyLabelOnly && (
@@ -372,7 +399,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
ref={initContainerRef}
style={containerStyles}
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
>
@@ -394,27 +421,46 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
{filter && (
<HStack
space={2}
alignItems="center"
className="py-0.5 px-1.5 mb-1 text-xs border border-highlight mx-2 rounded font-mono"
>
<Icon icon="search" size="xs" className="text-gray-600" />
<div className="text-gray-800">{filter}</div>
</HStack>
)}
{filteredItems.length === 0 && (
<span className="text-gray-500 text-sm text-center px-2 py-1">No matches</span>
)}
{items
.filter(
(i) =>
filter === '' ||
getNodeText(i.label).toLowerCase().includes(filter.toLowerCase()),
)
.map((item, i) => {
if (item.type === 'separator') {
return (
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
{item.label}
</Separator>
);
}
if (item.hidden) {
return null;
}
return (
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
{item.label}
</Separator>
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
})}
</VStack>
)}
</motion.div>

View File

@@ -42,6 +42,7 @@ const icons = {
plusCircle: lucide.PlusCircleIcon,
question: lucide.ShieldQuestionIcon,
refresh: lucide.RefreshCwIcon,
search: lucide.SearchIcon,
sendHorizontal: lucide.SendHorizonalIcon,
settings2: lucide.Settings2Icon,
settings: lucide.SettingsIcon,

View File

@@ -1,23 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useAppRoutes();
const queryClient = useQueryClient();
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
mutationFn: (patch) => {
return invoke('cmd_create_workspace', patch);
},
onSettled: () => trackEvent('Workspace', 'Create'),
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),
workspace,
]);
if (navigateAfter) {
routes.navigate('workspace', { workspaceId: workspace.id });
}

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
/**
* Get the text content from a ReactNode
* https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react
*/
export function getNodeText(node: ReactNode): string {
if (['string', 'number'].includes(typeof node)) {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join('');
}
if (typeof node === 'object' && node) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return getNodeText((node as any).props.children);
}
return '';
}