mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-20 15:51:23 +02:00
Better dropdown filtering
This commit is contained in:
@@ -23,11 +23,13 @@ import React, {
|
|||||||
import { useKey, useWindowSize } from 'react-use';
|
import { useKey, useWindowSize } from 'react-use';
|
||||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||||
import { useHotKey } from '../../hooks/useHotKey';
|
import { useHotKey } from '../../hooks/useHotKey';
|
||||||
|
import { getNodeText } from '../../lib/getNodeText';
|
||||||
import { Overlay } from '../Overlay';
|
import { Overlay } from '../Overlay';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { HotKey } from './HotKey';
|
import { HotKey } from './HotKey';
|
||||||
import { Separator } from './Separator';
|
import { Separator } from './Separator';
|
||||||
import { VStack } from './Stacks';
|
import { HStack, VStack } from './Stacks';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
export type DropdownItemSeparator = {
|
export type DropdownItemSeparator = {
|
||||||
type: 'separator';
|
type: 'separator';
|
||||||
@@ -209,9 +211,10 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
}: MenuProps,
|
}: MenuProps,
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
|
||||||
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
|
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
|
// Calculate the max height so we can scroll
|
||||||
const initMenu = useCallback((el: HTMLDivElement | null) => {
|
const initMenu = useCallback((el: HTMLDivElement | null) => {
|
||||||
@@ -224,20 +227,30 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onClose();
|
onClose();
|
||||||
setSelectedIndex(null);
|
setSelectedIndex(null);
|
||||||
|
setFilter('');
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Close menu on space bar
|
// Close menu on space bar
|
||||||
const handleMenuKeyDown = useCallback(
|
const handleMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
const isCharacter = e.key.length === 1;
|
||||||
if (e.key === ' ') {
|
const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
|
||||||
e.preventDefault();
|
if (isCharacter && !isSpecial) {
|
||||||
handleClose();
|
e.preventDefault();
|
||||||
}
|
setFilter((f) => f + e.key);
|
||||||
},
|
setSelectedIndex(0);
|
||||||
[handleClose],
|
} 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(() => {
|
const handlePrev = useCallback(() => {
|
||||||
setSelectedIndex((currIndex) => {
|
setSelectedIndex((currIndex) => {
|
||||||
@@ -309,6 +322,11 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
[handleClose, handleNext, handlePrev, handleSelect, items, selectedIndex],
|
[handleClose, handleNext, handlePrev, handleSelect, items, selectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const initContainerRef = (n: HTMLDivElement | null) => {
|
||||||
|
if (n == null) return null;
|
||||||
|
setContainerWidth(n.offsetWidth);
|
||||||
|
};
|
||||||
|
|
||||||
const { containerStyles, triangleStyles } = useMemo<{
|
const { containerStyles, triangleStyles } = useMemo<{
|
||||||
containerStyles: CSSProperties;
|
containerStyles: CSSProperties;
|
||||||
triangleStyles: CSSProperties | null;
|
triangleStyles: CSSProperties | null;
|
||||||
@@ -328,27 +346,36 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
bottom: upsideDown ? docRect.height - top : undefined,
|
bottom: upsideDown ? docRect.height - top : undefined,
|
||||||
right: onRight ? docRect.width - triggerShape?.right : undefined,
|
right: onRight ? docRect.width - triggerShape?.right : undefined,
|
||||||
left: !onRight ? triggerShape?.left : undefined,
|
left: !onRight ? triggerShape?.left : undefined,
|
||||||
|
width: containerWidth ?? 'auto',
|
||||||
};
|
};
|
||||||
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
|
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
|
||||||
const triangleStyles = onRight
|
const triangleStyles = onRight
|
||||||
? { right: width / 2, marginRight: '-0.2rem', ...size }
|
? { right: width / 2, marginRight: '-0.2rem', ...size }
|
||||||
: { left: width / 2, marginLeft: '-0.2rem', ...size };
|
: { left: width / 2, marginLeft: '-0.2rem', ...size };
|
||||||
return { containerStyles, triangleStyles };
|
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(
|
const handleFocus = useCallback(
|
||||||
(i: DropdownItem) => {
|
(i: DropdownItem) => {
|
||||||
const index = items.findIndex((item) => item === i) ?? null;
|
const index = filteredItems.findIndex((item) => item === i) ?? null;
|
||||||
setSelectedIndex(index);
|
setSelectedIndex(index);
|
||||||
},
|
},
|
||||||
[items],
|
[filteredItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{items.map(
|
{filteredItems.map(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.type !== 'separator' &&
|
item.type !== 'separator' &&
|
||||||
!item.hotKeyLabelOnly && (
|
!item.hotKeyLabelOnly && (
|
||||||
@@ -372,7 +399,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
|||||||
role="menu"
|
role="menu"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
ref={containerRef}
|
ref={initContainerRef}
|
||||||
style={containerStyles}
|
style={containerStyles}
|
||||||
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
|
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',
|
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{items.map((item, i) => {
|
{filter && (
|
||||||
if (item.type === 'separator') {
|
<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 (
|
return (
|
||||||
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
|
<MenuItem
|
||||||
{item.label}
|
focused={i === selectedIndex}
|
||||||
</Separator>
|
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>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const icons = {
|
|||||||
plusCircle: lucide.PlusCircleIcon,
|
plusCircle: lucide.PlusCircleIcon,
|
||||||
question: lucide.ShieldQuestionIcon,
|
question: lucide.ShieldQuestionIcon,
|
||||||
refresh: lucide.RefreshCwIcon,
|
refresh: lucide.RefreshCwIcon,
|
||||||
|
search: lucide.SearchIcon,
|
||||||
sendHorizontal: lucide.SendHorizonalIcon,
|
sendHorizontal: lucide.SendHorizonalIcon,
|
||||||
settings2: lucide.Settings2Icon,
|
settings2: lucide.Settings2Icon,
|
||||||
settings: lucide.SettingsIcon,
|
settings: lucide.SettingsIcon,
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { invoke } from '@tauri-apps/api';
|
import { invoke } from '@tauri-apps/api';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
import type { Workspace } from '../lib/models';
|
import type { Workspace } from '../lib/models';
|
||||||
import { useAppRoutes } from './useAppRoutes';
|
import { useAppRoutes } from './useAppRoutes';
|
||||||
import { workspacesQueryKey } from './useWorkspaces';
|
|
||||||
|
|
||||||
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
|
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
|
||||||
const routes = useAppRoutes();
|
const routes = useAppRoutes();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
|
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
|
||||||
mutationFn: (patch) => {
|
mutationFn: (patch) => {
|
||||||
return invoke('cmd_create_workspace', patch);
|
return invoke('cmd_create_workspace', patch);
|
||||||
},
|
},
|
||||||
onSettled: () => trackEvent('Workspace', 'Create'),
|
onSettled: () => trackEvent('Workspace', 'Create'),
|
||||||
onSuccess: async (workspace) => {
|
onSuccess: async (workspace) => {
|
||||||
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
|
|
||||||
...(workspaces ?? []),
|
|
||||||
workspace,
|
|
||||||
]);
|
|
||||||
if (navigateAfter) {
|
if (navigateAfter) {
|
||||||
routes.navigate('workspace', { workspaceId: workspace.id });
|
routes.navigate('workspace', { workspaceId: workspace.id });
|
||||||
}
|
}
|
||||||
|
|||||||
22
src-web/lib/getNodeText.ts
Normal file
22
src-web/lib/getNodeText.ts
Normal 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 '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user