Dropdown manages hotkeys now

This commit is contained in:
Gregory Schier
2024-01-11 10:18:05 -08:00
parent dbaf1da3ce
commit bd5ae12f2e
12 changed files with 177 additions and 150 deletions

View File

@@ -4,7 +4,6 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import { useHotkey } from '../hooks/useHotkey';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
@@ -35,8 +34,6 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
});
}, [dialog, activeEnvironment]);
useHotkey('environmentEditor.toggle', showEnvironmentDialog, { enable: environments.length > 0 });
const items: DropdownItem[] = useMemo(
() => [
...environments.map(

View File

@@ -1,4 +1,4 @@
import { hotkeyActions } from '../hooks/useHotkey';
import { hotkeyActions } from '../hooks/useHotKey';
import { HotKeyList } from './core/HotKeyList';
export const KeyboardShortcutsDialog = () => {

View File

@@ -2,7 +2,6 @@ import { invoke, shell } from '@tauri-apps/api';
import { useRef } from 'react';
import { useAppVersion } from '../hooks/useAppVersion';
import { useExportData } from '../hooks/useExportData';
import { useHotkey } from '../hooks/useHotkey';
import { useImportData } from '../hooks/useImportData';
import { useTheme } from '../hooks/useTheme';
import { useUpdateMode } from '../hooks/useUpdateMode';
@@ -24,17 +23,6 @@ export function SettingsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog();
const showHotkeyHelp = () => {
dialog.show({
id: 'hotkey-help',
title: 'Keyboard Shortcuts',
size: 'sm',
render: () => <KeyboardShortcutsDialog />,
});
};
useHotkey('hotkeys.showHelp', showHotkeyHelp);
return (
<Dropdown
ref={dropdownRef}
@@ -83,7 +71,14 @@ export function SettingsDropdown() {
key: 'hotkeys',
label: 'Keyboard shortcuts',
hotkeyAction: 'hotkeys.showHelp',
onSelect: showHotkeyHelp,
onSelect: () => {
dialog.show({
id: 'hotkey-help',
title: 'Keyboard Shortcuts',
size: 'sm',
render: () => <KeyboardShortcutsDialog />,
});
},
leftSlot: <Icon icon="keyboard" />,
},
{ type: 'separator', label: `Yaak v${appVersion.data}` },

View File

@@ -16,7 +16,7 @@ import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useFolders } from '../hooks/useFolders';
import { useHotkey } from '../hooks/useHotkey';
import { useHotKey } from '../hooks/useHotKey';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { usePrompt } from '../hooks/usePrompt';
@@ -55,7 +55,6 @@ export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequestId = useActiveRequestId();
const duplicateRequest = useDuplicateRequest({ id: activeRequestId ?? '', navigateAfter: true });
const activeEnvironmentId = useActiveEnvironmentId();
const requests = useRequests();
const folders = useFolders();
@@ -76,8 +75,6 @@ export function Sidebar({ className }: Props) {
namespace: NAMESPACE_NO_SYNC,
});
useHotkey('request.duplicate', () => duplicateRequest.mutate());
const isCollapsed = useCallback(
(id: string) => collapsed.value?.[id] ?? false,
[collapsed.value],
@@ -209,7 +206,7 @@ export function Sidebar({ className }: Props) {
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useHotkey('sidebar.focus', () => {
useHotKey('sidebar.focus', () => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(
@@ -649,10 +646,7 @@ const SidebarItem = forwardRef(function SidebarItem(
label: 'Duplicate',
hotkeyAction: 'request.duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
console.log('DUPLICATE');
duplicateRequest.mutate();
},
onSelect: () => duplicateRequest.mutate(),
},
{
key: 'deleteRequest',

View File

@@ -1,7 +1,6 @@
import { memo } from 'react';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useHotkey } from '../hooks/useHotkey';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { Dropdown } from './core/Dropdown';
import { IconButton } from './core/IconButton';
@@ -12,8 +11,6 @@ export const SidebarActions = memo(function SidebarActions() {
const createFolder = useCreateFolder();
const { hidden, toggle } = useSidebarHidden();
useHotkey('request.create', () => createRequest.mutate({}));
return (
<HStack>
<IconButton

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useHotkey } from '../hooks/useHotkey';
import { useHotKey } from '../hooks/useHotKey';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
@@ -40,7 +40,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
[sendRequest],
);
useHotkey('urlBar.focus', () => {
useHotKey('urlBar.focus', () => {
const head = inputRef.current?.state.doc.length ?? 0;
inputRef.current?.dispatch({
selection: { anchor: 0, head },

View File

@@ -1,8 +1,8 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useFormattedHotkey, useHotkey } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { Icon } from './Icon';
const colorStyles = {
@@ -80,7 +80,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
() => buttonRef.current,
);
useHotkey(hotkeyAction ?? null, () => {
useHotKey(hotkeyAction ?? null, () => {
buttonRef.current?.click();
});

View File

@@ -20,7 +20,8 @@ import React, {
useState,
} from 'react';
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
import type { HotkeyAction } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKey } from '../../hooks/useHotKey';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { HotKey } from './HotKey';
@@ -50,6 +51,7 @@ export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
openOnHotKeyAction?: HotkeyAction;
}
export interface DropdownRef {
@@ -63,20 +65,24 @@ export interface DropdownRef {
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items }: DropdownProps,
{ children, items, openOnHotKeyAction }: DropdownProps,
ref,
) {
const [open, setOpen] = useState<boolean>(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
useHotKey(openOnHotKeyAction ?? null, () => {
setIsOpen(true);
});
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: open,
isOpen: isOpen,
toggle(activeIndex?: number) {
if (!open) this.open(activeIndex);
else setOpen(false);
if (!isOpen) this.open(activeIndex);
else setIsOpen(false);
},
open(activeIndex?: number) {
if (activeIndex === undefined) {
@@ -84,7 +90,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
} else {
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
}
setOpen(true);
setIsOpen(true);
},
}));
@@ -101,41 +107,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
e.preventDefault();
e.stopPropagation();
setDefaultSelectedIndex(undefined);
setOpen((o) => !o);
setIsOpen((o) => !o);
}),
};
return cloneElement(existingChild, props);
}, [children]);
const handleClose = useCallback(() => {
setOpen(false);
setIsOpen(false);
buttonRef.current?.focus();
}, []);
useEffect(() => {
buttonRef.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
}, [isOpen]);
const windowSize = useWindowSize();
const triggerRect = useMemo(() => {
if (!windowSize) return null; // No-op to TS happy with this dep
if (!open) return null;
if (!isOpen) return null;
return buttonRef.current?.getBoundingClientRect();
}, [open, windowSize]);
}, [isOpen, windowSize]);
return (
<>
{child}
{open && triggerRect && (
<Menu
ref={menuRef}
showTriangle
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect}
onClose={handleClose}
/>
)}
<Menu
ref={menuRef}
showTriangle
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerShape={triggerRect ?? null}
onClose={handleClose}
isOpen={isOpen}
/>
</>
);
});
@@ -161,15 +166,12 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
[show],
);
if (show === null) {
return null;
}
return (
<Menu
className={className}
ref={ref}
items={items}
isOpen={show != null}
onClose={onClose}
triggerShape={triggerShape}
/>
@@ -180,13 +182,22 @@ interface MenuProps {
className?: string;
defaultSelectedIndex?: number;
items: DropdownProps['items'];
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>;
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
onClose: () => void;
showTriangle?: boolean;
isOpen: boolean;
}
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
{ className, items, onClose, triggerShape, defaultSelectedIndex, showTriangle }: MenuProps,
{
className,
isOpen,
items,
onClose,
triggerShape,
defaultSelectedIndex,
showTriangle,
}: MenuProps,
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -291,6 +302,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
containerStyles: CSSProperties;
triangleStyles: CSSProperties | null;
}>(() => {
if (triggerShape == null) return { containerStyles: {}, triangleStyles: null };
const docRect = document.documentElement.getBoundingClientRect();
const width = triggerShape.right - triggerShape.left;
const hSpaceRemaining = docRect.width - triggerShape.left;
@@ -322,61 +335,76 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
if (items.length === 0) return null;
return (
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
>
{triangleStyles && showTriangle && (
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
<>
{items.map(
(item) =>
item.type !== 'separator' && (
<MenuItemHotKey
key={item.key}
onSelect={handleSelect}
item={item}
action={item.hotkeyAction}
/>
)}
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classNames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
),
)}
{isOpen && (
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
<div>
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
<motion.div
tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={containerRef}
style={containerStyles}
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</Overlay>
{triangleStyles && showTriangle && (
<span
aria-hidden
style={triangleStyles}
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
/>
)}
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classNames(
className,
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
'border-gray-200 overflow-auto mb-1 mx-0.5',
)}
>
{items.map((item, i) => {
if (item.type === 'separator') {
return <Separator key={i} className="my-1.5" label={item.label} />;
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
item={item}
/>
);
})}
</VStack>
)}
</motion.div>
</div>
</Overlay>
)}
</>
);
});
@@ -443,3 +471,19 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
</Button>
);
}
interface MenuItemHotKeyProps {
action: HotkeyAction | undefined;
onSelect: MenuItemProps['onSelect'];
item: MenuItemProps['item'];
}
function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
if (action) {
console.log('MENU ITEM HOTKEY', action, item);
}
useHotKey(action ?? null, () => {
onSelect(item);
});
return null;
}

