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
@@ -1,9 +1,23 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, createSignal, type JSX } from "solid-js";
import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import { workspaceSidebarHeaderActions, workspaceStaticItems, workspaceTree, type SidebarItem, type WorkspaceTreeNode } from "../data/shell.data";
import {
activeProject,
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
workspaceSidebarHeaderActions,
workspaceStaticItems,
workspaceTree,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
import styles from "./WorkspaceSidebar.module.scss";
type WorkspaceSidebarProps = {
@@ -12,8 +26,15 @@ type WorkspaceSidebarProps = {
onToggleRailCollapse: () => void;
};
const WorkspaceHomeEntry = (props: { item: SidebarItem }): JSX.Element => {
const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
const WorkspaceHomeEntry = (props: {
item: WorkspaceStaticItem;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const Icon = props.item.icon;
const target = createWorkspaceStaticTarget(props.item);
return (
<li>
@@ -26,6 +47,18 @@ const WorkspaceHomeEntry = (props: { item: SidebarItem }): JSX.Element => {
aria-current={props.item.active ? "page" : undefined}
aria-label={props.item.label}
title={props.item.label}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{props.item.label}</span>
@@ -37,7 +70,12 @@ const WorkspaceHomeEntry = (props: { item: SidebarItem }): JSX.Element => {
);
};
const WorkspaceTreeBranch = (props: { nodes: readonly WorkspaceTreeNode[]; depth?: number }): JSX.Element => {
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = () => props.depth ?? 0;
return (
@@ -45,6 +83,7 @@ const WorkspaceTreeBranch = (props: { nodes: readonly WorkspaceTreeNode[]; depth
<For each={props.nodes}>
{(node): JSX.Element => {
const Icon = node.icon;
const target = createWorkspaceTreeTarget(node);
return (
<li>
@@ -59,6 +98,18 @@ const WorkspaceTreeBranch = (props: { nodes: readonly WorkspaceTreeNode[]; depth
aria-current={node.active ? "page" : undefined}
aria-label={node.label}
title={node.label}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
@@ -68,7 +119,12 @@ const WorkspaceTreeBranch = (props: { nodes: readonly WorkspaceTreeNode[]; depth
</button>
<Show when={node.children?.length}>
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth() + 1} />
<WorkspaceTreeBranch
nodes={node.children ?? []}
depth={depth() + 1}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
/>
</Show>
</li>
);
@@ -78,86 +134,128 @@ const WorkspaceTreeBranch = (props: { nodes: readonly WorkspaceTreeNode[]; depth
);
};
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const contextMenu = createWorkspaceContextMenuController();
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(activeProject);
const contextMenuTarget = createMemo(() => contextMenu.menuState()?.target ?? null);
const contextMenuPosition = createMemo(() => {
const state = contextMenu.menuState();
return state
? {
x: state.x,
y: state.y,
}
: null;
});
const handleContextActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Initial implementation only establishes the menu IA and placement.
};
return (
<aside
classList={{
[styles.sidebar]: true,
[styles.sidebarCollapsed]: props.collapsed,
}}
aria-label="Left workspace sidebar"
>
<div
<>
<aside
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
[styles.sidebar]: true,
[styles.sidebarCollapsed]: props.collapsed,
}}
aria-label="Left workspace sidebar"
onContextMenu={(event): void => {
contextMenu.openMenu(event, sidebarContextMenuTarget);
}}
>
<div class={styles.headerActions}>
<button
type="button"
classList={{
[styles.headerActionButton]: true,
[styles.headerCollapseButton]: true,
}}
aria-label={railToggleLabel()}
title={railToggleLabel()}
onClick={props.onToggleRailCollapse}
>
{props.railCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
<div
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
}}
>
<div class={styles.headerActions}>
<button
type="button"
classList={{
[styles.headerActionButton]: true,
[styles.headerCollapseButton]: true,
}}
aria-label={railToggleLabel()}
title={railToggleLabel()}
onClick={props.onToggleRailCollapse}
>
{props.railCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
<For each={workspaceSidebarHeaderActions}>
{(action): JSX.Element => {
const Icon = action.icon;
<For each={workspaceSidebarHeaderActions}>
{(action): JSX.Element => {
const Icon = action.icon;
return (
<button type="button" class={styles.headerActionButton} aria-label={action.label} title={action.label}>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
return (
<button type="button" class={styles.headerActionButton} aria-label={action.label} title={action.label}>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
</div>
<div class={styles.headerControls}>
<ProjectSelector
compact={props.collapsed}
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
</div>
<div class={styles.headerControls}>
<ProjectSelector
compact={props.collapsed}
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
</div>
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
}}
>
<Show when={!props.collapsed}>
<span class={styles.sectionLabel}>Workspace</span>
</Show>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={workspaceStaticItems}>{(item): JSX.Element => <WorkspaceHomeEntry item={item} />}</For>
</ul>
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
}}
>
<Show when={!props.collapsed}>
<div class={styles.treeSectionLabel}>Items</div>
<span class={styles.sectionLabel}>Workspace</span>
</Show>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={workspaceStaticItems}>
{(item): JSX.Element => (
<WorkspaceHomeEntry
item={item}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
)}
</For>
</ul>
<WorkspaceTreeBranch nodes={workspaceTree} />
<Show when={!props.collapsed}>
<div class={styles.treeSectionLabel}>Items</div>
</Show>
<WorkspaceTreeBranch
nodes={workspaceTree}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
</div>
</div>
</div>
</aside>
</aside>
<WorkspaceContextMenu
target={contextMenuTarget()}
position={contextMenuPosition()}
menuRef={contextMenu.setMenuRef}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
</>
);
};