diff --git a/apps/web/core/components/navigation/top-nav-power-k.tsx b/apps/web/core/components/navigation/top-nav-power-k.tsx index 6ca9af0a4a0..054aceaf322 100644 --- a/apps/web/core/components/navigation/top-nav-power-k.tsx +++ b/apps/web/core/components/navigation/top-nav-power-k.tsx @@ -1,9 +1,8 @@ -import { useState, useRef, useMemo, useCallback, useEffect } from "react"; +import { useState, useMemo, useCallback, useEffect } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // hooks -import { useOutsideClickDetector } from "@plane/hooks"; import { CloseIcon, SearchIcon } from "@plane/propel/icons"; import { cn } from "@plane/utils"; // power-k @@ -14,6 +13,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { usePowerK } from "@/hooks/store/use-power-k"; import { useUser } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; +import { useExpandableSearch } from "@/hooks/use-expandable-search"; export const TopNavPowerK = observer(() => { // router @@ -22,7 +22,6 @@ export const TopNavPowerK = observer(() => { const { projectId: routerProjectId, workItem: workItemIdentifier } = params; // states - const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [activeCommand, setActiveCommand] = useState(null); const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true); @@ -32,6 +31,25 @@ export const TopNavPowerK = observer(() => { const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK(); const { data: currentUser } = useUser(); + const handleOnClose = useCallback(() => { + setSearchTerm(""); + setActivePage(null); + setActiveCommand(null); + }, [setSearchTerm, setActivePage, setActiveCommand]); + + // expandable search hook + const { + isOpen, + containerRef, + inputRef, + handleClose: closePanel, + handleMouseDown, + handleFocus, + openPanel, + } = useExpandableSearch({ + onClose: handleOnClose, + }); + // derived values const { issue: { getIssueById, getIssueIdByIdentifier }, @@ -54,12 +72,7 @@ export const TopNavPowerK = observer(() => { projectId, }, router, - closePalette: () => { - setIsOpen(false); - setSearchTerm(""); - setActivePage(null); - setActiveCommand(null); - }, + closePalette: closePanel, setActiveCommand, setActivePage, }), @@ -72,12 +85,10 @@ export const TopNavPowerK = observer(() => { projectId, router, setActivePage, + closePanel, ] ); - const containerRef = useRef(null); - const inputRef = useRef(null); - // Register input ref with PowerK store for keyboard shortcut access useEffect(() => { setTopNavInputRef(inputRef); @@ -86,18 +97,6 @@ export const TopNavPowerK = observer(() => { }; }, [setTopNavInputRef]); - useOutsideClickDetector(containerRef, () => { - if (isOpen) { - setIsOpen(false); - setActivePage(null); - setActiveCommand(null); - } - }); - - const handleFocus = () => { - setIsOpen(true); - }; - const handleClear = () => { setSearchTerm(""); inputRef.current?.focus(); @@ -136,10 +135,7 @@ export const TopNavPowerK = observer(() => { // Cmd/Ctrl+K closes the search dropdown if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); - setIsOpen(false); - setSearchTerm(""); - setActivePage(null); - context.setActiveCommand(null); + closePanel(); return; } @@ -148,9 +144,7 @@ export const TopNavPowerK = observer(() => { if (searchTerm) { setSearchTerm(""); } - setIsOpen(false); - inputRef.current?.blur(); - + closePanel(); return; } @@ -203,7 +197,7 @@ export const TopNavPowerK = observer(() => { return; } }, - [searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen] + [searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel] ); return ( @@ -228,7 +222,11 @@ export const TopNavPowerK = observer(() => { ref={inputRef} type="text" value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} + onChange={(e) => { + setSearchTerm(e.target.value); + if (!isOpen) openPanel(); + }} + onMouseDown={handleMouseDown} onFocus={handleFocus} onKeyDown={handleKeyDown} placeholder="Search commands..." diff --git a/apps/web/core/components/navigation/use-responsive-tab-layout.ts b/apps/web/core/components/navigation/use-responsive-tab-layout.ts index 3e2126ece41..4e91fea85d8 100644 --- a/apps/web/core/components/navigation/use-responsive-tab-layout.ts +++ b/apps/web/core/components/navigation/use-responsive-tab-layout.ts @@ -42,9 +42,10 @@ export const useResponsiveTabLayout = ({ const gap = 4; // gap-1 = 4px const overflowButtonWidth = 40; + const container = containerRef?.current; + // ResizeObserver to measure container width useEffect(() => { - const container = containerRef.current; if (!container) return; const resizeObserver = new ResizeObserver((entries) => { @@ -58,7 +59,7 @@ export const useResponsiveTabLayout = ({ return () => { resizeObserver.disconnect(); }; - }, []); + }, [container]); // Calculate how many items can fit useEffect(() => { diff --git a/apps/web/core/components/sidebar/sidebar-wrapper.tsx b/apps/web/core/components/sidebar/sidebar-wrapper.tsx index c458f74efcc..a80155986e3 100644 --- a/apps/web/core/components/sidebar/sidebar-wrapper.tsx +++ b/apps/web/core/components/sidebar/sidebar-wrapper.tsx @@ -49,16 +49,18 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
{title} -
- - -
+ {title === "Projects" && ( +
+ + +
+ )}
{/* Quick actions */} {quickActions} diff --git a/apps/web/core/hooks/use-expandable-search.ts b/apps/web/core/hooks/use-expandable-search.ts new file mode 100644 index 00000000000..583a35c3c78 --- /dev/null +++ b/apps/web/core/hooks/use-expandable-search.ts @@ -0,0 +1,75 @@ +import { useCallback, useRef, useState } from "react"; +import { useOutsideClickDetector } from "@plane/hooks"; + +type UseExpandableSearchOptions = { + onClose?: () => void; +}; + +/** + * Custom hook for expandable search input behavior + * Handles focus management to prevent unwanted opening on programmatic focus restoration + */ +export const useExpandableSearch = (options?: UseExpandableSearchOptions) => { + const { onClose } = options || {}; + + // states + const [isOpen, setIsOpen] = useState(false); + + // refs + const containerRef = useRef(null); + const inputRef = useRef(null); + const wasClickedRef = useRef(false); + + // Handle close + const handleClose = useCallback(() => { + setIsOpen(false); + inputRef.current?.blur(); + onClose?.(); + }, [onClose]); + + // Outside click handler - memoized to prevent unnecessary re-registrations + const handleOutsideClick = useCallback(() => { + if (isOpen) { + handleClose(); + } + }, [isOpen, handleClose]); + + // Outside click detection + useOutsideClickDetector(containerRef, handleOutsideClick); + + // Track explicit clicks + const handleMouseDown = useCallback(() => { + wasClickedRef.current = true; + }, []); + + // Only open on explicit clicks, not programmatic focus + const handleFocus = useCallback(() => { + if (wasClickedRef.current) { + setIsOpen(true); + wasClickedRef.current = false; + } + }, []); + + // Helper to open panel (for typing/onChange) + const openPanel = useCallback(() => { + if (!isOpen) { + setIsOpen(true); + } + }, [isOpen]); + + return { + // State + isOpen, + setIsOpen, + + // Refs + containerRef, + inputRef, + + // Handlers + handleClose, + handleMouseDown, + handleFocus, + openPanel, + }; +}; diff --git a/packages/i18n/src/locales/de/empty-state.ts b/packages/i18n/src/locales/de/empty-state.ts index 55dfe580045..087e51e7b47 100644 --- a/packages/i18n/src/locales/de/empty-state.ts +++ b/packages/i18n/src/locales/de/empty-state.ts @@ -28,7 +28,8 @@ export default { project_empty_state: { no_access: { title: "Es scheint, als hätten Sie keinen Zugriff auf dieses Projekt", - restricted_description: "Kontaktieren Sie den Administrator, um Zugriff anzufordern, damit Sie hier fortfahren können.", + restricted_description: + "Kontaktieren Sie den Administrator, um Zugriff anzufordern, damit Sie hier fortfahren können.", join_description: "Klicken Sie unten auf die Schaltfläche, um beizutreten.", cta_primary: "Projekt beitreten", cta_loading: "Projekt wird beigetreten", diff --git a/packages/i18n/src/locales/pt-BR/empty-state.ts b/packages/i18n/src/locales/pt-BR/empty-state.ts index 9ceb00fe5d1..697cb3cb7c6 100644 --- a/packages/i18n/src/locales/pt-BR/empty-state.ts +++ b/packages/i18n/src/locales/pt-BR/empty-state.ts @@ -28,7 +28,8 @@ export default { project_empty_state: { no_access: { title: "Parece que você não tem acesso a este projeto", - restricted_description: "Entre em contato com o administrador para solicitar acesso e você poderá continuar aqui.", + restricted_description: + "Entre em contato com o administrador para solicitar acesso e você poderá continuar aqui.", join_description: "Clique no botão abaixo para participar.", cta_primary: "Participar do projeto", cta_loading: "Participando do projeto",