From eeba19bbb6afa3ab22f02fc4d9901b183e17f93b Mon Sep 17 00:00:00 2001 From: MangoPig Date: Thu, 18 Jun 2026 11:16:54 +0100 Subject: [PATCH] Feat: Add workspace context actions --- .../MobileWorkspaceBrowser.module.scss | 22 ++ .../MobileWorkspaceBrowser.tsx | 214 ++++++++++++---- .../WorkspaceContextMenu.module.scss | 194 ++++++++++++++ .../WorkspaceContextMenu.tsx | 231 +++++++++++++++++ .../createWorkspaceContextMenuController.ts | 134 ++++++++++ .../WorkspaceMobileActionSheet.module.scss | 146 +++++++++++ .../WorkspaceMobileActionSheet.tsx | 131 ++++++++++ .../WorkspaceSidebar/WorkspaceSidebar.tsx | 240 ++++++++++++------ .../shell/createLongPressGesture.ts | 73 ++++++ .../src/components/shell/data/shell.data.ts | 198 ++++++++++++++- 10 files changed, 1464 insertions(+), 119 deletions(-) create mode 100644 Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss create mode 100644 Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx create mode 100644 Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts create mode 100644 Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss create mode 100644 Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx create mode 100644 Frontend/src/components/shell/createLongPressGesture.ts diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss b/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss index 28e7538..68d69a2 100644 --- a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss +++ b/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss @@ -30,6 +30,12 @@ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 84%, transparent); } + .headerActions { + display: inline-flex; + align-items: center; + gap: var(--space-2); + } + .brandBlock { min-width: 0; display: grid; @@ -74,6 +80,22 @@ color: var(--color-text-subtle); } + .createButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: var(--control-size-md); + padding: 0 var(--space-3); + border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--color-surface) 94%, var(--color-canvas) 6%); + color: var(--color-text); + box-shadow: var(--shadow-soft); + font: inherit; + font-weight: var(--font-weight-semibold); + } + .sheetBody { min-height: 0; display: grid; diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx b/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx index 30d5385..ed4ab8c 100644 --- a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx +++ b/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx @@ -1,6 +1,21 @@ -import { For, Show, type JSX } from "solid-js"; -import { ChevronRight, X } from "../../../lib/icons"; -import { activeProject, activeServer, workspaceStaticItems, workspaceTree, type SidebarItem, type WorkspaceTreeNode } from "../data/shell.data"; +import { For, Show, createSignal, type JSX } from "solid-js"; +import { ChevronRight, Plus, X } from "../../../lib/icons"; +import { createLongPressGesture } from "../createLongPressGesture"; +import { + activeProject, + activeServer, + createWorkspaceStaticTarget, + createWorkspaceSurfaceTarget, + createWorkspaceTreeTarget, + workspaceStaticItems, + workspaceTree, + type SidebarItem, + type WorkspaceContextMenuAction, + type WorkspaceContextMenuTarget, + type WorkspaceStaticItem, + type WorkspaceTreeNode, +} from "../data/shell.data"; +import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet"; import styles from "./MobileWorkspaceBrowser.module.scss"; type MobileWorkspaceBrowserProps = { @@ -14,29 +29,29 @@ const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Elemen const hasChildren = (props.node.children?.length ?? 0) > 0; return ( -
  • - - - -
      - {(child): JSX.Element => } -
    -
    -
  • + + + {props.node.meta} + + + + + + ); }; @@ -44,55 +59,158 @@ const StaticRow = (props: { item: SidebarItem }): JSX.Element => { const Icon = props.item.icon; return ( -
  • - + + ); +}; +const WorkspaceStaticRow = (props: { + item: WorkspaceStaticItem; + onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void; +}): JSX.Element => { + const target = createWorkspaceStaticTarget(props.item); + const longPress = createLongPressGesture({ + onLongPress: () => { + props.onOpenActionSheet(target); + }, + }); + + return ( +
  • { + event.preventDefault(); + props.onOpenActionSheet(target); + }} + {...longPress} + > +
  • ); }; +const WorkspaceTreeBranch = (props: { + nodes: readonly WorkspaceTreeNode[]; + depth?: number; + onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void; +}): JSX.Element => { + const depth = props.depth ?? 0; + + return ( + + {(node): JSX.Element => { + const target = createWorkspaceTreeTarget(node); + const longPress = createLongPressGesture({ + onLongPress: () => { + props.onOpenActionSheet(target); + }, + }); + + return ( +
  • { + event.preventDefault(); + props.onOpenActionSheet(target); + }} + {...longPress} + > + + + +
      + +
    +
    +
  • + ); + }} +
    + ); +}; + export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => { + const [actionSheetTarget, setActionSheetTarget] = createSignal(null); const sectionNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) > 0); const looseNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) === 0); + const workspaceTarget = createWorkspaceSurfaceTarget(activeProject); + const openActionSheet = (target: WorkspaceContextMenuTarget): void => { + setActionSheetTarget(target); + }; + const closeActionSheet = (): void => { + setActionSheetTarget(null); + }; + const openWorkspaceActionSheet = (): void => { + openActionSheet(workspaceTarget); + }; + + const handleActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => { + // Mobile first pass only establishes the action-sheet IA and long-press behavior. + }; + + const workspaceLongPress = createLongPressGesture({ + onLongPress: openWorkspaceActionSheet, + }); return (
    -
    +
    { + event.preventDefault(); + openWorkspaceActionSheet(); + }} + {...workspaceLongPress} + > + {/* Long-pressing the browser header exposes workspace-level actions on mobile. */} Moku Work {activeProject.name} {activeServer.name}
    - +
    + + + +
    Workspace
      - {(item): JSX.Element => } + + {(item): JSX.Element => } +
    Items
      - {(node): JSX.Element => } +
    @@ -100,12 +218,18 @@ export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.
    More
      - {(node): JSX.Element => } +
    + +
    ); diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss b/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss new file mode 100644 index 0000000..3137fc8 --- /dev/null +++ b/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss @@ -0,0 +1,194 @@ +.menu { + --context-menu-width: 13.5rem; + position: fixed; + width: min(var(--context-menu-width), calc(100vw - (var(--space-4) * 2))); + display: grid; + gap: 0; + padding: var(--space-1); + border: 1px solid color-mix(in srgb, var(--color-border-strong) 56%, transparent); + border-radius: calc(var(--radius-md) + (var(--space-1) / 2)); + background: var(--color-surface); + box-shadow: var(--shadow-soft); + z-index: 2147483647; + user-select: none; +} + +.header { + display: grid; + gap: calc(var(--space-1) / 2); + padding: var(--space-2) calc(var(--space-2) + (var(--space-1) / 2)); +} + +.eyebrow { + @include text-caption; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.title { + @include text-label; + color: var(--color-text); + font-weight: var(--font-weight-title); +} + +.sectionList { + display: grid; + gap: 0; +} + +.sectionListCompact { + gap: calc(var(--space-1) / 2); +} + +.section { + display: grid; + gap: 0; +} + +.section:first-child { + padding-top: calc(var(--space-1) / 2); +} + +.section + .section { + margin-top: calc(var(--space-1) / 2); + padding-top: var(--space-2); + border-top: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent); +} + +.sectionLabel { + @include text-caption; + color: var(--color-text-subtle); + padding: 0 var(--space-2); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.actionList { + display: grid; + gap: 0; +} + +.actionItem { + position: relative; +} + +.action { + width: 100%; + min-height: calc(var(--control-size-md) - var(--space-2)); + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: var(--space-2); + padding: var(--space-2) calc(var(--space-2) + (var(--space-1) / 2)); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + text-align: left; + font-size: var(--font-size-label); + line-height: var(--line-height-label); + font-weight: var(--font-weight-label); + transition: + background var(--motion-duration-fast) var(--motion-ease-standard), + border-color var(--motion-duration-fast) var(--motion-ease-standard), + color var(--motion-duration-fast) var(--motion-ease-standard); +} + +.actionCreate { + border-color: transparent; + background: transparent; + font-weight: var(--font-weight-title); + box-shadow: none; +} + +.actionCreate:hover, +.actionCreate:focus-visible, +.actionCreate.actionSubmenuOpen { + background: color-mix(in srgb, var(--color-surface-hover) 82%, var(--color-surface)); + border-color: color-mix(in srgb, var(--color-border) 72%, transparent); +} + +.actionCreateIcon { + width: calc(var(--control-size-md) - var(--space-3)); + height: calc(var(--control-size-md) - var(--space-3)); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-surface-hover) 72%, var(--color-surface)); + color: var(--color-text); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 74%, transparent); +} + +.actionLabel { + min-width: 0; +} + +.actionMeta { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding-left: var(--space-3); +} + +.actionShortcut { + color: var(--color-text-muted); + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + font-weight: var(--font-weight-caption); + white-space: nowrap; +} + +.actionChevron { + color: var(--color-text-subtle); + flex: 0 0 auto; +} + +.actionSubmenuOpen { + background: color-mix(in srgb, var(--color-surface-hover) 82%, var(--color-surface)); + border-color: color-mix(in srgb, var(--color-border-strong) 72%, transparent); +} + +.action:hover, +.action:focus-visible { + background: color-mix(in srgb, var(--color-surface-hover) 78%, var(--color-surface)); + border-color: color-mix(in srgb, var(--color-border) 72%, transparent); +} + +.actionDanger { + color: var(--color-danger-text, var(--color-text)); +} + +.actionDanger:hover, +.actionDanger:focus-visible { + background: color-mix(in srgb, var(--color-danger-soft, var(--color-surface-hover)) 72%, transparent); + border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 72%, transparent); + color: var(--color-danger-text, var(--color-text)); +} + +.submenu { + position: absolute; + top: calc(var(--space-1) * -1); + left: calc(100% + var(--space-2)); + width: min(var(--context-menu-width), calc(100vw - (var(--space-4) * 2))); + padding: var(--space-1); + border: 1px solid color-mix(in srgb, var(--color-border-strong) 56%, transparent); + border-radius: calc(var(--radius-md) + (var(--space-1) / 2)); + background: var(--color-surface); + box-shadow: var(--shadow-soft); + z-index: 2147483647; +} + +.submenuList { + display: grid; + gap: var(--space-1); +} + +@include respond-down(mobile) { + .menu { + display: none; + } +} diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx b/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx new file mode 100644 index 0000000..da508c6 --- /dev/null +++ b/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx @@ -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(null); + const [shortcutPlatform, setShortcutPlatform] = createSignal("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 ( + + {(resolvedMenuState): JSX.Element => { + const target = resolvedMenuState().target; + const position = resolvedMenuState().position; + + return ( + + + + ); + }} + + ); +}; diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts b/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts new file mode 100644 index 0000000..e5f21a8 --- /dev/null +++ b/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts @@ -0,0 +1,134 @@ +import { createEffect, createSignal, onCleanup } from "solid-js"; +import type { WorkspaceContextMenuTarget } from "../data/shell.data"; + +type WorkspaceContextMenuState = { + target: WorkspaceContextMenuTarget; + x: number; + y: number; +}; + +const readRootPixelToken = (name: string, fallback: number): number => { + if (typeof window === "undefined") { + return fallback; + } + + const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const parsed = Number.parseFloat(value); + + if (!Number.isFinite(parsed)) { + return fallback; + } + + if (value.endsWith("px")) { + return parsed; + } + + return parsed * 16; +}; + +const clampMenuPosition = (value: number, min: number, max: number): number => { + if (max <= min) { + return min; + } + + return Math.min(Math.max(value, min), max); +}; + +export const createWorkspaceContextMenuController = () => { + const [menuState, setMenuState] = createSignal(null); + let menuRef: HTMLDivElement | undefined; + + const closeMenu = (): void => { + setMenuState(null); + }; + + const repositionMenu = (): void => { + if (typeof window === "undefined" || !menuRef) { + return; + } + + const current = menuState(); + + if (!current) { + return; + } + + const viewportPadding = readRootPixelToken("--space-4", 16); + const rect = menuRef.getBoundingClientRect(); + const nextX = clampMenuPosition(current.x, viewportPadding, window.innerWidth - rect.width - viewportPadding); + const nextY = clampMenuPosition(current.y, viewportPadding, window.innerHeight - rect.height - viewportPadding); + + if (nextX === current.x && nextY === current.y) { + return; + } + + setMenuState({ ...current, x: nextX, y: nextY }); + }; + + const openMenu = (event: MouseEvent, target: WorkspaceContextMenuTarget): void => { + event.preventDefault(); + setMenuState({ target, x: event.clientX, y: event.clientY }); + }; + + const openMenuFromElement = (element: HTMLElement, target: WorkspaceContextMenuTarget): void => { + const rect = element.getBoundingClientRect(); + setMenuState({ + target, + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + }; + + createEffect(() => { + if (!menuState()) { + return; + } + + if (typeof window === "undefined") { + return; + } + + const frame = window.requestAnimationFrame(() => { + repositionMenu(); + }); + + const handlePointerDown = (event: PointerEvent): void => { + if (!menuRef?.contains(event.target as Node)) { + closeMenu(); + } + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + closeMenu(); + } + }; + + const handleViewportChange = (): void => { + closeMenu(); + }; + + document.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + window.addEventListener("keydown", handleKeyDown); + + onCleanup(() => { + window.cancelAnimationFrame(frame); + document.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + window.removeEventListener("keydown", handleKeyDown); + }); + }); + + return { + menuState, + openMenu, + openMenuFromElement, + closeMenu, + setMenuRef: (element: HTMLDivElement): void => { + menuRef = element; + }, + }; +}; diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss b/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss new file mode 100644 index 0000000..7c4a28d --- /dev/null +++ b/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss @@ -0,0 +1,146 @@ +.layer { + display: none; +} + +@include respond-down(mobile) { + .layer { + position: fixed; + inset: 0; + display: block; + z-index: var(--z-modal); + } + + .backdrop { + position: absolute; + inset: 0; + border: 0; + background: color-mix(in srgb, black 52%, transparent); + } + + .sheet { + position: absolute; + right: 0; + bottom: 0; + left: 0; + max-height: calc(100dvh - (var(--space-12) * 2)); + display: grid; + gap: var(--space-3); + padding: var(--space-3) var(--space-3) calc(var(--space-4) + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + background: var(--color-surface); + box-shadow: var(--shadow-strong); + overflow: auto; + } + + .handle { + width: var(--space-10); + height: var(--space-1); + margin: 0 auto; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-text-muted) 24%, transparent); + } + + .header { + display: flex; + align-items: start; + justify-content: space-between; + gap: var(--space-3); + padding-bottom: var(--space-3); + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + } + + .headerCopy { + min-width: 0; + display: grid; + gap: calc(var(--space-1) / 2); + } + + .eyebrow { + @include text-caption; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .title { + @include text-title; + color: var(--color-text); + font-weight: var(--font-weight-semibold); + } + + .closeButton { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--control-size-md); + height: var(--control-size-md); + padding: 0; + border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-surface) 84%, var(--color-surface-elevated, var(--color-surface)) 16%); + color: var(--color-text-subtle); + } + + .sectionList { + display: grid; + gap: var(--space-3); + } + + .section { + display: grid; + gap: var(--space-2); + } + + .section + .section { + padding-top: var(--space-3); + border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent); + } + + .sectionLabel { + @include text-caption; + padding-inline: var(--space-1); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .actionList { + display: grid; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-elevated, var(--color-surface)) 12%); + } + + .action { + min-width: 0; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + min-height: calc(var(--control-size-lg) + var(--space-2)); + padding: 0 var(--space-3); + border: 0; + background: transparent; + color: var(--color-text); + text-align: left; + } + + .action:active { + background: color-mix(in srgb, var(--color-text) 6%, transparent); + } + + .action + .action { + border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent); + } + + .actionLabel { + @include text-label; + } + + .actionDanger { + color: var(--color-danger-text, var(--color-text)); + } +} diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx b/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx new file mode 100644 index 0000000..d5281b1 --- /dev/null +++ b/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx @@ -0,0 +1,131 @@ +import { For, Show, createMemo, type JSX } from "solid-js"; +import { Portal } from "solid-js/web"; +import { X } from "../../../lib/icons"; +import { + getWorkspaceContextMenuEyebrow, + getWorkspaceContextMenuSections, + type WorkspaceContextMenuAction, + type WorkspaceContextMenuSection, + type WorkspaceContextMenuTarget, +} from "../data/shell.data"; +import styles from "./WorkspaceMobileActionSheet.module.scss"; + +type WorkspaceMobileActionSheetProps = { + target: WorkspaceContextMenuTarget | null; + onClose: VoidFunction; + onSelect: (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget) => void; +}; + +type FlattenedActionSection = { + id: string; + label?: string; + items: readonly WorkspaceContextMenuAction[]; +}; + +const flattenMobileSections = ( + sections: readonly WorkspaceContextMenuSection[], +): readonly FlattenedActionSection[] => { + // Mobile uses a flat action-sheet model, so desktop flyout groups become + // standalone labeled sections instead of nested menus. + return sections.flatMap((section) => { + const directActions = section.items.filter((action) => !action.children?.length); + const nestedSections = section.items + .filter((action) => action.children?.length) + .map((action) => ({ + id: `${section.id}-${action.id}`, + label: action.label, + items: action.children ?? [], + })); + + const flattenedSections: FlattenedActionSection[] = []; + + if (directActions.length > 0) { + flattenedSections.push({ + id: section.id, + label: section.label, + items: directActions, + }); + } + + return [...flattenedSections, ...nestedSections]; + }); +}; + +export const WorkspaceMobileActionSheet = (props: WorkspaceMobileActionSheetProps): JSX.Element => { + const sheetState = createMemo(() => { + if (!props.target) { + return null; + } + + return { + target: props.target, + sections: flattenMobileSections(getWorkspaceContextMenuSections(props.target)), + }; + }); + + const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { + props.onSelect(action, target); + props.onClose(); + }; + + return ( + + {(sheetState): JSX.Element => { + const target = sheetState().target; + const sections = sheetState().sections; + + return ( + +
    + + + +
    + + {(section): JSX.Element => ( +
    + + {section.label} + + +
    + + {(action): JSX.Element => ( + + )} + +
    +
    + )} +
    +
    + +
    +
    + ); + }} +
    + ); +}; diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index fef903a..2db788a 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -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 (
  • @@ -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); + }} > {props.item.label} @@ -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 {(node): JSX.Element => { const Icon = node.icon; + const target = createWorkspaceTreeTarget(node); return (
  • @@ -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); + }} > {node.label} @@ -68,7 +119,12 @@ const WorkspaceTreeBranch = (props: { nodes: readonly WorkspaceTreeNode[]; depth - +
  • ); @@ -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 ( - + + + ); }; diff --git a/Frontend/src/components/shell/createLongPressGesture.ts b/Frontend/src/components/shell/createLongPressGesture.ts new file mode 100644 index 0000000..86d0a70 --- /dev/null +++ b/Frontend/src/components/shell/createLongPressGesture.ts @@ -0,0 +1,73 @@ +import type { JSX } from "solid-js"; + +type PointerHandler = NonNullable["onPointerDown"]>; + +type LongPressGestureOptions = { + onLongPress: () => void; + delay?: number; + movementThreshold?: number; +}; + +type LongPressGestureHandlers = { + onPointerDown: PointerHandler; + onPointerMove: PointerHandler; + onPointerUp: PointerHandler; + onPointerCancel: PointerHandler; + onPointerLeave: PointerHandler; +}; + +export const createLongPressGesture = (options: LongPressGestureOptions): LongPressGestureHandlers => { + let timeoutId: number | undefined; + let originX = 0; + let originY = 0; + + const delay = options.delay ?? 420; + const movementThreshold = options.movementThreshold ?? 10; + + const clearPendingLongPress = (): void => { + if (typeof timeoutId === "number") { + window.clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + const onPointerDown: PointerHandler = (event): void => { + // Mobile long-press should only respond to the primary touch/pen pointer. + if (event.pointerType === "mouse" || !event.isPrimary) { + return; + } + + originX = event.clientX; + originY = event.clientY; + clearPendingLongPress(); + timeoutId = window.setTimeout(() => { + timeoutId = undefined; + options.onLongPress(); + }, delay); + }; + + const onPointerMove: PointerHandler = (event): void => { + if (typeof timeoutId !== "number") { + return; + } + + const deltaX = Math.abs(event.clientX - originX); + const deltaY = Math.abs(event.clientY - originY); + + if (deltaX > movementThreshold || deltaY > movementThreshold) { + clearPendingLongPress(); + } + }; + + const onPointerUp: PointerHandler = (): void => { + clearPendingLongPress(); + }; + + return { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel: onPointerUp, + onPointerLeave: onPointerUp, + }; +}; diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts index 0a77f74..7038589 100644 --- a/Frontend/src/components/shell/data/shell.data.ts +++ b/Frontend/src/components/shell/data/shell.data.ts @@ -82,6 +82,12 @@ export type SidebarItem = { meta?: string; }; +export type WorkspaceStaticKind = "workspace" | "home" | "settings"; + +export type WorkspaceStaticItem = SidebarItem & { + contextKind: WorkspaceStaticKind; +}; + export type WorkspaceTreeNode = { id: string; label: string; @@ -111,6 +117,69 @@ export type MobileBottomNavItem = { active?: boolean; }; +export type WorkspaceContextMenuTarget = { + id: string; + label: string; + kind: WorkspaceStaticKind | WorkspaceTreeNode["kind"]; +}; + +export type WorkspaceContextMenuAction = { + id: string; + label: string; + tone?: "default" | "danger"; + shortcut?: WorkspaceContextMenuShortcut; + children?: readonly WorkspaceContextMenuAction[]; +}; + +export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift"; + +export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r"; + +export type WorkspaceContextMenuShortcut = { + modifiers?: readonly WorkspaceContextMenuShortcutModifier[]; + key: WorkspaceContextMenuShortcutKey; +}; + +export type WorkspaceContextMenuSection = { + id: string; + label?: string; + items: readonly WorkspaceContextMenuAction[]; +}; + +export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => { + switch (target.kind) { + case "workspace": + case "home": + return "Workspace"; + case "settings": + return "Configuration"; + case "folder": + return "Folder"; + case "board": + return "Board"; + case "doc": + return "Doc"; + } +}; + +export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({ + id: `workspace-${workspace.id}`, + label: workspace.name, + kind: "workspace", +}); + +export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({ + id: item.id, + label: item.label, + kind: item.contextKind, +}); + +export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({ + id: node.id, + label: node.label, + kind: node.kind, +}); + export type NotificationItem = { id: string; title: string; @@ -190,9 +259,9 @@ export const departmentItems: readonly DepartmentItem[] = [ // Sidebar and topbar scaffold data // These static entries stay pinned in both desktop and mobile workspace navigation. -export const workspaceStaticItems: readonly SidebarItem[] = [ - { id: "home", label: "Home", icon: Home, active: true }, - { id: "workspace-settings", label: "Settings", icon: Settings }, +export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [ + { id: "home", label: "Home", icon: Home, active: true, contextKind: "home" }, + { id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" }, ] as const; // Freeform workspace tree scaffold: folders, boards, and docs are first-class siblings. @@ -240,6 +309,129 @@ export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [ { id: "browse", label: "Browse", icon: Folder }, ] as const; +// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the +// target kinds and action grouping should stay shared across workspace surfaces. +export const getWorkspaceContextMenuSections = ( + target: WorkspaceContextMenuTarget, +): readonly WorkspaceContextMenuSection[] => { + const createActions = [ + { id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } }, + { id: "new-board", label: "New board", shortcut: { modifiers: ["alt"], key: "b" } }, + { id: "new-doc", label: "New doc", shortcut: { modifiers: ["alt"], key: "d" } }, + ] as const; + + const createSubmenuAction = { + id: "create", + label: "Create", + children: createActions, + } as const; + + switch (target.kind) { + case "workspace": + return [ + { + id: "create", + label: undefined, + items: [createSubmenuAction], + }, + { + id: "workspace", + label: undefined, + items: [ + { id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } }, + { id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } }, + ], + }, + ] as const; + case "home": + return [ + { + id: "create", + label: undefined, + items: [createSubmenuAction], + }, + { + id: "workspace", + label: undefined, + items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }], + }, + ] as const; + case "settings": + return [ + { + id: "settings", + label: undefined, + items: [ + { id: "open-settings", label: "Open settings", shortcut: { key: "enter" } }, + { id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } }, + ], + }, + ] as const; + case "folder": + return [ + { + id: "open", + items: [ + { id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, + { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, + ], + }, + { + id: "create", + label: undefined, + items: [createSubmenuAction], + }, + { + id: "organize", + label: undefined, + items: [ + { id: "duplicate-folder", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } }, + { id: "move-folder", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } }, + { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, + ], + }, + ] as const; + case "board": + return [ + { + id: "board", + items: [ + { id: "open-board", label: "Open board", shortcut: { key: "enter" } }, + { id: "rename-board", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, + ], + }, + { + id: "organize", + label: undefined, + items: [ + { id: "duplicate-board", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } }, + { id: "move-board", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } }, + { id: "delete-board", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, + ], + }, + ] as const; + case "doc": + return [ + { + id: "doc", + items: [ + { id: "open-doc", label: "Open doc", shortcut: { key: "enter" } }, + { id: "rename-doc", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, + ], + }, + { + id: "organize", + label: undefined, + items: [ + { id: "duplicate-doc", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } }, + { id: "move-doc", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } }, + { id: "delete-doc", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, + ], + }, + ] as const; + } +}; + export const topBarActions: readonly TopBarAction[] = [ { id: "search", label: "Search", icon: Search }, ] as const;