UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: portal popup рабочей области и sidebar-search

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 18:32:36 +03:00
parent b46390ccdd
commit 01eb3d4c8a
4 changed files with 232 additions and 111 deletions

View File

@ -4,10 +4,11 @@
* See the LICENSE file for details.
*/
import { useState, useMemo, useCallback, useEffect } from "react";
import { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { createPortal } from "react-dom";
// hooks
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
@ -37,6 +38,14 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [sidebarSearchPosition, setSidebarSearchPosition] = useState<{
left: number;
top: number;
width: number;
} | null>(null);
const sidebarSearchPortalRef = useRef<HTMLDivElement>(null);
const sidebarSearchButtonRef = useRef<HTMLButtonElement>(null);
// store hooks
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
@ -59,6 +68,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
openPanel,
} = useExpandableSearch({
onClose: handleOnClose,
additionalRefs: [sidebarSearchPortalRef],
});
// derived values
@ -113,6 +123,37 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
inputRef.current?.focus();
};
const updateSidebarSearchPosition = useCallback(() => {
if (variant !== "sidebar" || !sidebarSearchButtonRef.current || typeof window === "undefined") return;
const rect = sidebarSearchButtonRef.current.getBoundingClientRect();
const width = 320;
const viewportPadding = 16;
const left = Math.min(rect.left, window.innerWidth - width - viewportPadding);
const top = rect.top;
setSidebarSearchPosition({
left,
top,
width,
});
}, [variant]);
useLayoutEffect(() => {
if (!isOpen || variant !== "sidebar") return;
updateSidebarSearchPosition();
const handlePositionUpdate = () => updateSidebarSearchPosition();
window.addEventListener("resize", handlePositionUpdate);
window.addEventListener("scroll", handlePositionUpdate, true);
return () => {
window.removeEventListener("resize", handlePositionUpdate);
window.removeEventListener("scroll", handlePositionUpdate, true);
};
}, [isOpen, updateSidebarSearchPosition, variant]);
// Handle command selection
const handleCommandSelect = useCallback(
(command: TPowerKCommandConfig) => {
@ -211,6 +252,37 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel]
);
const searchCommandContent = (
<Command
filter={(i18nValue: string, search: string) => {
if (i18nValue === "no-results") return 1;
if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
shouldFilter={searchTerm.length > 0}
className="flex h-full w-full flex-col"
>
<Command.Input value={searchTerm} hidden />
<Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto px-2 pb-4 outline-none">
<ProjectsAppPowerKCommandsList
activePage={activePage}
context={context}
handleCommandSelect={handleCommandSelect}
handlePageDataSelection={handlePageDataSelection}
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
handleSearchMenuClose={() => closePanel()}
/>
</Command.List>
<PowerKModalFooter
isWorkspaceLevel={isWorkspaceLevel}
projectId={context.params.projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
</Command>
);
return (
<div ref={containerRef} className="relative">
{variant === "top-navigation" ? (
@ -252,15 +324,13 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</div>
</div>
) : (
<div
className={cn("relative z-30 h-8 transition-all duration-300 ease-in-out", {
"w-[19.5rem]": isOpen,
"w-8": !isOpen,
})}
>
<div className="relative z-30 h-8 w-8">
<button
ref={sidebarSearchButtonRef}
type="button"
className="absolute left-0 top-0 z-10 flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
className={cn(
"absolute left-0 top-0 z-[161] flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
)}
onClick={() => {
if (isOpen) {
closePanel();
@ -273,15 +343,39 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
>
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
</button>
</div>
)}
{variant === "top-navigation" && (
<div
className={cn(
"flex h-8 w-full items-center overflow-hidden rounded-full transition-all duration-300",
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out",
{
"nodedc-glass-surface pl-10 pr-3": isOpen,
"border-transparent bg-transparent pl-0 pr-0 shadow-none": !isOpen,
"max-h-[80vh] w-[574px] opacity-100": isOpen,
"h-0 w-0 opacity-0": !isOpen,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10":
true,
}
)}
>
{isOpen && searchCommandContent}
</div>
)}
{variant === "sidebar" &&
isOpen &&
sidebarSearchPosition &&
typeof document !== "undefined" &&
createPortal(
<div
ref={sidebarSearchPortalRef}
className="fixed z-[160]"
style={{
left: `${sidebarSearchPosition.left}px`,
top: `${sidebarSearchPosition.top}px`,
width: `${sidebarSearchPosition.width}px`,
}}
>
<div className="relative">
<div className="nodedc-glass-modal nodedc-glass-surface flex h-8 w-full items-center overflow-hidden rounded-full pl-11 pr-3">
<input
ref={inputRef}
type="text"
@ -294,68 +388,22 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
className={cn(
"placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none transition-all",
{
"pointer-events-none w-0 opacity-0": !isOpen,
}
)}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
autoFocus
/>
{isOpen && searchTerm && (
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
</div>
<div className="nodedc-glass-modal nodedc-glass-surface mt-3 flex max-h-[70vh] w-full flex-col overflow-hidden rounded-[1.5rem] pt-3">
{searchCommandContent}
</div>
)}
<div
className={cn(
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] w-[574px] opacity-100": isOpen && variant === "top-navigation",
"max-h-[70vh] w-[20rem] opacity-100": isOpen && variant === "sidebar",
"h-0 w-0 opacity-0": !isOpen,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-surface bottom-11 left-0 rounded-[1.5rem] pt-3": variant === "sidebar",
}
)}
>
{isOpen && (
<Command
filter={(i18nValue: string, search: string) => {
if (i18nValue === "no-results") return 1;
if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
shouldFilter={searchTerm.length > 0}
className="flex h-full w-full flex-col"
>
<Command.Input value={searchTerm} hidden />
{/* We can skip the header input since we have the main input above,
but we might need the context indicator if we want that feature.
For now, let's just render the list. */}
<Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto px-2 pb-4 outline-none">
<ProjectsAppPowerKCommandsList
activePage={activePage}
context={context}
handleCommandSelect={handleCommandSelect}
handlePageDataSelection={handlePageDataSelection}
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
handleSearchMenuClose={() => closePanel()}
/>
</Command.List>
<PowerKModalFooter
isWorkspaceLevel={isWorkspaceLevel}
projectId={context.params.projectId?.toString()}
onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
</Command>
)}
</div>
</div>,
document.body
)}
</div>
);
});

View File

@ -4,9 +4,10 @@
* See the LICENSE file for details.
*/
import { Fragment, useState, useEffect } from "react";
import { Fragment, useState, useEffect, useCallback, useLayoutEffect, useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { createPortal } from "react-dom";
// icons
import { CirclePlus, LogOut, Mails } from "lucide-react";
// ui
@ -48,6 +49,13 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
const { t } = useTranslation();
// local state
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false);
const [sidebarPanelMenuPosition, setSidebarPanelMenuPosition] = useState<{
left: number;
top: number;
width: number;
} | null>(null);
const sidebarPanelButtonRef = useRef<HTMLButtonElement>(null);
const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id });
@ -69,11 +77,40 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
// TODO: fix workspaces list scroll
const updateSidebarPanelMenuPosition = useCallback(() => {
if (variant !== "sidebar-panel" || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
const rect = sidebarPanelButtonRef.current.getBoundingClientRect();
const width = 480;
const viewportPadding = 16;
setSidebarPanelMenuPosition({
left: Math.min(rect.left, window.innerWidth - width - viewportPadding),
top: rect.bottom + 8,
width,
});
}, [variant]);
// Toggle sidebar dropdown state when either menu is open
useEffect(() => {
toggleAnySidebarDropdown(isWorkspaceMenuOpen);
}, [isWorkspaceMenuOpen, toggleAnySidebarDropdown]);
useLayoutEffect(() => {
if (!isWorkspaceMenuOpen || variant !== "sidebar-panel") return;
updateSidebarPanelMenuPosition();
const handlePositionUpdate = () => updateSidebarPanelMenuPosition();
window.addEventListener("resize", handlePositionUpdate);
window.addEventListener("scroll", handlePositionUpdate, true);
return () => {
window.removeEventListener("resize", handlePositionUpdate);
window.removeEventListener("scroll", handlePositionUpdate, true);
};
}, [isWorkspaceMenuOpen, updateSidebarPanelMenuPosition, variant]);
return (
<Menu
as="div"
@ -138,6 +175,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
)}
{variant === "sidebar-panel" && (
<Menu.Button
ref={sidebarPanelButtonRef}
className={cn(
"group/menu-button flex w-full items-center justify-between gap-2 px-0 py-1 text-left text-13 font-medium text-secondary transition-all focus:outline-none",
{
@ -165,26 +203,31 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="trnsform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items as={Fragment}>
<div
{(() => {
const menuItems = (
<Menu.Items
as="div"
className={cn(
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y outline-none",
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y overflow-hidden outline-none",
{
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200": variant !== "sidebar-panel",
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200":
variant !== "sidebar-panel",
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-surface absolute top-full left-0 z-[140] mt-2 rounded-[1.5rem] divide-white/10": variant === "sidebar-panel",
"nodedc-glass-modal nodedc-glass-surface rounded-[1.5rem] divide-white/10":
variant === "sidebar-panel",
}
)}
style={
variant === "sidebar-panel" && sidebarPanelMenuPosition
? {
position: "fixed",
left: `${sidebarPanelMenuPosition.left}px`,
top: `${sidebarPanelMenuPosition.top}px`,
width: `${sidebarPanelMenuPosition.width}px`,
}
: undefined
}
>
<div className="vertical-scrollbar flex scrollbar-sm max-h-96 flex-col items-start justify-start overflow-x-hidden overflow-y-scroll">
<span
@ -261,9 +304,28 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</Menu.Item>
</div>
</div>
</div>
</Menu.Items>
);
if (variant === "sidebar-panel") {
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
return createPortal(menuItems, document.body);
}
return (
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="trnsform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{menuItems}
</Transition>
);
})()}
</>
);
}}

View File

@ -4,11 +4,12 @@
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { useOutsideClickDetector } from "@plane/hooks";
type UseExpandableSearchOptions = {
onClose?: () => void;
additionalRefs?: Array<RefObject<HTMLElement | null>>;
};
/**
@ -17,7 +18,7 @@ type UseExpandableSearchOptions = {
* Opens on click, typing, or keyboard shortcut (via PowerK Cmd+F)
*/
export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
const { onClose } = options || {};
const { onClose, additionalRefs = [] } = options || {};
// states
const [isOpen, setIsOpen] = useState(false);
@ -28,6 +29,15 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
const wasClickedRef = useRef<boolean>(false);
const wasKeyboardTriggeredRef = useRef<boolean>(false);
const isWithinTrackedElements = useCallback(
(target: Node | null) => {
if (!target) return false;
if (containerRef.current?.contains(target)) return true;
return additionalRefs.some((ref) => ref.current?.contains(target));
},
[additionalRefs]
);
// Handle close
const handleClose = useCallback(() => {
setIsOpen(false);
@ -51,7 +61,7 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (containerRef.current?.contains(target)) return;
if (isWithinTrackedElements(target)) return;
handleClose();
};
@ -59,7 +69,7 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isOpen, handleClose]);
}, [handleClose, isOpen, isWithinTrackedElements]);
// Track keyboard shortcuts that trigger focus (Cmd+F / Ctrl+F)
useEffect(() => {

View File

@ -218,14 +218,15 @@
}
.nodedc-glass-surface {
background: rgba(11, 11, 14, 0.82);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%),
rgba(9, 9, 12, 0.88);
@apply border border-subtle/70 backdrop-blur-2xl;
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 20px 56px rgba(0, 0, 0, 0.34),
0 4px 16px rgba(0, 0, 0, 0.18);
isolation: isolate;
}
.nodedc-glass-modal [data-slot="button"],