merge: stack widget modernization on dnd kit

This commit is contained in:
Thomas Camlong committed 2026-08-08 20:40:26 +02:00
commit 9edeba5cd2
940 files changed
+104475 -33649

No files matched your search

+3 -2
View File
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Spotlight } from "@homarr/spotlight";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
import { LazySpotlight } from "./header/lazy-spotlight";
interface Props {
logo?: ReactNode;
@@ -23,6 +23,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
zIndex="var(--homarr-z-index-board-header)"
style={{ overflowX: "hidden" }}
data-advanced-focus-background
data-app-shell-header
>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
@@ -42,7 +43,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget>
</Group>
</Group>
<Spotlight />
<LazySpotlight />
</AppShellHeader>
);
};
@@ -1,4 +1,4 @@
import type { ForwardedRef, ReactNode } from "react";
import type { FocusEventHandler, ForwardedRef, PointerEventHandler, ReactNode } from "react";
import { forwardRef } from "react";
import type { ActionIconProps } from "@mantine/core";
import { ActionIcon } from "@mantine/core";
@@ -8,12 +8,17 @@ import { Link } from "@homarr/ui";
type HeaderButtonProps = (
| {
onClick?: () => void;
onFocus?: FocusEventHandler<HTMLButtonElement>;
onPointerEnter?: PointerEventHandler<HTMLButtonElement>;
}
| {
href: string;
}
) & {
children: ReactNode;
onFocus?: () => void;
onPointerDown?: () => void;
onPointerEnter?: () => void;
} & Partial<ActionIconProps>;
const headerButtonActionIconProps: ActionIconProps = {
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Center, Loader, Paper } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions";
import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
const loadSpotlight = () => import("@homarr/spotlight/component");
const SpotlightLoading = () => (
<Center
data-homarr-dev-benchmark-spotlight-feedback
pos="fixed"
inset={0}
role="status"
aria-label="Loading search"
style={{ zIndex: 1000, background: "rgb(0 0 0 / 20%)", backdropFilter: "blur(2px)" }}
>
<Paper withBorder radius="md" p="xl" shadow="xl">
<Loader />
</Paper>
</Center>
);
const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
ssr: false,
loading: SpotlightLoading,
});
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false);
useHotkeys(
isMounted
? []
: [
[
hotkeys.openSpotlight,
() => {
setIsMounted(true);
openSpotlight();
},
],
],
);
useEffect(() => {
let isActive = true;
const mount = () => setIsMounted(true);
window.addEventListener(spotlightOpenEvent, mount);
if (hasPendingSpotlightOpen()) mount();
const preload = () => {
void loadSpotlight().then(
() => {
if (isActive) setIsPreloaded(true);
},
() => undefined,
);
};
let idleId: number | undefined;
const delayId = window.setTimeout(() => {
idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 });
if (idleId === undefined) preload();
}, 1_000);
return () => {
isActive = false;
window.removeEventListener(spotlightOpenEvent, mount);
window.clearTimeout(delayId);
if (idleId !== undefined) window.cancelIdleCallback?.(idleId);
};
}, []);
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && (
<>
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
</>
) : null;
};
@@ -3,23 +3,28 @@
import { TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { openSpotlight } from "@homarr/spotlight";
import { openSpotlight } from "@homarr/spotlight/open";
import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
import { preloadSpotlight } from "./lazy-spotlight";
export const DesktopSearchInput = () => {
const t = useI18n();
return (
<TextInput
data-homarr-dev-benchmark-interaction="search"
component={UnstyledButton}
className={classes.desktopSearch}
w={400}
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
radius="xl"
>
{`${t("search.placeholder")}...`}
@@ -29,7 +34,14 @@ export const DesktopSearchInput = () => {
export const MobileSearchButton = () => {
return (
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight}
onFocus={preloadSpotlight}
onPointerDown={preloadSpotlight}
onPointerEnter={preloadSpotlight}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
);
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { TourTarget, TourTargetsProvider } from "./tour-target";
vi.mock("@gfazioli/mantine-onboarding-tour", () => ({
OnboardingTour: {
Target: ({ children }: { children: ReactNode }) => children,
},
}));
const mountedRoots: Array<ReturnType<typeof createRoot>> = [];
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) root.unmount();
});
});
const renderTargetAsync = async (enabled: boolean) => {
const host = document.createElement("div");
const root = createRoot(host);
mountedRoots.push(root);
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(TourTargetsProvider, {
enabled,
children: createElement(TourTarget, { id: "example", children: createElement("button", null, "Target") }),
}),
),
);
});
return host;
};
describe("TourTarget", () => {
it("keeps a stable route-transition marker while a tour is active", async () => {
const host = await renderTargetAsync(true);
expect(host.querySelector('[data-tour-target="example"] button')?.textContent).toBe("Target");
});
it("adds no wrapper for users without an active tour", async () => {
const host = await renderTargetAsync(false);
expect(host.querySelector("[data-tour-target]")).toBeNull();
expect(host.querySelector("button")?.parentElement).toBe(host);
});
});
@@ -1,11 +1,41 @@
"use client";
import type { ReactNode } from "react";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import type { PropsWithChildren, ReactNode } from "react";
import { createContext, Suspense, use, useContext } from "react";
import { Box } from "@mantine/core";
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
<OnboardingTour.Target id={id}>
<Box data-tour-target={id}>{children}</Box>
</OnboardingTour.Target>
const TourTargetsEnabledContext = createContext(false);
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
const loadTourLibrary = () => {
if (tourLibraryPromise) return tourLibraryPromise;
const currentPromise = import("@gfazioli/mantine-onboarding-tour");
tourLibraryPromise = currentPromise;
void currentPromise.catch(() => {
if (tourLibraryPromise === currentPromise) tourLibraryPromise = undefined;
});
return currentPromise;
};
export const TourTargetsProvider = ({ enabled = true, children }: PropsWithChildren<{ enabled?: boolean }>) => (
<TourTargetsEnabledContext value={enabled}>{children}</TourTargetsEnabledContext>
);
const LoadedTourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
const { OnboardingTour } = use(loadTourLibrary());
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
const isEnabled = useContext(TourTargetsEnabledContext);
if (!isEnabled) return children;
const target = <Box data-tour-target={id}>{children}</Box>;
return (
<Suspense fallback={target}>
<LoadedTourTarget id={id}>{target}</LoadedTourTarget>
</Suspense>
);
};
@@ -1,7 +1,6 @@
"use client";
import type { PropsWithChildren } from "react";
import { Suspense, use } from "react";
import { Indicator, Menu, Text } from "@mantine/core";
import { IconBellRinging } from "@tabler/icons-react";
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
interface UpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
disabled: boolean;
}
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
if (disabled || availableUpdatesPromise === undefined) {
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
if (disabled || availableUpdates === undefined) {
return children;
}
return (
<Suspense fallback={children}>
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
{children}
</InnerUpdateIndicator>
</Suspense>
);
};
interface InnerUpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
disabled: boolean;
}
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
const availableUpdates = use(availableUpdatesPromise);
return (
<Indicator
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
size={15}
processing
withBorder
>
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
{children}
</Indicator>
);
};
interface AvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
}
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
if (availableUpdatesPromise === undefined) {
return null;
}
return (
<Suspense fallback={null}>
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
</Suspense>
);
};
interface InnerAvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
}
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const availableUpdates = use(availableUpdatesPromise);
if (availableUpdates === undefined || availableUpdates.length === 0) {
return null;
}
@@ -0,0 +1,125 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
isDockerEnabled: boolean;
}
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const isCurrentSessionAdmin =
isAdmin && session.status === "authenticated" && session.data.user.permissions.includes("admin");
useEffect(() => {
if (!isCurrentSessionAdmin) {
setCanCheckForUpdates(false);
return;
}
const timeout = window.setTimeout(() => setCanCheckForUpdates(true), 2_000);
return () => window.clearTimeout(timeout);
}, [isCurrentSessionAdmin]);
const { data: availableUpdates } = clientApi.updateChecker.getAvailableUpdates.useQuery(undefined, {
enabled: canCheckForUpdates,
refetchOnWindowFocus: false,
retry: false,
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
);
};
@@ -1,28 +1,14 @@
import { Suspense } from "react";
import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
import { UserButtonClient } from "./user-client";
export const UserButton = async () => {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
<CurrentUserAvatar size="md" />
</UpdateIndicator>
</Suspense>
</UnstyledButton>
</UserAvatarMenu>
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
);
};
@@ -1,20 +1,19 @@
"use client";
import type { ReactNode } from "react";
import type { ReactElement, ReactNode } from "react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui";
import { TourTarget } from "./header/tour-target";
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
if (!id) return <>{children}</>;
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
id ? <TourTarget id={id}>{children}</TourTarget> : children;
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
@@ -43,7 +42,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
active={isClient && pathMatches(pathname, props.href)}
/>
);
return <TourTarget id={tourId}>{link}</TourTarget>;
return withOptionalTourTarget(tourId, link);
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
))}
</NavLink>
);
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
};
interface CommonNavigationLinkProps {
@@ -0,0 +1,28 @@
"use client";
import { useEffect } from "react";
import { boardViewportWidthCookieName } from "@homarr/boards/layout-selection";
export const ViewportHint = () => {
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const persist = () => {
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
document.cookie = `${boardViewportWidthCookieName}=${Math.round(viewportWidth)}; Path=/; Max-Age=31536000; SameSite=Lax`;
};
const handleResize = () => {
clearTimeout(timeout);
timeout = setTimeout(persist, 150);
};
persist();
window.addEventListener("resize", handleResize, { passive: true });
return () => {
clearTimeout(timeout);
window.removeEventListener("resize", handleResize);
};
}, []);
return null;
};