diff --git a/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx b/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx new file mode 100644 index 0000000..089c8ee --- /dev/null +++ b/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx @@ -0,0 +1,204 @@ +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(null); + const [shortcutPlatform, setShortcutPlatform] = createSignal("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 ( + + {(resolvedMenuState): JSX.Element => { + const target = resolvedMenuState().target; + const position = resolvedMenuState().position; + + return ( + + + + ); + }} + + ); +}; diff --git a/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts b/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts new file mode 100644 index 0000000..12a8b84 --- /dev/null +++ b/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts @@ -0,0 +1,120 @@ +import { createEffect, createSignal, onCleanup } from "solid-js"; +import type { ProjectMenuTarget } from "../data/shell.data"; + +type ProjectContextMenuState = { + target: ProjectMenuTarget; + 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 createProjectContextMenuController = () => { + 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: ProjectMenuTarget): void => { + event.preventDefault(); + setMenuState({ target, x: event.clientX, y: event.clientY }); + }; + + createEffect(() => { + if (!menuState() || 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, + closeMenu, + setMenuRef: (element: HTMLDivElement): void => { + menuRef = element; + }, + }; +}; diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss index 04f86fa..92112c0 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss @@ -38,9 +38,9 @@ } .triggerOpen { - border-color: color-mix(in srgb, var(--color-border-strong) 22%, transparent); - background: color-mix(in srgb, var(--color-surface) 92%, transparent); - box-shadow: var(--shadow-soft); + border-color: color-mix(in srgb, var(--color-accent-strong) 22%, var(--color-border-strong)); + background: color-mix(in srgb, var(--color-accent-soft) 26%, var(--color-surface)); + box-shadow: 0 10px 28px color-mix(in srgb, black 8%, transparent); } .triggerCompact { @@ -83,19 +83,14 @@ gap: 0.12rem; } -.eyebrow, -.projectItemDescription { +.eyebrow { @include text-caption; color: var(--color-text-muted); -} - -.eyebrow { text-transform: uppercase; letter-spacing: 0.08em; } -.value, -.projectItemName { +.value { @include text-label; } @@ -175,7 +170,7 @@ min-height: 0; display: grid; align-content: start; - gap: var(--space-3); + gap: var(--space-2); padding: var(--space-4); overflow-y: auto; overscroll-behavior: contain; @@ -186,20 +181,34 @@ width: 0; } -.projectList { +.treeSectionLabel { + @include text-caption; + margin: 0 0 var(--space-2); + padding: 0 var(--space-3); + color: var(--color-text-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.treeList { list-style: none; display: grid; - gap: 0.2rem; + gap: var(--space-1); padding: 0; } -.projectItem { +.treeItem { width: 100%; min-width: 0; - min-height: calc(var(--control-size-md) + var(--space-2)); + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + min-height: calc(var(--control-size-lg) - var(--space-2)); padding: var(--space-2) var(--space-3); + padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4))); border: 1px solid transparent; - border-radius: var(--radius-sm); + border-radius: var(--radius-lg); background: transparent; color: var(--color-text-muted); transition: @@ -210,25 +219,50 @@ text-align: left; } -.projectItem:hover { - background: color-mix(in srgb, var(--color-surface-hover) 82%, transparent); +.treeItem:hover, +.treeItem:focus-visible { + background: var(--color-surface-hover); color: var(--color-text); - border-color: color-mix(in srgb, var(--color-border) 22%, transparent); } -.projectItemActive { - border-color: color-mix(in srgb, var(--color-border) 28%, transparent); - background: color-mix(in srgb, var(--color-surface) 82%, transparent); +.treeItemFolder { color: var(--color-text); - box-shadow: none; } -.projectItemCopy { +.folderChevron { + color: var(--color-text-muted); + transition: transform 160ms var(--easing-standard); +} + +.folderChevronOpen { + transform: rotate(90deg); +} + +.treeItemActive { + border-color: var(--color-border); + background: var(--color-surface); + color: var(--color-text); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); +} + +.icon { + color: inherit; + opacity: 0.85; +} + +.label { + @include text-label; min-width: 0; - display: grid; - gap: 0.05rem; } -.projectItemDescription { - color: color-mix(in srgb, var(--color-text-muted) 84%, transparent); +.itemMeta { + @include text-caption; + color: var(--color-text-muted); +} + +@media (max-width: 720px) { + .rootCompact .scrim, + .rootCompact .drawer { + width: min(18rem, calc(100vw - 5rem)); + } } diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index 1d7fbea..bc4d5ff 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -1,8 +1,17 @@ // Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx -import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; -import { ChevronDown, Folder } from "../../../lib/icons"; +import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"; +import { ChevronDown, ChevronRight, Folder, LayoutGrid } from "../../../lib/icons"; +import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu"; import { useAppShellData } from "../data/app-shell.context"; +import { + createProjectFolderTarget, + createProjectSurfaceTarget, + createProjectTarget, + type ProjectItem, + type ProjectMenuTarget, +} from "../data/shell.data"; +import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController"; import styles from "./ProjectSelector.module.scss"; type ProjectSelectorProps = { @@ -16,37 +25,105 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { const appShellData = useAppShellData(); const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject()); const [drawerTop, setDrawerTop] = createSignal(0); + const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); + let rootRef: HTMLDivElement | undefined; let triggerRef: HTMLButtonElement | undefined; + let contextMenuRef: HTMLDivElement | undefined; + const contextMenu = createProjectContextMenuController(); + + const projectFolders = createMemo(() => { + const sections = new Map(); + + for (const item of appShellData.projectItems()) { + const key = item.parentLabel || item.groupLabel || "Projects"; + const existing = sections.get(key); + + if (existing) { + existing.push(item); + continue; + } + + sections.set(key, [item]); + } + + return Array.from(sections.entries()).map(([label, items]) => ({ + id: label.toLowerCase().replace(/\s+/g, "-"), + label, + meta: items[0]?.groupLabel && items[0].groupLabel !== label ? items[0].groupLabel : undefined, + items, + })); + }); + + const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId); + + const toggleFolder = (folderId: string): void => { + setCollapsedFolderIds((current) => + current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId], + ); + }; createEffect(() => { setSelectedProject(appShellData.activeProject()); }); onMount(() => { - if (!triggerRef) { - return; + if (triggerRef) { + const updateDrawerTop = (): void => { + if (!triggerRef) { + return; + } + + setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8); + }; + + updateDrawerTop(); + + const observer = new ResizeObserver(() => { + updateDrawerTop(); + }); + + observer.observe(triggerRef); + window.addEventListener("resize", updateDrawerTop); + + onCleanup(() => { + observer.disconnect(); + window.removeEventListener("resize", updateDrawerTop); + }); } - const updateDrawerTop = (): void => { - if (!triggerRef) { + const handlePointerDown = (event: PointerEvent): void => { + if (!props.isOpen || !rootRef) { return; } - setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight); + const target = event.target; + + if (target instanceof Node && rootRef.contains(target)) { + return; + } + + if (target instanceof Node && contextMenuRef?.contains(target)) { + return; + } + + props.onClose(); }; - updateDrawerTop(); + const handleEscape = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || !props.isOpen) { + return; + } - const observer = new ResizeObserver(() => { - updateDrawerTop(); - }); + props.onClose(); + triggerRef?.focus(); + }; - observer.observe(triggerRef); - window.addEventListener("resize", updateDrawerTop); + document.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleEscape); onCleanup(() => { - observer.disconnect(); - window.removeEventListener("resize", updateDrawerTop); + document.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleEscape); }); }); @@ -70,8 +147,18 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { props.onClose(); }; + const handleContextActionSelect = (_action: { id: string; label: string }, _target: ProjectMenuTarget): void => { + // Initial implementation keeps the project menu aligned with workspace-menu IA. + }; + + const handleSurfaceContextMenu = (event: MouseEvent): void => { + event.stopPropagation(); + contextMenu.openMenu(event, createProjectSurfaceTarget("Projects")); + }; + return (
{ "--project-drawer-top": `${drawerTop()}px`, }} > - {/* Project trigger */} - {/* Outside-click scrim */} - + + +
    + + {(item): JSX.Element => { + const isSelected = (): boolean => selectedProject().id === item.id; + + return ( +
  • + +
  • + ); + }} +
    +
