Skip to content

Commit 980428b

Browse files
[WEB-5536] feat: prevent search panels from reopening on programmatic focus restoration (#8207)
1 parent f428c3b commit 980428b

File tree

6 files changed

+125
-47
lines changed

6 files changed

+125
-47
lines changed

apps/web/core/components/navigation/top-nav-power-k.tsx

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
1+
import { useState, useMemo, useCallback, useEffect } from "react";
22
import { Command } from "cmdk";
33
import { observer } from "mobx-react";
44
import { useParams } from "next/navigation";
55
// hooks
6-
import { useOutsideClickDetector } from "@plane/hooks";
76
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
87
import { cn } from "@plane/utils";
98
// power-k
@@ -14,6 +13,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
1413
import { usePowerK } from "@/hooks/store/use-power-k";
1514
import { useUser } from "@/hooks/store/user";
1615
import { useAppRouter } from "@/hooks/use-app-router";
16+
import { useExpandableSearch } from "@/hooks/use-expandable-search";
1717

1818
export const TopNavPowerK = observer(() => {
1919
// router
@@ -22,7 +22,6 @@ export const TopNavPowerK = observer(() => {
2222
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
2323

2424
// states
25-
const [isOpen, setIsOpen] = useState(false);
2625
const [searchTerm, setSearchTerm] = useState("");
2726
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
2827
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
@@ -32,6 +31,25 @@ export const TopNavPowerK = observer(() => {
3231
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
3332
const { data: currentUser } = useUser();
3433

34+
const handleOnClose = useCallback(() => {
35+
setSearchTerm("");
36+
setActivePage(null);
37+
setActiveCommand(null);
38+
}, [setSearchTerm, setActivePage, setActiveCommand]);
39+
40+
// expandable search hook
41+
const {
42+
isOpen,
43+
containerRef,
44+
inputRef,
45+
handleClose: closePanel,
46+
handleMouseDown,
47+
handleFocus,
48+
openPanel,
49+
} = useExpandableSearch({
50+
onClose: handleOnClose,
51+
});
52+
3553
// derived values
3654
const {
3755
issue: { getIssueById, getIssueIdByIdentifier },
@@ -54,12 +72,7 @@ export const TopNavPowerK = observer(() => {
5472
projectId,
5573
},
5674
router,
57-
closePalette: () => {
58-
setIsOpen(false);
59-
setSearchTerm("");
60-
setActivePage(null);
61-
setActiveCommand(null);
62-
},
75+
closePalette: closePanel,
6376
setActiveCommand,
6477
setActivePage,
6578
}),
@@ -72,12 +85,10 @@ export const TopNavPowerK = observer(() => {
7285
projectId,
7386
router,
7487
setActivePage,
88+
closePanel,
7589
]
7690
);
7791

78-
const containerRef = useRef<HTMLDivElement>(null);
79-
const inputRef = useRef<HTMLInputElement>(null);
80-
8192
// Register input ref with PowerK store for keyboard shortcut access
8293
useEffect(() => {
8394
setTopNavInputRef(inputRef);
@@ -86,18 +97,6 @@ export const TopNavPowerK = observer(() => {
8697
};
8798
}, [setTopNavInputRef]);
8899

89-
useOutsideClickDetector(containerRef, () => {
90-
if (isOpen) {
91-
setIsOpen(false);
92-
setActivePage(null);
93-
setActiveCommand(null);
94-
}
95-
});
96-
97-
const handleFocus = () => {
98-
setIsOpen(true);
99-
};
100-
101100
const handleClear = () => {
102101
setSearchTerm("");
103102
inputRef.current?.focus();
@@ -136,10 +135,7 @@ export const TopNavPowerK = observer(() => {
136135
// Cmd/Ctrl+K closes the search dropdown
137136
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
138137
e.preventDefault();
139-
setIsOpen(false);
140-
setSearchTerm("");
141-
setActivePage(null);
142-
context.setActiveCommand(null);
138+
closePanel();
143139
return;
144140
}
145141

@@ -148,9 +144,7 @@ export const TopNavPowerK = observer(() => {
148144
if (searchTerm) {
149145
setSearchTerm("");
150146
}
151-
setIsOpen(false);
152-
inputRef.current?.blur();
153-
147+
closePanel();
154148
return;
155149
}
156150

@@ -203,7 +197,7 @@ export const TopNavPowerK = observer(() => {
203197
return;
204198
}
205199
},
206-
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
200+
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel]
207201
);
208202

209203
return (
@@ -228,7 +222,11 @@ export const TopNavPowerK = observer(() => {
228222
ref={inputRef}
229223
type="text"
230224
value={searchTerm}
231-
onChange={(e) => setSearchTerm(e.target.value)}
225+
onChange={(e) => {
226+
setSearchTerm(e.target.value);
227+
if (!isOpen) openPanel();
228+
}}
229+
onMouseDown={handleMouseDown}
232230
onFocus={handleFocus}
233231
onKeyDown={handleKeyDown}
234232
placeholder="Search commands..."

apps/web/core/components/navigation/use-responsive-tab-layout.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ export const useResponsiveTabLayout = ({
4242
const gap = 4; // gap-1 = 4px
4343
const overflowButtonWidth = 40;
4444

45+
const container = containerRef?.current;
46+
4547
// ResizeObserver to measure container width
4648
useEffect(() => {
47-
const container = containerRef.current;
4849
if (!container) return;
4950

5051
const resizeObserver = new ResizeObserver((entries) => {
@@ -58,7 +59,7 @@ export const useResponsiveTabLayout = ({
5859
return () => {
5960
resizeObserver.disconnect();
6061
};
61-
}, []);
62+
}, [container]);
6263

6364
// Calculate how many items can fit
6465
useEffect(() => {

apps/web/core/components/sidebar/sidebar-wrapper.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,18 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
4949

5050
<div className="flex items-center justify-between gap-2 px-2">
5151
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
52-
<div className="flex items-center gap-2">
53-
<button
54-
type="button"
55-
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
56-
onClick={() => setIsCustomizeNavDialogOpen(true)}
57-
>
58-
<PreferencesIcon className="size-4" />
59-
</button>
60-
<AppSidebarToggleButton />
61-
</div>
52+
{title === "Projects" && (
53+
<div className="flex items-center gap-2">
54+
<button
55+
type="button"
56+
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
57+
onClick={() => setIsCustomizeNavDialogOpen(true)}
58+
>
59+
<PreferencesIcon className="size-4" />
60+
</button>
61+
<AppSidebarToggleButton />
62+
</div>
63+
)}
6264
</div>
6365
{/* Quick actions */}
6466
{quickActions}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useCallback, useRef, useState } from "react";
2+
import { useOutsideClickDetector } from "@plane/hooks";
3+
4+
type UseExpandableSearchOptions = {
5+
onClose?: () => void;
6+
};
7+
8+
/**
9+
* Custom hook for expandable search input behavior
10+
* Handles focus management to prevent unwanted opening on programmatic focus restoration
11+
*/
12+
export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
13+
const { onClose } = options || {};
14+
15+
// states
16+
const [isOpen, setIsOpen] = useState(false);
17+
18+
// refs
19+
const containerRef = useRef<HTMLDivElement>(null);
20+
const inputRef = useRef<HTMLInputElement>(null);
21+
const wasClickedRef = useRef<boolean>(false);
22+
23+
// Handle close
24+
const handleClose = useCallback(() => {
25+
setIsOpen(false);
26+
inputRef.current?.blur();
27+
onClose?.();
28+
}, [onClose]);
29+
30+
// Outside click handler - memoized to prevent unnecessary re-registrations
31+
const handleOutsideClick = useCallback(() => {
32+
if (isOpen) {
33+
handleClose();
34+
}
35+
}, [isOpen, handleClose]);
36+
37+
// Outside click detection
38+
useOutsideClickDetector(containerRef, handleOutsideClick);
39+
40+
// Track explicit clicks
41+
const handleMouseDown = useCallback(() => {
42+
wasClickedRef.current = true;
43+
}, []);
44+
45+
// Only open on explicit clicks, not programmatic focus
46+
const handleFocus = useCallback(() => {
47+
if (wasClickedRef.current) {
48+
setIsOpen(true);
49+
wasClickedRef.current = false;
50+
}
51+
}, []);
52+
53+
// Helper to open panel (for typing/onChange)
54+
const openPanel = useCallback(() => {
55+
if (!isOpen) {
56+
setIsOpen(true);
57+
}
58+
}, [isOpen]);
59+
60+
return {
61+
// State
62+
isOpen,
63+
setIsOpen,
64+
65+
// Refs
66+
containerRef,
67+
inputRef,
68+
69+
// Handlers
70+
handleClose,
71+
handleMouseDown,
72+
handleFocus,
73+
openPanel,
74+
};
75+
};

packages/i18n/src/locales/de/empty-state.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export default {
2828
project_empty_state: {
2929
no_access: {
3030
title: "Es scheint, als hätten Sie keinen Zugriff auf dieses Projekt",
31-
restricted_description: "Kontaktieren Sie den Administrator, um Zugriff anzufordern, damit Sie hier fortfahren können.",
31+
restricted_description:
32+
"Kontaktieren Sie den Administrator, um Zugriff anzufordern, damit Sie hier fortfahren können.",
3233
join_description: "Klicken Sie unten auf die Schaltfläche, um beizutreten.",
3334
cta_primary: "Projekt beitreten",
3435
cta_loading: "Projekt wird beigetreten",

packages/i18n/src/locales/pt-BR/empty-state.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export default {
2828
project_empty_state: {
2929
no_access: {
3030
title: "Parece que você não tem acesso a este projeto",
31-
restricted_description: "Entre em contato com o administrador para solicitar acesso e você poderá continuar aqui.",
31+
restricted_description:
32+
"Entre em contato com o administrador para solicitar acesso e você poderá continuar aqui.",
3233
join_description: "Clique no botão abaixo para participar.",
3334
cta_primary: "Participar do projeto",
3435
cta_loading: "Participando do projeto",

0 commit comments

Comments
 (0)