From 01eb3d4c8a6e344803985bf2be0f14c5ea980525 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 19 Apr 2026 18:32:36 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20portal=20po?= =?UTF-8?q?pup=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B5=D0=B9=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=82=D0=B8=20=D0=B8=20sidebar-search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/navigation/top-nav-power-k.tsx | 220 +++++++++++------- .../workspace/sidebar/workspace-menu-root.tsx | 98 ++++++-- .../web/core/hooks/use-expandable-search.ts | 18 +- plane-src/apps/web/styles/globals.css | 7 +- 4 files changed, 232 insertions(+), 111 deletions(-) diff --git a/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx b/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx index 298bd26..c741a3f 100644 --- a/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx +++ b/plane-src/apps/web/core/components/navigation/top-nav-power-k.tsx @@ -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(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(null); + const sidebarSearchButtonRef = useRef(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 = ( + { + 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" + > + + ); + return (
{variant === "top-navigation" ? ( @@ -252,15 +324,13 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
) : ( -
+
-
- { - 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 && ( - - )} -
)} -
- {isOpen && ( - { - if (i18nValue === "no-results") return 1; - if (i18nValue.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; + {variant === "top-navigation" && ( +
+ {isOpen && searchCommandContent} +
+ )} + {variant === "sidebar" && + isOpen && + sidebarSearchPosition && + typeof document !== "undefined" && + createPortal( +
0} - className="flex h-full w-full flex-col" > -
, + document.body )} -
); }); diff --git a/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx b/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx index 55a01f3..a54b853 100644 --- a/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx +++ b/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx @@ -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(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 ( )} - - -
{ + const menuItems = ( +
- -
-
+ + ); + + if (variant === "sidebar-panel") { + if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null; + return createPortal(menuItems, document.body); + } + + return ( + + {menuItems} + + ); + })()} ); }} diff --git a/plane-src/apps/web/core/hooks/use-expandable-search.ts b/plane-src/apps/web/core/hooks/use-expandable-search.ts index 28926d3..7eac4d0 100644 --- a/plane-src/apps/web/core/hooks/use-expandable-search.ts +++ b/plane-src/apps/web/core/hooks/use-expandable-search.ts @@ -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>; }; /** @@ -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(false); const wasKeyboardTriggeredRef = useRef(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(() => { diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index a3f7bee..3e5cb10 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -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"],