diff --git a/apps/web/src/components/SideNavigation.tsx b/apps/web/src/components/SideNavigation.tsx index 5941a0e2..c5619ed2 100644 --- a/apps/web/src/components/SideNavigation.tsx +++ b/apps/web/src/components/SideNavigation.tsx @@ -4,7 +4,7 @@ import { Button } from "@headlessui/react"; import { t } from "@lingui/core/macro"; import { env } from "next-runtime-env"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { HiBolt } from "react-icons/hi2"; import { TbLayoutSidebarLeftCollapse, @@ -92,56 +92,59 @@ export default function SideNavigation({ href: string; icon: object; keyboardShortcut: KeyboardShortcut; - }[] = [ - { - name: t`Boards`, - href: "/boards", - icon: isDarkMode ? boardsIconDark : boardsIconLight, - keyboardShortcut: { - type: "SEQUENCE", - strokes: [{ key: "G" }, { key: "B" }], - action: () => router.push("/boards"), - group: "NAVIGATION", - description: t`Go to boards`, + }[] = useMemo( + () => [ + { + name: t`Boards`, + href: "/boards", + icon: isDarkMode ? boardsIconDark : boardsIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "B" }], + action: () => router.push("/boards"), + group: "NAVIGATION", + description: t`Go to boards`, + }, }, - }, - { - name: t`Templates`, - href: "/templates", - icon: isDarkMode ? templatesIconDark : templatesIconLight, - keyboardShortcut: { - type: "SEQUENCE", - strokes: [{ key: "G" }, { key: "T" }], - action: () => router.push("/templates"), - group: "NAVIGATION", - description: t`Go to templates`, + { + name: t`Templates`, + href: "/templates", + icon: isDarkMode ? templatesIconDark : templatesIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "T" }], + action: () => router.push("/templates"), + group: "NAVIGATION", + description: t`Go to templates`, + }, }, - }, - { - name: t`Members`, - href: "/members", - icon: isDarkMode ? membersIconDark : membersIconLight, - keyboardShortcut: { - type: "SEQUENCE", - strokes: [{ key: "G" }, { key: "M" }], - action: () => router.push("/members"), - group: "NAVIGATION", - description: t`Go to members`, + { + name: t`Members`, + href: "/members", + icon: isDarkMode ? membersIconDark : membersIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "M" }], + action: () => router.push("/members"), + group: "NAVIGATION", + description: t`Go to members`, + }, }, - }, - { - name: t`Settings`, - href: "/settings", - icon: isDarkMode ? settingsIconDark : settingsIconLight, - keyboardShortcut: { - type: "SEQUENCE", - strokes: [{ key: "G" }, { key: "S" }], - action: () => router.push("/settings"), - group: "NAVIGATION", - description: t`Go to settings`, + { + name: t`Settings`, + href: "/settings", + icon: isDarkMode ? settingsIconDark : settingsIconLight, + keyboardShortcut: { + type: "SEQUENCE", + strokes: [{ key: "G" }, { key: "S" }], + action: () => router.push("/settings"), + group: "NAVIGATION", + description: t`Go to settings`, + }, }, - }, - ]; + ], + [isDarkMode], + ); const toggleCollapse = () => { setIsCollapsed(!isCollapsed); diff --git a/apps/web/src/components/Tooltip.tsx b/apps/web/src/components/Tooltip.tsx index e8eff111..c4004d36 100644 --- a/apps/web/src/components/Tooltip.tsx +++ b/apps/web/src/components/Tooltip.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import type { Root } from "react-dom/client"; -import type { Placement } from "tippy.js"; +import type { Placement, Instance as TippyInstance } from "tippy.js"; import { useEffect, useRef } from "react"; import { createRoot } from "react-dom/client"; import tippy from "tippy.js"; @@ -20,16 +20,16 @@ export function Tooltip({ }: TooltipProps) { const triggerRef = useRef(null); const rootRef = useRef(null); + const tippyRef = useRef(null); + const contentRef = useRef(content); + contentRef.current = content; useEffect(() => { if (!triggerRef.current) return; - if (!content) return; - const container = document.createElement("div"); const root = createRoot(container); rootRef.current = root; - root.render(content); const instance = tippy(triggerRef.current, { content: container, @@ -39,12 +39,33 @@ export function Tooltip({ theme: "tooltip", touch: false, }); + tippyRef.current = instance; + + if (contentRef.current) { + root.render(contentRef.current); + } else { + instance.disable(); + } return () => { instance.destroy(); - rootRef.current?.unmount(); + tippyRef.current = null; + root.unmount(); + rootRef.current = null; }; - }, [content, placement, delay]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [placement, delay]); + + useEffect(() => { + if (!content) { + tippyRef.current?.disable(); + return; + } + if (tippyRef.current) { + tippyRef.current.enable(); + rootRef.current?.render(content); + } + }, [content]); return (
diff --git a/apps/web/src/components/WorkspaceMenu.tsx b/apps/web/src/components/WorkspaceMenu.tsx index e8c54d27..888b5776 100644 --- a/apps/web/src/components/WorkspaceMenu.tsx +++ b/apps/web/src/components/WorkspaceMenu.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { Button, Menu, Transition } from "@headlessui/react"; import { t } from "@lingui/core/macro"; import { env } from "next-runtime-env"; -import { Fragment, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2"; import { twMerge } from "tailwind-merge"; @@ -26,17 +26,22 @@ export default function WorkspaceMenu({ const router = useRouter(); const [isOpen, setIsOpen] = useState(false); - const { tooltipContent: commandPaletteShortcutTooltipContent } = - useKeyboardShortcut({ - type: "PRESS", + const commandPaletteShortcut = useMemo( + () => ({ + type: "PRESS" as const, stroke: { key: "k", - modifiers: ["META"], + modifiers: ["META"] as ("META" | "CONTROL" | "ALT" | "SHIFT")[], }, action: () => setIsOpen(true), description: t`Open command menu`, - group: "GENERAL", - }); + group: "GENERAL" as const, + }), + [], + ); + + const { tooltipContent: commandPaletteShortcutTooltipContent } = + useKeyboardShortcut(commandPaletteShortcut); return ( <> diff --git a/apps/web/src/hooks/useModalFormState.ts b/apps/web/src/hooks/useModalFormState.ts index 7c7f779d..71ce117f 100644 --- a/apps/web/src/hooks/useModalFormState.ts +++ b/apps/web/src/hooks/useModalFormState.ts @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; + import { useModal } from "~/providers/modal"; interface UseModalFormStateOptions { @@ -12,25 +13,43 @@ export function useModalFormState>({ initialValues, resetOnClose = false, }: UseModalFormStateOptions) { - const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal(); - + const { + modalContentType, + isOpen, + getModalState, + setModalState, + clearModalState, + } = useModal(); + const isCurrentModal = modalContentType === modalType; const savedState = getModalState(modalType) as T | undefined; - + // get current form state (using the saved values if available, otherwise the initial values) const formState = savedState || initialValues; - const saveFormState = (state: Partial) => { - if (!isCurrentModal) return; - - const currentState = getModalState(modalType) || initialValues; - const newState = { ...currentState, ...state }; - setModalState(modalType, newState); - }; + // Keep refs so the callbacks below stay stable across re-renders. + const modalTypeRef = useRef(modalType); + const initialValuesRef = useRef(initialValues); + const getModalStateRef = useRef(getModalState); + modalTypeRef.current = modalType; + initialValuesRef.current = initialValues; + getModalStateRef.current = getModalState; - const clearFormState = () => { - clearModalState(modalType); - }; + const saveFormState = useCallback( + (state: Partial) => { + const type = modalTypeRef.current; + const currentState = + getModalStateRef.current(type) ?? initialValuesRef.current; + const newState = { ...currentState, ...state }; + setModalState(type, newState); + }, + // setModalState is stable (useCallback with [] deps in ModalProvider) + [setModalState], + ); + + const clearFormState = useCallback(() => { + clearModalState(modalTypeRef.current); + }, [clearModalState]); useEffect(() => { if (resetOnClose && !isOpen && savedState) { @@ -45,4 +64,4 @@ export function useModalFormState>({ isCurrentModal, hasSavedState: !!savedState, }; -} \ No newline at end of file +} diff --git a/apps/web/src/providers/popup.tsx b/apps/web/src/providers/popup.tsx index 9f03bd8e..b36e1f14 100644 --- a/apps/web/src/providers/popup.tsx +++ b/apps/web/src/providers/popup.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useCallback, useContext, useState } from "react"; interface PopupContextType { isOpen: boolean; @@ -25,24 +25,27 @@ export const PopupProvider: React.FC = ({ children }) => { const [popupMessage, setPopupMessage] = useState(""); const [popupIcon, setPopupIcon] = useState(""); - const showPopup = ({ - header, - message, - icon, - }: { - header: string; - message: string; - icon: string; - }) => { - setIsOpen(true); - setPopupHeader(header); - setPopupMessage(message); - setPopupIcon(icon); - }; + const showPopup = useCallback( + ({ + header, + message, + icon, + }: { + header: string; + message: string; + icon: string; + }) => { + setIsOpen(true); + setPopupHeader(header); + setPopupMessage(message); + setPopupIcon(icon); + }, + [], + ); - const hidePopup = () => { + const hidePopup = useCallback(() => { setIsOpen(false); - }; + }, []); return ( boardId && canCreateList && openNewListForm(boardId), - description: t`Create new list`, - group: "ACTIONS", - }); - const boardId = params?.boardId ? Array.isArray(params.boardId) ? params.boardId[0] : params.boardId : null; + const createListShortcut = useMemo( + () => ({ + type: "PRESS" as const, + stroke: { key: "C" }, + action: () => boardId && canCreateList && openNewListForm(boardId), + description: t`Create new list`, + group: "ACTIONS" as const, + }), + [boardId, canCreateList], + ); + + const { tooltipContent: createListShortcutTooltipContent } = + useKeyboardShortcut(createListShortcut); + const updateBoard = api.board.update.useMutation(); const { register, handleSubmit, setValue } = useForm({ diff --git a/apps/web/src/views/boards/index.tsx b/apps/web/src/views/boards/index.tsx index 9d775699..13c1dd3a 100644 --- a/apps/web/src/views/boards/index.tsx +++ b/apps/web/src/views/boards/index.tsx @@ -5,8 +5,12 @@ import { ListboxOptions, } from "@headlessui/react"; import { t } from "@lingui/core/macro"; -import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { + HiArrowDownTray, + HiChevronDown, + HiOutlinePlusSmall, +} from "react-icons/hi2"; import Button from "~/components/Button"; import FeedbackModal from "~/components/FeedbackModal"; @@ -33,14 +37,19 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) { const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards"); const { canCreateBoard } = usePermissions(); - const { tooltipContent: createModalShortcutTooltipContent } = - useKeyboardShortcut({ - type: "PRESS", + const createBoardShortcut = useMemo( + () => ({ + type: "PRESS" as const, stroke: { key: "C" }, action: () => canCreateBoard && openModal("NEW_BOARD"), description: t`Create new ${isTemplate ? "template" : "board"}`, - group: "ACTIONS", - }); + group: "ACTIONS" as const, + }), + [canCreateBoard, isTemplate, openModal], + ); + + const { tooltipContent: createModalShortcutTooltipContent } = + useKeyboardShortcut(createBoardShortcut); return ( <> @@ -137,7 +146,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) { onChange={(tab) => setActiveTab(tab)} >
- + {boardsTabs.find((tab) => tab.key === activeTab)?.label ?? "Select a tab"} - `relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected - ? "font-bold text-light-1000 dark:text-dark-1000" - : "font-normal text-light-1000 dark:text-dark-1000" + `relative cursor-pointer select-none py-2 pl-3 pr-9 ${ + selected + ? "font-bold text-light-1000 dark:text-dark-1000" + : "font-normal text-light-1000 dark:text-dark-1000" }` } > @@ -175,10 +185,11 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) { key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} - className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key - ? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000" - : "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950" - }`} + className={`mb-8 mt-2 whitespace-nowrap px-1 py-0 text-sm font-semibold transition-colors focus:outline-none ${ + activeTab === tab.key + ? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000" + : "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950" + }`} > {tab.label}