[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 { 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..."
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
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: {
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue