[WEB-5536] feat: prevent search panels from reopening on programmatic focus restoration (#8207)

This commit is contained in:
Anmol Singh Bhatia 2025-12-01 18:37:06 +05:30 committed by GitHub
parent f428c3bdaf
commit 980428b204
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 125 additions and 47 deletions

View file

@ -1,9 +1,8 @@
import { useState, useRef, useMemo, useCallback, useEffect } from "react"; import { useState, useMemo, useCallback, useEffect } 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";
// hooks // hooks
import { useOutsideClickDetector } from "@plane/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";
// power-k // power-k
@ -14,6 +13,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k"; import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
export const TopNavPowerK = observer(() => { export const TopNavPowerK = observer(() => {
// router // router
@ -22,7 +22,6 @@ export const TopNavPowerK = observer(() => {
const { projectId: routerProjectId, workItem: workItemIdentifier } = params; const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
// states // states
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null); const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true); const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
@ -32,6 +31,25 @@ export const TopNavPowerK = observer(() => {
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK(); const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
const { data: currentUser } = useUser(); 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 // derived values
const { const {
issue: { getIssueById, getIssueIdByIdentifier }, issue: { getIssueById, getIssueIdByIdentifier },
@ -54,12 +72,7 @@ export const TopNavPowerK = observer(() => {
projectId, projectId,
}, },
router, router,
closePalette: () => { closePalette: closePanel,
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
},
setActiveCommand, setActiveCommand,
setActivePage, setActivePage,
}), }),
@ -72,12 +85,10 @@ export const TopNavPowerK = observer(() => {
projectId, projectId,
router, router,
setActivePage, setActivePage,
closePanel,
] ]
); );
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Register input ref with PowerK store for keyboard shortcut access // Register input ref with PowerK store for keyboard shortcut access
useEffect(() => { useEffect(() => {
setTopNavInputRef(inputRef); setTopNavInputRef(inputRef);
@ -86,18 +97,6 @@ export const TopNavPowerK = observer(() => {
}; };
}, [setTopNavInputRef]); }, [setTopNavInputRef]);
useOutsideClickDetector(containerRef, () => {
if (isOpen) {
setIsOpen(false);
setActivePage(null);
setActiveCommand(null);
}
});
const handleFocus = () => {
setIsOpen(true);
};
const handleClear = () => { const handleClear = () => {
setSearchTerm(""); setSearchTerm("");
inputRef.current?.focus(); inputRef.current?.focus();
@ -136,10 +135,7 @@ export const TopNavPowerK = observer(() => {
// Cmd/Ctrl+K closes the search dropdown // Cmd/Ctrl+K closes the search dropdown
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault(); e.preventDefault();
setIsOpen(false); closePanel();
setSearchTerm("");
setActivePage(null);
context.setActiveCommand(null);
return; return;
} }
@ -148,9 +144,7 @@ export const TopNavPowerK = observer(() => {
if (searchTerm) { if (searchTerm) {
setSearchTerm(""); setSearchTerm("");
} }
setIsOpen(false); closePanel();
inputRef.current?.blur();
return; return;
} }
@ -203,7 +197,7 @@ export const TopNavPowerK = observer(() => {
return; return;
} }
}, },
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen] [searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel]
); );
return ( return (
@ -228,7 +222,11 @@ export const TopNavPowerK = observer(() => {
ref={inputRef} ref={inputRef}
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus} onFocus={handleFocus}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Search commands..." placeholder="Search commands..."

View file

@ -42,9 +42,10 @@ export const useResponsiveTabLayout = ({
const gap = 4; // gap-1 = 4px const gap = 4; // gap-1 = 4px
const overflowButtonWidth = 40; const overflowButtonWidth = 40;
const container = containerRef?.current;
// ResizeObserver to measure container width // ResizeObserver to measure container width
useEffect(() => { useEffect(() => {
const container = containerRef.current;
if (!container) return; if (!container) return;
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
@ -58,7 +59,7 @@ export const useResponsiveTabLayout = ({
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
}; };
}, []); }, [container]);
// Calculate how many items can fit // Calculate how many items can fit
useEffect(() => { useEffect(() => {

View file

@ -49,16 +49,18 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
<div className="flex items-center justify-between gap-2 px-2"> <div className="flex items-center justify-between gap-2 px-2">
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span> <span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
<div className="flex items-center gap-2"> {title === "Projects" && (
<button <div className="flex items-center gap-2">
type="button" <button
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90" type="button"
onClick={() => setIsCustomizeNavDialogOpen(true)} className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
> onClick={() => setIsCustomizeNavDialogOpen(true)}
<PreferencesIcon className="size-4" /> >
</button> <PreferencesIcon className="size-4" />
<AppSidebarToggleButton /> </button>
</div> <AppSidebarToggleButton />
</div>
)}
</div> </div>
{/* Quick actions */} {/* Quick actions */}
{quickActions} {quickActions}

View file

@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const wasClickedRef = useRef<boolean>(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,
};
};

View file

@ -28,7 +28,8 @@ export default {
project_empty_state: { project_empty_state: {
no_access: { no_access: {
title: "Es scheint, als hätten Sie keinen Zugriff auf dieses Projekt", 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.", join_description: "Klicken Sie unten auf die Schaltfläche, um beizutreten.",
cta_primary: "Projekt beitreten", cta_primary: "Projekt beitreten",
cta_loading: "Projekt wird beigetreten", cta_loading: "Projekt wird beigetreten",

View file

@ -28,7 +28,8 @@ export default {
project_empty_state: { project_empty_state: {
no_access: { no_access: {
title: "Parece que você não tem acesso a este projeto", 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.", join_description: "Clique no botão abaixo para participar.",
cta_primary: "Participar do projeto", cta_primary: "Participar do projeto",
cta_loading: "Participando do projeto", cta_loading: "Participando do projeto",