View File

@@ -1,7 +1,8 @@
import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useFormattedHotkey } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey } from '../../hooks/useHotKey';
import { useOsInfo } from '../../hooks/useOsInfo';
import { HStack } from './Stacks';
interface Props {
action: HotkeyAction | null;
@@ -17,14 +18,18 @@ export function HotKey({ action, className, variant }: Props) {
}
return (
<span
<HStack
className={classNames(
className,
variant === 'with-bg' && 'rounded border',
'text-sm text-gray-1000 text-opacity-disabled',
'text-gray-1000 text-opacity-disabled',
)}
>
{label}
</span>
{label.split('').map((char, index) => (
<div key={index} className="w-[1.1em] text-center">
{char}
</div>
))}
</HStack>
);
}

View File

@@ -1,8 +1,8 @@
import type { HotkeyAction } from '../../hooks/useHotkey';
import { useHotKeyLabel } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKeyLabel } from '../../hooks/useHotKey';
interface Props {
action: HotkeyAction | null;
action: HotkeyAction;
}
export function HotKeyLabel({ action }: Props) {

View File

@@ -1,7 +1,8 @@
import React from 'react';
import type { HotkeyAction } from '../../hooks/useHotkey';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { HotKey } from './HotKey';
import { HotKeyLabel } from './HotKeyLabel';
import { HStack, VStack } from './Stacks';
interface Props {
hotkeys: HotkeyAction[];
@@ -10,14 +11,14 @@ interface Props {
export const HotKeyList = ({ hotkeys }: Props) => {
return (
<div className="mx-auto h-full flex items-center text-gray-700 text-sm">
<div className="flex flex-col gap-1">
<VStack space={2}>
{hotkeys.map((hotkey) => (
<div key={hotkey} className="grid grid-cols-2">
<HStack key={hotkey} className="grid grid-cols-2">
<HotKeyLabel action={hotkey} />
<HotKey className="ml-auto" action={hotkey} />
</div>
</HStack>
))}
</div>
</VStack>
</div>
);
};

View File

@@ -24,13 +24,24 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'hotkeys.showHelp': ['CmdCtrl+/'],
};
const hotkeyLabels: Record<HotkeyAction, string> = {
'request.send': 'Send Request',
'request.create': 'New Request',
'request.duplicate': 'Duplicate Request',
'sidebar.toggle': 'Toggle Sidebar',
'sidebar.focus': 'Focus Sidebar',
'urlBar.focus': 'Focus URL',
'environmentEditor.toggle': 'Edit Environments',
'hotkeys.showHelp': 'Show Hotkeys',
};
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
interface Options {
enable?: boolean;
}
export function useHotkey(
export function useHotKey(
action: HotkeyAction | null,
callback: (e: KeyboardEvent) => void,
options: Options = {},
@@ -97,25 +108,8 @@ export function useAnyHotkey(
}, [options.enable, os]);
}
export function useHotKeyLabel(action: HotkeyAction | null): string {
switch (action) {
case 'request.send':
return 'Send Request';
case 'request.create':
return 'New Request';
case 'request.duplicate':
return 'Duplicate Request';
case 'sidebar.toggle':
return 'Toggle Sidebar';
case 'sidebar.focus':
return 'Focus Sidebar';
case 'urlBar.focus':
return 'Focus URL';
case 'environmentEditor.toggle':
return 'Edit Environments';
default:
return 'Unknown';
}
export function useHotKeyLabel(action: HotkeyAction): string {
return hotkeyLabels[action];
}
export function useFormattedHotkey(action: HotkeyAction | null): string | null {