+
+ + ); + }} + + +
+ + + + + { + const state = contextMenu.menuState(); + return state ? { x: state.x, y: state.y } : null; + })()} + menuRef={(element) => { + contextMenuRef = element; + contextMenu.setMenuRef(element); }} - aria-hidden={!props.isOpen} - tabIndex={props.isOpen ? 0 : -1} - onClick={props.onClose} + onClose={contextMenu.closeMenu} + onSelect={handleContextActionSelect} /> - - {/* Slide-out project list */} -
-
-
    - - {(item): JSX.Element => { - const isSelected = (): boolean => selectedProject().id === item.id; - - return ( -
  • - -
  • - ); - }} -
    -
-
-
); }; diff --git a/Frontend/src/components/shell/data/app-shell.context.tsx b/Frontend/src/components/shell/data/app-shell.context.tsx index 68ac064..5b00c16 100644 --- a/Frontend/src/components/shell/data/app-shell.context.tsx +++ b/Frontend/src/components/shell/data/app-shell.context.tsx @@ -194,6 +194,16 @@ const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectIte id: project.id, name: project.name, description: project.slug || "Persisted project workspace", + groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects", + parentLabel: + payload.teams.find((team) => team.id === project.teamId)?.name || + payload.departments.find((department) => department.id === project.departmentId)?.name || + "Shared project", + meta: (() => { + const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length; + + return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined; + })(), active: index === 0, })); }; diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts index 7dc0e91..a1d1081 100644 --- a/Frontend/src/components/shell/data/shell.data.ts +++ b/Frontend/src/components/shell/data/shell.data.ts @@ -71,9 +71,43 @@ export type ProjectItem = { id: string; name: string; description: string; + groupLabel?: string; + parentLabel?: string; + meta?: string; active?: boolean; }; +export type ProjectMenuTarget = + | { + id: string; + label: string; + kind: "surface"; + } + | { + id: string; + label: string; + kind: "folder"; + } + | { + id: string; + label: string; + kind: "project"; + }; + +export type ProjectContextMenuAction = { + id: string; + label: string; + tone?: "default" | "danger"; + shortcut?: WorkspaceContextMenuShortcut; + children?: readonly ProjectContextMenuAction[]; +}; + +export type ProjectContextMenuSection = { + id: string; + label?: string; + items: readonly ProjectContextMenuAction[]; +}; + export type SidebarItem = { id: string; label: string; @@ -364,9 +398,31 @@ export const activeDepartment: ActiveDepartment = { }; export const projectItems: readonly ProjectItem[] = [ - { id: "general", name: "General", description: "Default shared project", active: true }, - { id: "operations", name: "Operations", description: "Cross-team planning and delivery" }, - { id: "hiring", name: "Hiring", description: "Candidate pipeline and interview loops" }, + { + id: "general", + name: "General", + description: "Default shared project", + groupLabel: "Shared space", + parentLabel: "Workspace home", + meta: "1 workspace", + active: true, + }, + { + id: "operations", + name: "Operations", + description: "Cross-team planning and delivery", + groupLabel: "Team folders", + parentLabel: "Shared Services", + meta: "2 workspaces", + }, + { + id: "hiring", + name: "Hiring", + description: "Candidate pipeline and interview loops", + groupLabel: "Team folders", + parentLabel: "People Ops", + meta: "1 workspace", + }, ] as const; export const departmentItems: readonly DepartmentItem[] = [ @@ -533,6 +589,69 @@ export const getWorkspaceContextMenuSections = ( } }; +const getProjectCreateActions = (): readonly ProjectContextMenuAction[] => + [ + { id: "new-project", label: "New project" }, + { id: "new-folder", label: "New folder" }, + ] as const; + +export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({ + id: "project-surface", + label, + kind: "surface", +}); + +export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({ + id, + label, + kind: "folder", +}); + +export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({ + id: project.id, + label: project.name, + kind: "project", +}); + +export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => { + switch (target.kind) { + case "surface": + return "Projects"; + case "folder": + return "Folder"; + case "project": + return "Project"; + } +}; + +export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => { + const createActions = getProjectCreateActions(); + + switch (target.kind) { + case "surface": + return [ + { + id: "create", + items: createActions, + }, + ] as const; + case "folder": + return [ + { + id: "create", + items: createActions, + }, + ] as const; + case "project": + return [ + { + id: "create", + items: createActions, + }, + ] as const; + } +}; + export const topBarActions: readonly TopBarAction[] = [ { id: "search", label: "Search", icon: Search }, ] as const;