[WEB-5536] feat: prevent search panels from reopening on programmatic focus restoration (#8207)
This commit is contained in:
parent
f428c3bdaf
commit
980428b204
6 changed files with 125 additions and 47 deletions
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -49,16 +49,18 @@ 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="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"
|
||||
onClick={() => setIsCustomizeNavDialogOpen(true)}
|
||||
>
|
||||
<PreferencesIcon className="size-4" />
|
||||
</button>
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
{title === "Projects" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="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"
|
||||
onClick={() => setIsCustomizeNavDialogOpen(true)}
|
||||
>
|
||||
<PreferencesIcon className="size-4" />
|
||||
</button>
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick actions */}
|
||||
{quickActions}
|
||||
|
|
|
|||
75
apps/web/core/hooks/use-expandable-search.ts
Normal file
75
apps/web/core/hooks/use-expandable-search.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue