fix: improve mobile header and board menus

This commit is contained in:
ajnart committed 2026-08-27 07:24:40 +02:00
1 parent 1b2f1bd720
commit 63e1bc7f6c
10 files changed
+322 -130

No files matched your search

@@ -13,7 +13,7 @@ import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header";
import { appShellLogoHeight } from "~/components/layout/constants";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { BoardLogo, BoardLogoWithName, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
@@ -111,7 +111,8 @@ export const createBoardLayout = <TParams extends Params>({
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
mobileLogoWithTitle={<BoardLogoWithName size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
@@ -15,6 +15,10 @@
outline: none;
}
.results {
min-height: 0;
}
.searchInput {
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
@@ -44,3 +48,19 @@
border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-md);
}
@media (max-width: $mantine-breakpoint-sm) {
.modalContent {
height: calc(100dvh - var(--modal-y-offset) * 2);
}
.modalBody,
.content {
min-height: 0;
height: 100%;
}
.results {
flex: 1;
}
}
@@ -228,7 +228,13 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
radius="xl"
classNames={{ input: classes.searchInput }}
/>
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
<ScrollArea.Autosize
mah="min(80vh, 42rem)"
type="never"
scrollbars="y"
overscrollBehavior="contain"
className={classes.results}
>
{results}
</ScrollArea.Autosize>
</Stack>
@@ -0,0 +1,14 @@
.mobileDrawerContent {
height: min(80dvh, rem(640px));
max-height: 80dvh;
overscroll-behavior: contain;
}
.mobileDrawerBody {
padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom));
}
.mobileActionList {
display: flex;
flex-direction: column;
}
@@ -2,7 +2,8 @@
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { Drawer, Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -20,7 +21,7 @@ import { getWidgetName } from "@homarr/definitions";
import { translateIfNecessary } from "@homarr/translation";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import type { WidgetContextMenuAction, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
@@ -31,6 +32,9 @@ import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
import classes from "./widget-context-menu.module.css";
type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }];
interface WidgetContextMenuProps {
item: SectionItem;
@@ -56,8 +60,6 @@ export const WidgetContextMenu = ({
const { hasChangeAccess } = useBoardPermissions(board);
const { updateAndPersistBoard } = usePersistBoard(board);
const t = useI18n();
const tMenu = useI18n("item.menu.label");
const tCommon = useI18n("common.action");
const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
@@ -72,6 +74,7 @@ export const WidgetContextMenu = ({
const { open: openAdvancedFocus } = useAdvancedFocus();
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
const isMobile = useMediaQuery("(max-width: 48em)");
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
@@ -134,11 +137,12 @@ export const WidgetContextMenu = ({
[item.id, persistBoard],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return [];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
const rawOptions = definition.createOptions(settings) as unknown as Record<string, ToggleOption[1]>;
return Object.entries(rawOptions).filter(
([, option]) => option.type === "switch" && !option.skipContextMenu,
) as ToggleOption[];
}, [canConfigureWidget, definition, settings]);
const widgetContextActions =
@@ -222,11 +226,37 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return;
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}, [item.id, openAdvancedFocus, sourceRef]);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
const actionContent = (
<WidgetContextActions
canOpenAdvancedFocus={canOpenAdvancedFocus}
onOpenAdvancedFocus={handleOpenAdvancedFocus}
toggleOptions={toggleOptions}
options={options}
onToggle={handleToggle}
itemKind={item.kind}
visibleWidgetActions={visibleWidgetActions}
isWidgetFetching={isWidgetFetching}
onRefetch={handleRefetch}
queryClient={queryClient}
matchesWidgetQuery={matchesWidgetQuery}
onEdit={openEditModal}
canConfigureWidget={canConfigureWidget}
isIntegrationDataPending={hasSupportedIntegrations && isPending}
/>
);
return (
<Menu
@@ -239,91 +269,137 @@ export const WidgetContextMenu = ({
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{t(`${getWidgetOptionTranslationNamespace(item.kind, key)}.label` as never)}
</Menu.CheckboxItem>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
{isMobile ? (
<Drawer
opened={menuOpened}
onClose={() => setMenuOpened(false)}
position="bottom"
size="min(80dvh, 40rem)"
title={getWidgetName(item.kind, t)}
classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tCommon("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
<div className={classes.mobileActionList} role="menu" aria-orientation="vertical" data-menu-dropdown>
{actionContent}
</div>
</Drawer>
) : (
<Menu.Dropdown>{actionContent}</Menu.Dropdown>
)}
</Menu>
);
};
interface WidgetContextActionsProps {
canOpenAdvancedFocus: boolean;
onOpenAdvancedFocus: () => void;
toggleOptions: ToggleOption[];
options: Record<string, unknown>;
onToggle: (key: string) => (checked: boolean) => void;
itemKind: SectionItem["kind"];
visibleWidgetActions: WidgetContextMenuAction[];
isWidgetFetching: boolean;
onRefetch: () => void;
queryClient: QueryClient;
matchesWidgetQuery: (queryKey: QueryKey) => boolean;
onEdit: () => void;
canConfigureWidget: boolean;
isIntegrationDataPending: boolean;
}
const WidgetContextActions = ({
canOpenAdvancedFocus,
onOpenAdvancedFocus,
toggleOptions,
options,
onToggle,
itemKind,
visibleWidgetActions,
isWidgetFetching,
onRefetch,
queryClient,
matchesWidgetQuery,
onEdit,
canConfigureWidget,
isIntegrationDataPending,
}: WidgetContextActionsProps) => {
const t = useI18n();
const tMenu = useI18n("item.menu.label");
const tCommon = useI18n("common.action");
return (
<>
{canOpenAdvancedFocus && (
<>
<Menu.Item closeMenuOnClick leftSection={<IconMaximize size={16} />} onClick={onOpenAdvancedFocus}>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={onToggle(key)}>
{t(`${getWidgetOptionTranslationNamespace(itemKind, key)}.label` as never)}
</Menu.CheckboxItem>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={onRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tCommon("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={onEdit}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || isIntegrationDataPending}
>
{tMenu("settings")}
</Menu.Item>
</>
);
};
interface WidgetQueryStatusProps {
queryClient: QueryClient;
matchesQuery: (queryKey: QueryKey) => boolean;
+5 -1
View File
@@ -10,6 +10,7 @@ import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
interface Props {
logo?: ReactNode;
logoWithTitle?: ReactNode;
mobileLogoWithTitle?: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
@@ -19,6 +20,7 @@ interface Props {
export const MainHeader = async ({
logo,
logoWithTitle,
mobileLogoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
@@ -27,11 +29,13 @@ export const MainHeader = async ({
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const resolvedLogoWithTitle = logoWithTitle ?? <HomarrLogoWithTitle size="md" />;
return (
<ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
logoWithTitle={resolvedLogoWithTitle}
mobileLogoWithTitle={mobileLogoWithTitle ?? resolvedLogoWithTitle}
actions={actions}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
@@ -2,6 +2,15 @@
overflow: hidden;
}
.header[data-desktop-visible="false"] {
display: none;
}
.desktopContent {
width: 100%;
height: 100%;
}
.content {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -64,6 +73,41 @@
flex: 0 0 auto;
}
.mobileContent {
display: none;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-xs);
}
.mobileIdentity,
.mobileActions {
min-width: 0;
}
.mobileIdentity {
flex: 1 1 auto;
overflow: hidden;
}
.mobileIdentity > :last-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.mobileIdentity h2 {
overflow: hidden;
text-overflow: ellipsis;
}
.mobileActions {
flex: 0 0 auto;
}
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
@@ -111,18 +155,21 @@
}
@media (max-width: $mantine-breakpoint-sm) {
.content {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
.header[data-desktop-visible="false"] {
display: block;
}
.zone {
gap: var(--mantine-spacing-xs);
.desktopContent {
display: none;
}
.search {
width: auto;
flex: 0 0 auto;
.mobileContent {
display: flex;
}
.floatingControls,
.floatingControlsCorner {
display: none;
}
}
@@ -2,15 +2,7 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
import {
IconBrandDocker,
IconHome,
@@ -46,6 +38,7 @@ import classes from "./configurable-header.module.css";
interface ConfigurableHeaderProps {
logo: ReactNode;
logoWithTitle: ReactNode;
mobileLogoWithTitle: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
@@ -64,6 +57,7 @@ const floatingControlsDismissDelayMs = 900;
export const ConfigurableHeader = ({
logo,
logoWithTitle,
mobileLogoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
@@ -116,14 +110,15 @@ export const ConfigurableHeader = ({
return (
<>
{headerPreferences.visible ? (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-advanced-focus-background
data-app-shell-header
>
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-desktop-visible={headerPreferences.visible}
data-advanced-focus-background
data-app-shell-header
>
<div className={classes.desktopContent}>
<div className={classes.content}>
<div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null}
@@ -137,8 +132,36 @@ export const ConfigurableHeader = ({
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div>
</div>
</AppShellHeader>
) : (
</div>
<div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null}
<HeaderLogo
display="logoAndText"
logo={logo}
logoWithTitle={mobileLogoWithTitle}
label={t("items.logo")}
/>
</Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
{actions}
{boardEditAction}
{boardSettingsAction}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group>
</div>
</AppShellHeader>
{!headerPreferences.visible ? (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
@@ -148,7 +171,7 @@ export const ConfigurableHeader = ({
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
)}
) : null}
<LazySpotlight />
</>
);
@@ -291,7 +314,7 @@ const HeaderItem = ({
<Tooltip label={board.name}>
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
<Avatar src={board.logoImageUrl} size={22} radius="sm">
<IconLayoutDashboard size={17} stroke={1.5} />
{getBoardInitial(board.name)}
</Avatar>
</HeaderButton>
</Tooltip>
@@ -299,14 +322,7 @@ const HeaderItem = ({
}
if (item.id === "logo") {
return (
<HeaderLogo
display={logoDisplay}
logo={logo}
logoWithTitle={logoWithTitle}
label={label("logo")}
/>
);
return <HeaderLogo display={logoDisplay} logo={logo} logoWithTitle={logoWithTitle} label={label("logo")} />;
}
if (item.id === "boardEdit") return boardEditAction;
@@ -416,3 +432,5 @@ const HeaderItem = ({
</TourTarget>
);
};
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
@@ -44,3 +44,9 @@ export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTi
/>
);
};
export const BoardLogoWithName = ({ size }: Pick<CommonLogoWithTitleProps, "size">) => {
const board = useRequiredBoard();
const imageOptions = useImageOptions();
return <LogoWithTitle size={size} title={board.name} image={imageOptions} />;
};
+2 -2
View File
@@ -22,12 +22,12 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const isHeaderVisible = hasHeader && headerPreferences.visible;
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
return (
<AppShell
{...backgroundProps}
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
? {