UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: portal popup рабочей области и sidebar-search
This commit is contained in:
parent
b46390ccdd
commit
01eb3d4c8a
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue