Feat: Add workspace context actions
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user