205 lines
6.8 KiB
TypeScript
205 lines
6.8 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 {
|
|
getProjectContextMenuEyebrow,
|
|
getProjectContextMenuSections,
|
|
type ProjectContextMenuAction,
|
|
type ProjectMenuTarget,
|
|
type WorkspaceContextMenuShortcut,
|
|
} from "../data/shell.data";
|
|
import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss";
|
|
|
|
type ShortcutPlatform = "mac" | "windows";
|
|
|
|
type NavigatorWithUserAgentData = Navigator & {
|
|
userAgentData?: {
|
|
platform?: string;
|
|
};
|
|
};
|
|
|
|
type ProjectContextMenuPosition = {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
type ProjectContextMenuProps = {
|
|
target: ProjectMenuTarget | null;
|
|
position: ProjectContextMenuPosition | null;
|
|
onClose: VoidFunction;
|
|
onSelect: (action: ProjectContextMenuAction, target: ProjectMenuTarget) => 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";
|
|
}
|
|
}) ?? [];
|
|
|
|
return platform === "mac" ? `${modifierLabels.join("")}${keyLabel}` : [...modifierLabels, keyLabel].join("+");
|
|
};
|
|
|
|
export const ProjectContextMenu = (props: ProjectContextMenuProps): JSX.Element => {
|
|
const [activeSubmenuActionId, setActiveSubmenuActionId] = createSignal<string | null>(null);
|
|
const [shortcutPlatform, setShortcutPlatform] = createSignal<ShortcutPlatform>("mac");
|
|
const sections = createMemo(() => (props.target ? getProjectContextMenuSections(props.target) : []));
|
|
const isCreateAction = (action: ProjectContextMenuAction): boolean => action.id.startsWith("new-");
|
|
const menuState = createMemo<{
|
|
target: ProjectMenuTarget;
|
|
position: ProjectContextMenuPosition;
|
|
} | null>(() => (props.target && props.position ? { target: props.target, position: props.position } : null));
|
|
|
|
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} project context menu`}
|
|
style={{ left: `${position.x}px`, top: `${position.y}px` }}
|
|
>
|
|
<Show when={target.kind !== "surface"}>
|
|
<header class={styles.header}>
|
|
<span class={styles.eyebrow}>{getProjectContextMenuEyebrow(target)}</span>
|
|
<strong class={styles.title}>{target.label}</strong>
|
|
</header>
|
|
</Show>
|
|
|
|
<div class={styles.sectionList}>
|
|
<For each={sections()}>
|
|
{(section): JSX.Element => (
|
|
<section class={styles.section}>
|
|
<Show when={section.label}>
|
|
<span class={styles.sectionLabel}>{section.label}</span>
|
|
</Show>
|
|
<div class={styles.actionList}>
|
|
<For each={section.items}>
|
|
{(action): JSX.Element => {
|
|
const isSubmenuOpen = () => activeSubmenuActionId() === action.id;
|
|
|
|
return (
|
|
<div class={styles.actionItem} onMouseEnter={() => setActiveSubmenuActionId(action.children ? action.id : null)}>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
classList={{
|
|
[styles.action]: true,
|
|
[styles.actionCreate]: isCreateAction(action),
|
|
[styles.actionDanger]: action.tone === "danger",
|
|
[styles.actionSubmenuOpen]: isSubmenuOpen(),
|
|
}}
|
|
onClick={() => {
|
|
if (action.children) {
|
|
setActiveSubmenuActionId(isSubmenuOpen() ? null : action.id);
|
|
return;
|
|
}
|
|
|
|
props.onSelect(action, target);
|
|
props.onClose();
|
|
}}
|
|
>
|
|
<Show when={isCreateAction(action)}>
|
|
<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`}>
|
|
<div class={styles.submenuList}>
|
|
<For each={action.children ?? []}>
|
|
{(childAction): JSX.Element => (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
classList={{
|
|
[styles.action]: true,
|
|
[styles.actionDanger]: childAction.tone === "danger",
|
|
}}
|
|
onClick={() => {
|
|
props.onSelect(childAction, target);
|
|
props.onClose();
|
|
}}
|
|
>
|
|
<span class={styles.actionLabel}>{childAction.label}</span>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}}
|
|
</For>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}}
|
|
</Show>
|
|
);
|
|
};
|