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

View File

@ -4,9 +4,10 @@
* See the LICENSE file for details. * 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 { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { createPortal } from "react-dom";
// icons // icons
import { CirclePlus, LogOut, Mails } from "lucide-react"; import { CirclePlus, LogOut, Mails } from "lucide-react";
// ui // ui
@ -48,6 +49,13 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
const { t } = useTranslation(); const { t } = useTranslation();
// local state // local state
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false); 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 }); 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 ?? {})); const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
// TODO: fix workspaces list scroll // 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 // Toggle sidebar dropdown state when either menu is open
useEffect(() => { useEffect(() => {
toggleAnySidebarDropdown(isWorkspaceMenuOpen); toggleAnySidebarDropdown(isWorkspaceMenuOpen);
}, [isWorkspaceMenuOpen, toggleAnySidebarDropdown]); }, [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 ( return (
<Menu <Menu
as="div" as="div"
@ -138,6 +175,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
)} )}
{variant === "sidebar-panel" && ( {variant === "sidebar-panel" && (
<Menu.Button <Menu.Button
ref={sidebarPanelButtonRef}
className={cn( 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", "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> </Menu.Button>
)} )}
<Transition {(() => {
as={Fragment} const menuItems = (
enter="transition ease-out duration-100" <Menu.Items
enterFrom="transform opacity-0 scale-95" as="div"
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
className={cn( 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-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation", "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"> <div className="vertical-scrollbar flex scrollbar-sm max-h-96 flex-col items-start justify-start overflow-x-hidden overflow-y-scroll">
<span <span
@ -261,9 +304,28 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</Menu.Item> </Menu.Item>
</div> </div>
</div> </div>
</div> </Menu.Items>
</Menu.Items> );
</Transition>
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. * 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"; import { useOutsideClickDetector } from "@plane/hooks";
type UseExpandableSearchOptions = { type UseExpandableSearchOptions = {
onClose?: () => void; onClose?: () => void;
additionalRefs?: Array<RefObject<HTMLElement | null>>;
}; };
/** /**
@ -17,7 +18,7 @@ type UseExpandableSearchOptions = {
* Opens on click, typing, or keyboard shortcut (via PowerK Cmd+F) * Opens on click, typing, or keyboard shortcut (via PowerK Cmd+F)
*/ */
export const useExpandableSearch = (options?: UseExpandableSearchOptions) => { export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
const { onClose } = options || {}; const { onClose, additionalRefs = [] } = options || {};
// states // states
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -28,6 +29,15 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
const wasClickedRef = useRef<boolean>(false); const wasClickedRef = useRef<boolean>(false);
const wasKeyboardTriggeredRef = 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 // Handle close
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setIsOpen(false); setIsOpen(false);
@ -51,7 +61,7 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null; const target = event.target as Node | null;
if (!target) return; if (!target) return;
if (containerRef.current?.contains(target)) return; if (isWithinTrackedElements(target)) return;
handleClose(); handleClose();
}; };
@ -59,7 +69,7 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
return () => { return () => {
document.removeEventListener("pointerdown", handlePointerDown, true); document.removeEventListener("pointerdown", handlePointerDown, true);
}; };
}, [isOpen, handleClose]); }, [handleClose, isOpen, isWithinTrackedElements]);
// Track keyboard shortcuts that trigger focus (Cmd+F / Ctrl+F) // Track keyboard shortcuts that trigger focus (Cmd+F / Ctrl+F)
useEffect(() => { useEffect(() => {

View File

@ -218,14 +218,15 @@
} }
.nodedc-glass-surface { .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); -webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px); backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: box-shadow:
0 20px 56px rgba(0, 0, 0, 0.34), 0 20px 56px rgba(0, 0, 0, 0.34),
0 4px 16px rgba(0, 0, 0, 0.18); 0 4px 16px rgba(0, 0, 0, 0.18);
isolation: isolate;
} }
.nodedc-glass-modal [data-slot="button"], .nodedc-glass-modal [data-slot="button"],