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,15 +343,39 @@ 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>
)}
{variant === "top-navigation" && (
<div <div
className={cn( 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, "max-h-[80vh] w-[574px] opacity-100": isOpen,
"border-transparent bg-transparent pl-0 pr-0 shadow-none": !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 <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@ -294,68 +388,22 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
onFocus={handleFocus} onFocus={handleFocus}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Search commands..." placeholder="Search commands..."
className={cn( className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
"placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none transition-all", autoFocus
{
"pointer-events-none w-0 opacity-0": !isOpen,
}
)}
/> />
{isOpen && searchTerm && ( {searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0"> <button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" /> <CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button> </button>
)} )}
</div> </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
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>
</div>,
document.body
)}
</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>
);
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> </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"],