243 lines
8.3 KiB
TypeScript
243 lines
8.3 KiB
TypeScript
import { For, Show, createEffect, createMemo, createSignal, onMount, type JSX } from "solid-js";
|
|
import { Portal } from "solid-js/web";
|
|
import { ChevronRight, Plus } from "../../../lib/icons";
|
|
import {
|
|
getWorkspaceContextMenuEyebrow,
|
|
getWorkspaceContextMenuSections,
|
|
type WorkspaceContextMenuAction,
|
|
type WorkspaceContextMenuShortcut,
|
|
type WorkspaceContextMenuTarget,
|
|
} from "../data/shell.data";
|
|
import styles from "./WorkspaceContextMenu.module.scss";
|
|
|
|
type ShortcutPlatform = "mac" | "windows";
|
|
|
|
type NavigatorWithUserAgentData = Navigator & {
|
|
userAgentData?: {
|
|
platform?: string;
|
|
};
|
|
};
|
|
|
|
type WorkspaceContextMenuPosition = {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
type WorkspaceContextMenuProps = {
|
|
target: WorkspaceContextMenuTarget | null;
|
|
position: WorkspaceContextMenuPosition | null;
|
|
onClose: VoidFunction;
|
|
onSelect: (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget) => void;
|
|
menuRef: (element: HTMLDivElement) => void;
|
|
};
|
|
|
|
const getShortcutPlatform = (): ShortcutPlatform => {
|
|
if (typeof navigator === "undefined") {
|
|
return "mac";
|
|
}
|
|
|
|
const navigatorWithUserAgentData = navigator as NavigatorWithUserAgentData;
|
|
|
|
const platform =
|
|
typeof navigatorWithUserAgentData.userAgentData?.platform === "string"
|
|
? navigatorWithUserAgentData.userAgentData.platform
|
|
: navigator.platform;
|
|
|
|
return /mac|iphone|ipad|ipod/i.test(platform) ? "mac" : "windows";
|
|
};
|
|
|
|
const formatShortcut = (shortcut: WorkspaceContextMenuShortcut, platform: ShortcutPlatform): string => {
|
|
const keyLabel = (() => {
|
|
switch (shortcut.key) {
|
|
case "enter":
|
|
return platform === "mac" ? "↩" : "Enter";
|
|
case "delete":
|
|
return platform === "mac" ? "⌫" : "Del";
|
|
default:
|
|
return shortcut.key.toUpperCase();
|
|
}
|
|
})();
|
|
|
|
const modifierLabels =
|
|
shortcut.modifiers?.map((modifier) => {
|
|
switch (modifier) {
|
|
case "meta":
|
|
return platform === "mac" ? "⌘" : "Ctrl";
|
|
case "alt":
|
|
return platform === "mac" ? "⌥" : "Alt";
|
|
case "shift":
|
|
return platform === "mac" ? "⇧" : "Shift";
|
|
}
|
|
}) ?? [];
|
|
|
|
if (platform === "mac") {
|
|
return `${modifierLabels.join("")}${keyLabel}`;
|
|
}
|
|
|
|
return [...modifierLabels, keyLabel].join("+");
|
|
};
|
|
|
|
export const WorkspaceContextMenu = (props: WorkspaceContextMenuProps): JSX.Element => {
|
|
const [activeSubmenuActionId, setActiveSubmenuActionId] = createSignal<string | null>(null);
|
|
const [shortcutPlatform, setShortcutPlatform] = createSignal<ShortcutPlatform>("mac");
|
|
const sections = createMemo(() => (props.target ? getWorkspaceContextMenuSections(props.target) : []));
|
|
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
|
|
props.onSelect(action, target);
|
|
props.onClose();
|
|
};
|
|
const menuState = createMemo<{
|
|
target: WorkspaceContextMenuTarget;
|
|
position: WorkspaceContextMenuPosition;
|
|
} | null>(() =>
|
|
props.target && props.position
|
|
? {
|
|
target: props.target,
|
|
position: props.position,
|
|
}
|
|
: null,
|
|
);
|
|
const showHeader = (): boolean => props.target?.kind !== "workspace";
|
|
const sectionHasLabel = createMemo(() => sections().some((section) => Boolean(section.label)));
|
|
|
|
onMount(() => {
|
|
setShortcutPlatform(getShortcutPlatform());
|
|
});
|
|
|
|
createEffect(() => {
|
|
void props.target;
|
|
setActiveSubmenuActionId(null);
|
|
});
|
|
|
|
return (
|
|
<Show when={menuState()}>
|
|
{(resolvedMenuState): JSX.Element => {
|
|
const target = resolvedMenuState().target;
|
|
const position = resolvedMenuState().position;
|
|
|
|
return (
|
|
<Portal>
|
|
<div
|
|
ref={props.menuRef}
|
|
class={styles.menu}
|
|
role="menu"
|
|
aria-label={`${target.label} context menu`}
|
|
data-ui="workspace-context-menu"
|
|
data-target-kind={target.kind}
|
|
data-item-type={target.kind === "item" ? target.itemType : undefined}
|
|
style={{
|
|
left: `${position.x}px`,
|
|
top: `${position.y}px`,
|
|
}}
|
|
>
|
|
<Show when={target.kind !== "workspace"}>
|
|
<header class={styles.header} data-slot="context-menu-header">
|
|
<span class={styles.eyebrow}>{getWorkspaceContextMenuEyebrow(target)}</span>
|
|
<strong class={styles.title}>{target.label}</strong>
|
|
</header>
|
|
</Show>
|
|
|
|
<div classList={{ [styles.sectionList]: true, [styles.sectionListCompact]: !sectionHasLabel() }} data-slot="context-menu-sections">
|
|
<For each={sections()}>
|
|
{(section): JSX.Element => (
|
|
<section class={styles.section} data-slot="context-menu-section" data-section-id={section.id}>
|
|
<Show when={section.label}>
|
|
<span class={styles.sectionLabel}>{section.label}</span>
|
|
</Show>
|
|
|
|
<div class={styles.actionList} data-slot="context-menu-action-list">
|
|
<For each={section.items}>
|
|
{(action): JSX.Element => {
|
|
const isSubmenuOpen = () => activeSubmenuActionId() === action.id;
|
|
|
|
return (
|
|
<div
|
|
class={styles.actionItem}
|
|
data-slot="context-menu-action-item"
|
|
data-action-id={action.id}
|
|
onMouseEnter={() => {
|
|
setActiveSubmenuActionId(action.children ? action.id : null);
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
classList={{
|
|
[styles.action]: true,
|
|
[styles.actionCreate]: action.id === "create",
|
|
[styles.actionDanger]: action.tone === "danger",
|
|
[styles.actionSubmenuOpen]: isSubmenuOpen(),
|
|
}}
|
|
data-slot="context-menu-action"
|
|
data-action-id={action.id}
|
|
data-tone={action.tone ?? "default"}
|
|
onClick={() => {
|
|
if (action.children) {
|
|
setActiveSubmenuActionId(isSubmenuOpen() ? null : action.id);
|
|
return;
|
|
}
|
|
|
|
handleActionSelect(action, target);
|
|
}}
|
|
>
|
|
<Show when={action.id === "create"}>
|
|
<span class={styles.actionCreateIcon} aria-hidden="true">
|
|
<Plus size={14} strokeWidth={2.25} />
|
|
</span>
|
|
</Show>
|
|
<span class={styles.actionLabel}>{action.label}</span>
|
|
<div class={styles.actionMeta}>
|
|
<Show when={action.shortcut}>
|
|
<span class={styles.actionShortcut}>{formatShortcut(action.shortcut!, shortcutPlatform())}</span>
|
|
</Show>
|
|
<Show when={action.children}>
|
|
<ChevronRight class={styles.actionChevron} size={16} strokeWidth={2} />
|
|
</Show>
|
|
</div>
|
|
</button>
|
|
|
|
<Show when={action.children && isSubmenuOpen()}>
|
|
<div class={styles.submenu} role="menu" aria-label={`${action.label} submenu`} data-slot="context-menu-submenu">
|
|
<div class={styles.submenuList} data-slot="context-menu-submenu-list">
|
|
<For each={action.children ?? []}>
|
|
{(childAction): JSX.Element => (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
classList={{
|
|
[styles.action]: true,
|
|
[styles.actionDanger]: childAction.tone === "danger",
|
|
}}
|
|
data-slot="context-menu-submenu-action"
|
|
data-action-id={childAction.id}
|
|
data-tone={childAction.tone ?? "default"}
|
|
onClick={() => handleActionSelect(childAction, target)}
|
|
>
|
|
<span class={styles.actionLabel}>{childAction.label}</span>
|
|
<div class={styles.actionMeta}>
|
|
<Show when={childAction.shortcut}>
|
|
<span class={styles.actionShortcut}>{formatShortcut(childAction.shortcut!, shortcutPlatform())}</span>
|
|
</Show>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}}
|
|
</For>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}}
|
|
</Show>
|
|
);
|
|
};
|