[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 { 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<TPowerKCommandConfig | null>(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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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..."

View file

@ -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(() => {

View file

@ -49,6 +49,7 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
<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>
{title === "Projects" && (
<div className="flex items-center gap-2">
<button
type="button"
@ -59,6 +60,7 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
</button>
<AppSidebarToggleButton />
</div>
)}
</div>
{/* Quick actions */}
{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: {
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",

View file

@ -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",