Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 31 additions & 33 deletions apps/web/core/components/navigation/top-nav-power-k.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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 },
Expand All @@ -54,12 +72,7 @@ export const TopNavPowerK = observer(() => {
projectId,
},
router,
closePalette: () => {
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
},
closePalette: closePanel,
setActiveCommand,
setActivePage,
}),
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

Expand All @@ -148,9 +144,7 @@ export const TopNavPowerK = observer(() => {
if (searchTerm) {
setSearchTerm("");
}
setIsOpen(false);
inputRef.current?.blur();

closePanel();
return;
}

Expand Down Expand Up @@ -203,7 +197,7 @@ export const TopNavPowerK = observer(() => {
return;
}
},
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel]
);

return (
Expand All @@ -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..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -58,7 +59,7 @@ export const useResponsiveTabLayout = ({
return () => {
resizeObserver.disconnect();
};
}, []);
}, [container]);

// Calculate how many items can fit
useEffect(() => {
Expand Down
22 changes: 12 additions & 10 deletions apps/web/core/components/sidebar/sidebar-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
75 changes: 75 additions & 0 deletions apps/web/core/hooks/use-expandable-search.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
3 changes: 2 additions & 1 deletion packages/i18n/src/locales/de/empty-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/i18n/src/locales/pt-BR/empty-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading