feat: rework board switcher (#6660)

* feat: rework board switcher

* fix: address board switcher review
This commit is contained in:
Thomas authored and GitHub committed 2026-08-22 11:15:40 +02:00
1 parent 8a08293016
commit 2accdc2fad
8 files changed
+482 -157

No files matched your search

@@ -0,0 +1,21 @@
.content {
outline: none;
}
.option {
min-width: 0;
border-radius: var(--mantine-radius-md);
outline: none;
}
.card {
height: 100%;
overflow: hidden;
}
.option:hover .card,
.option:focus-visible .card,
.option[data-active="true"] .card {
border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-md);
}
@@ -0,0 +1,331 @@
"use client";
import type { KeyboardEvent, ReactNode, RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Card,
Center,
Group,
Kbd,
Loader,
Modal,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
UnstyledButton,
useMatches,
VisuallyHidden,
} from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
interface BoardSwitcherProps {
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
}
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher");
const currentBoard = useOptionalBoard();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const responsiveColumnCount = useMatches({ base: 1, sm: 2, lg: 3, xl: 4 });
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([]);
const utils = clientApi.useUtils();
const {
data: boards = [],
isPending,
isError,
} = clientApi.board.getManageOverview.useQuery(undefined, {
enabled: isOpen,
});
const availableBoards = useMemo(
() => boards.filter((board) => board.id !== currentBoard?.id),
[boards, currentBoard?.id],
);
const filteredBoards = useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase();
if (normalizedSearch.length === 0) return availableBoards;
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
}, [availableBoards, search]);
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
const openSwitcher = useCallback(() => {
setIsOpen(true);
}, []);
const closeSwitcher = useCallback(() => {
setIsOpen(false);
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useEffect(() => {
if (!isOpen || filteredBoards.length === 0) return;
const normalizedIndex = Math.min(activeIndex, filteredBoards.length - 1);
if (normalizedIndex !== activeIndex) {
setActiveIndex(normalizedIndex);
return;
}
optionRefs.current[normalizedIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeIndex, filteredBoards.length, isOpen]);
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Backspace") {
event.preventDefault();
setSearch((current) => current.slice(0, -1));
setActiveIndex(0);
return;
}
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setSearch((current) => `${current}${event.key}`);
setActiveIndex(0);
return;
}
if (filteredBoards.length === 0) return;
if (event.key === "Enter") {
const target = event.target;
if (target instanceof Element && target.closest("[data-board-switcher-option]")) return;
event.preventDefault();
optionRefs.current[activeIndex]?.click();
return;
}
let nextIndex = activeIndex;
if (event.key === "ArrowRight") {
nextIndex = (activeIndex + 1) % filteredBoards.length;
} else if (event.key === "ArrowLeft") {
nextIndex = (activeIndex - 1 + filteredBoards.length) % filteredBoards.length;
} else if (event.key === "ArrowDown") {
const nextRowIndex = activeIndex + columnCount;
nextIndex = nextRowIndex;
if (nextRowIndex >= filteredBoards.length) nextIndex = activeIndex % columnCount;
} else if (event.key === "ArrowUp") {
const previousRowIndex = activeIndex - columnCount;
if (previousRowIndex >= 0) {
nextIndex = previousRowIndex;
} else {
const lastRowStart = Math.floor((filteredBoards.length - 1) / columnCount) * columnCount;
const lastIndexInColumn = lastRowStart + activeIndex;
nextIndex = lastIndexInColumn;
if (lastIndexInColumn >= filteredBoards.length) nextIndex -= columnCount;
}
} else {
return;
}
event.preventDefault();
setActiveIndex(nextIndex);
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus({ preventScroll: true }));
};
const results = getBoardSwitcherResults({
availableBoardCount: availableBoards.length,
filteredBoards,
isError,
isPending,
search,
t,
activeIndex,
closeSwitcher,
setActiveIndex,
optionRefs,
columnCount,
});
return (
<>
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
<Modal
opened={isOpen}
onClose={closeSwitcher}
title={
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" radius="md">
<IconReplace size={18} stroke={1.5} />
</ThemeIcon>
<Text fw={700}>{t("title")}</Text>
</Group>
}
size={1280}
yOffset={32}
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
>
<Stack
data-autofocus
tabIndex={-1}
gap="md"
onKeyDown={handleModalKeyDown}
className={classes.content}
aria-describedby="board-switcher-instructions"
>
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
{results}
</ScrollArea.Autosize>
<Group justify={currentBoard ? "space-between" : "flex-end"} gap="xs" visibleFrom="sm">
{currentBoard && (
<Text size="xs" c="dimmed">
{t("currentHidden", { name: currentBoard.name })}
</Text>
)}
<Group gap="xs">
<Kbd size="xs">
<IconArrowRight size={12} />
</Kbd>
<Kbd size="xs">
<IconArrowDown size={12} />
</Kbd>
<Text size="xs" c="dimmed">
{t("keyboard.navigate")}
</Text>
<Kbd size="xs">Enter</Kbd>
<Text size="xs" c="dimmed">
{t("keyboard.open")}
</Text>
</Group>
</Group>
</Stack>
</Modal>
</>
);
};
interface BoardSwitcherResultsProps {
availableBoardCount: number;
filteredBoards: RouterOutputs["board"]["getManageOverview"];
isError: boolean;
isPending: boolean;
search: string;
t: ReturnType<typeof useI18n<"board.action.switcher">>;
activeIndex: number;
closeSwitcher: () => void;
setActiveIndex: (index: number) => void;
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
columnCount: number;
}
const getBoardSwitcherResults = ({
availableBoardCount,
filteredBoards,
isError,
isPending,
search,
t,
activeIndex,
closeSwitcher,
setActiveIndex,
optionRefs,
columnCount,
}: BoardSwitcherResultsProps) => {
if (isPending) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
if (isError) {
return (
<Center py="xl">
<Text c="dimmed" size="sm">
{t("error")}
</Text>
</Center>
);
}
if (availableBoardCount === 0) {
return (
<Center py="xl">
<Stack align="center" gap={4}>
<ThemeIcon variant="light" color="gray" size="xl" radius="xl">
<IconLayoutBoard stroke={1.5} />
</ThemeIcon>
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed" size="sm" ta="center">
{t("empty.description")}
</Text>
</Stack>
</Center>
);
}
if (filteredBoards.length === 0) {
return (
<Center py="xl">
<Text c="dimmed" size="sm">
{t("search.empty", { search })}
</Text>
</Center>
);
}
return (
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
{filteredBoards.map((board, index) => (
<UnstyledButton
key={board.id}
ref={(node) => {
optionRefs.current[index] = node;
}}
className={classes.option}
w="100%"
maw={320}
mx="auto"
component={Link}
href={`/boards/${encodeURIComponent(board.name)}`}
data-active={index === activeIndex}
data-board-switcher-option
onClick={closeSwitcher}
onFocus={() => setActiveIndex(index)}
onPointerEnter={() => setActiveIndex(index)}
>
<Card padding={0} withBorder radius="md" className={classes.card}>
<Card.Section>
<BoardLayoutThumbnail
preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
/>
</Card.Section>
<Card.Section withBorder p="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" radius="md" size="md">
<IconLayoutBoard size={16} stroke={1.5} />
</ThemeIcon>
<Text fw={650} truncate>
{board.name}
</Text>
</Group>
</Card.Section>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
);
};
@@ -66,16 +66,6 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
/>
),
},
{
id: "board-switcher",
title: t("switcher.title"),
content: (
<TourStepContent
description={t("switcher.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-user-menu",
title: t("userMenu.title"),
+102 -86
View File
@@ -8,6 +8,7 @@ import {
IconHome,
IconLogin,
IconLogout,
IconReplace,
IconSettings,
IconRobot,
IconTool,
@@ -22,6 +23,7 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import { BoardSwitcher } from "./board/board-switcher";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
@@ -41,6 +43,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board");
const session = useSession();
const { logoutUrl } = useAuthContext();
@@ -56,98 +59,111 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
}, [logoutUrl]);
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<BoardSwitcher>
{({ open, preload, hotkey }) => (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
onClick={open}
onFocus={preload}
onPointerEnter={preload}
>
{t("preferences")}
{tBoard("action.switch")}
</Menu.Item>
<Menu.Divider />
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
) : (
assistant.unreadCount
)
) : undefined
}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
) : (
assistant.unreadCount
)
) : undefined
}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
)}
</BoardSwitcher>
);
};