Feat: Add workspace context actions

This commit is contained in:
MangoPig
2026-06-18 11:16:54 +01:00
parent dea9e7e6ff
commit eeba19bbb6
10 changed files with 1464 additions and 119 deletions

View File

@@ -0,0 +1,231 @@
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`}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
>
<Show when={target.kind !== "workspace"}>
<header class={styles.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() }}>
<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]: action.id === "create",
[styles.actionDanger]: action.tone === "danger",
[styles.actionSubmenuOpen]: isSubmenuOpen(),
}}
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`}>
<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={() => 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>
);
};