From a521b8f30872c51e8cdff1b802e2f988b91bfddf Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 17 Feb 2024 22:03:42 -0800 Subject: [PATCH] Better dropdown filtering --- src-web/components/core/Dropdown.tsx | 118 +++++++++++++++++++-------- src-web/components/core/Icon.tsx | 1 + src-web/hooks/useCreateWorkspace.ts | 8 +- src-web/lib/getNodeText.ts | 22 +++++ 4 files changed, 106 insertions(+), 43 deletions(-) create mode 100644 src-web/lib/getNodeText.ts diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 00cdb056..7482fb8a 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -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, MenuPro }: MenuProps, ref, ) { - const containerRef = useRef(null); const [selectedIndex, setSelectedIndex] = useState(defaultSelectedIndex ?? null); const [menuStyles, setMenuStyles] = useState({}); + const [filter, setFilter] = useState(''); + const [containerWidth, setContainerWidth] = useState(null); // Calculate the max height so we can scroll const initMenu = useCallback((el: HTMLDivElement | null) => { @@ -224,20 +227,30 @@ const Menu = forwardRef, MenuPro const handleClose = useCallback(() => { onClose(); setSelectedIndex(null); + setFilter(''); }, [onClose]); // Close menu on space bar - const handleMenuKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === ' ') { - e.preventDefault(); - handleClose(); - } - }, - [handleClose], - ); + const handleMenuKeyDown = (e: React.KeyboardEvent) => { + 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, 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, 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, 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, MenuPro 'border-gray-200 overflow-auto mb-1 mx-0.5', )} > - {items.map((item, i) => { - if (item.type === 'separator') { + {filter && ( + + +
{filter}
+
+ )} + {filteredItems.length === 0 && ( + No matches + )} + {items + .filter( + (i) => + filter === '' || + getNodeText(i.label).toLowerCase().includes(filter.toLowerCase()), + ) + .map((item, i) => { + if (item.type === 'separator') { + return ( + + {item.label} + + ); + } + if (item.hidden) { + return null; + } return ( - - {item.label} - + ); - } - if (item.hidden) { - return null; - } - return ( - - ); - })} + })} )} diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index ec557e34..020e0cf1 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -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, diff --git a/src-web/hooks/useCreateWorkspace.ts b/src-web/hooks/useCreateWorkspace.ts index 58422cfa..b684f912 100644 --- a/src-web/hooks/useCreateWorkspace.ts +++ b/src-web/hooks/useCreateWorkspace.ts @@ -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>({ mutationFn: (patch) => { return invoke('cmd_create_workspace', patch); }, onSettled: () => trackEvent('Workspace', 'Create'), onSuccess: async (workspace) => { - queryClient.setQueryData(workspacesQueryKey({}), (workspaces) => [ - ...(workspaces ?? []), - workspace, - ]); if (navigateAfter) { routes.navigate('workspace', { workspaceId: workspace.id }); } diff --git a/src-web/lib/getNodeText.ts b/src-web/lib/getNodeText.ts new file mode 100644 index 00000000..73a96eb6 --- /dev/null +++ b/src-web/lib/getNodeText.ts @@ -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 ''; +}