feat: complete automatic mobile board UX

This commit is contained in:
ajnart committed 2026-07-28 19:39:13 +02:00
1 parent cf83e0d849
commit 7959c20d88
87 files changed
+2850 -570

No files matched your search

@@ -60,9 +60,15 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const layoutItemSize = boardLayout
? {
...itemSize,
width: Math.min(itemSize.width, boardLayout.columnCount),
}
: itemSize;
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -70,8 +76,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: itemSize.width,
height: itemSize.height,
width: layoutItemSize.width,
height: layoutItemSize.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -1,9 +1,8 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveAndResizeItemInput {
itemId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput {
}
export const moveAndResizeItemCallback =
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
(previous: Board): Board => {
const currentLayout = getCurrentLayout(previous);
return {
...previous,
items: previous.items.map((item) =>
@@ -23,7 +20,7 @@ export const moveAndResizeItemCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== currentLayout
layout.layoutId !== layoutId
? layout
: {
...layout,
@@ -1,9 +1,8 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveItemToSectionInput {
itemId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -12,10 +11,8 @@ export interface MoveItemToSectionInput {
}
export const moveItemToSectionCallback =
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
(board: Board): Board => {
const currentLayout = getCurrentLayout(board);
return {
...board,
items: board.items.map((item) =>
@@ -24,7 +21,7 @@ export const moveItemToSectionCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== currentLayout
layout.layoutId !== layoutId
? layout
: {
...layout,
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
@@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -108,4 +112,24 @@ describe("item actions create-item", () => {
{ height: 1, width: 1 },
);
});
test("clamps wide widgets to each retained layout", () => {
const desktopLayout = new LayoutMockBuilder({ id: "desktop", columnCount: 12 }).build();
const mobileLayout = new LayoutMockBuilder({ id: "mobile", columnCount: 1 }).build();
const board = new BoardMockBuilder()
.addLayout(desktopLayout)
.addLayout(mobileLayout)
.addEmptySection({ id: "root", yOffset: 0 })
.build();
const result = createItemCallback({ kind: "mediaMissing" })(board);
const item = result.items.at(0);
expect(item?.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ layoutId: desktopLayout.id, width: 4 }),
expect.objectContaining({ layoutId: mobileLayout.id, width: 1 }),
]),
);
});
});
@@ -106,6 +106,10 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
test("does not place an item wider than the layout", () => {
expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined();
});
});
const createElementsFromLayout = (layout: string[][]) => {
@@ -1,6 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { describe, expect, test } from "vitest";
import { moveAndResizeItemCallback } from "../move-and-resize-item";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,6 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { describe, expect, test } from "vitest";
import { moveItemToSectionCallback } from "../move-item-to-section";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,6 +1,6 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { Badge, Card, Center, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
@@ -10,6 +10,8 @@ import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { WidgetDisplayMode } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
@@ -26,7 +28,9 @@ interface BoardItemContentProps {
displayMode?: WidgetDisplayMode;
disableContextMenu?: boolean;
isReadOnly?: boolean;
disableItemConfiguration?: boolean;
widgetStateRef?: MutableRefObject<Record<string, unknown> | null>;
onOpenDetails?: () => void;
}
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
@@ -40,7 +44,9 @@ export const BoardItemContent = ({
displayMode = "default",
disableContextMenu = false,
isReadOnly = false,
disableItemConfiguration = false,
widgetStateRef: externalWidgetStateRef,
onOpenDetails,
}: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
@@ -72,7 +78,9 @@ export const BoardItemContent = ({
height={height}
displayMode={displayMode}
isReadOnly={isReadOnly}
disableItemConfiguration={disableItemConfiguration}
widgetStateRef={widgetStateRef}
onOpenDetails={onOpenDetails}
/>
</Card>
);
@@ -116,13 +124,23 @@ interface InnerContentProps {
height: number;
displayMode: WidgetDisplayMode;
isReadOnly: boolean;
disableItemConfiguration: boolean;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
onOpenDetails?: () => void;
}
const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => {
const InnerContent = ({
item,
displayMode,
isReadOnly,
disableItemConfiguration,
onOpenDetails,
...dimensions
}: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const t = useI18n();
const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
@@ -132,6 +150,14 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon
updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
const hasNativeMobileSummary =
"mobile" in definition &&
definition.mobile !== undefined &&
"supportsCompactSummary" in definition.mobile &&
definition.mobile.supportsCompactSummary === true;
const useGenericMobileSummary = displayMode === "mobileSummary" && !hasNativeMobileSummary;
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const showItemMenu = !isReadOnly && !disableItemConfiguration;
const queryClient = useQueryClient();
return (
@@ -144,7 +170,7 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
{showItemMenu && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
@@ -157,32 +183,79 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} />}
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
isReadOnly={isReadOnly}
setOptions={(partialNewOptions) => {
if (isReadOnly) return;
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
});
}}
displayMode={displayMode}
{...dimensions}
/>
{showItemMenu && <BoardItemMenu offset={4} item={newItem} />}
{useGenericMobileSummary ? (
<GenericMobileSummary
icon={definition.icon}
title={title}
widgetName={t(`widget.${item.kind}.name`)}
detailsLabel={t("board.mobile.details")}
onOpenDetails={onOpenDetails}
/>
) : (
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
isReadOnly={isReadOnly}
setOptions={(partialNewOptions) => {
if (isReadOnly) return;
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
});
}}
displayMode={displayMode}
{...dimensions}
/>
)}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};
const GenericMobileSummary = ({
icon: Icon,
title,
widgetName,
detailsLabel,
onOpenDetails,
}: {
icon: TablerIcon;
title: string;
widgetName: string;
detailsLabel: string;
onOpenDetails?: () => void;
}) => (
<UnstyledButton
h="100%"
w="100%"
p="sm"
onClick={onOpenDetails}
aria-label={`${detailsLabel}: ${title}`}
data-mobile-generic-summary
>
<Center h="100%">
<Stack align="center" gap={4} maw="100%">
<ThemeIcon variant="light" size={36} radius="xl">
<Icon size={20} aria-hidden />
</ThemeIcon>
<Text fw={600} size="sm" ta="center" lineClamp={1} w="100%">
{widgetName}
</Text>
<Text c="dimmed" size="xs" ta="center" lineClamp={1} w="100%">
{detailsLabel}
</Text>
</Stack>
</Center>
</UnstyledButton>
);
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error;
return null;
@@ -5,66 +5,56 @@ import { useEffect, useRef, useState } from "react";
import { Skeleton } from "@mantine/core";
import classes from "./mobile-board.module.css";
import { observeMobileViewportProximity } from "./mobile-viewport-observer";
interface DeferredMobileItemProps extends PropsWithChildren {
eager: boolean;
unmountWhenOffscreen: boolean;
onNearViewportChange?: (isNearViewport: boolean) => void;
}
export const mobilePreloadViewportMultiplier = 2;
export { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./mobile-viewport-observer";
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => {
export const DeferredMobileItem = ({
eager,
unmountWhenOffscreen,
onNearViewportChange,
children,
}: DeferredMobileItemProps) => {
const [hasMounted, setHasMounted] = useState(eager);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hasMountedOnceRef = useRef(eager);
useEffect(() => {
if (eager) {
hasMountedOnceRef.current = true;
setHasMounted(true);
onNearViewportChange?.(true);
return;
}
const element = wrapperRef.current;
if (!element || typeof IntersectionObserver === "undefined") {
if (!element) {
setHasMounted(true);
onNearViewportChange?.(true);
return;
}
let observer: IntersectionObserver | null = null;
const observe = () => {
if (hasMountedOnceRef.current && !unmountWhenOffscreen) return;
observer?.disconnect();
observer = new IntersectionObserver(
([entry]) => {
const isNearViewport = entry?.isIntersecting ?? false;
return observeMobileViewportProximity(
element,
(isNearViewport) => {
onNearViewportChange?.(isNearViewport);
if (isNearViewport) {
hasMountedOnceRef.current = true;
setHasMounted(true);
if (!unmountWhenOffscreen) observer?.disconnect();
return;
}
if (isNearViewport) {
setHasMounted(true);
return;
}
if (unmountWhenOffscreen && !element.contains(document.activeElement)) {
setHasMounted(false);
}
},
{ rootMargin: getMobilePreloadRootMargin(window.innerHeight) },
);
observer.observe(element);
};
observe();
window.addEventListener("resize", observe);
return () => {
window.removeEventListener("resize", observe);
observer?.disconnect();
};
}, [eager, unmountWhenOffscreen]);
if (unmountWhenOffscreen && !element.contains(document.activeElement)) {
setHasMounted(false);
}
},
{ once: !unmountWhenOffscreen },
);
}, [eager, onNearViewportChange, unmountWhenOffscreen]);
return (
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
@@ -1,8 +1,9 @@
"use client";
import type { CSSProperties, RefObject } from "react";
import { useMemo, useRef } from "react";
import { useMemo, useRef, useState } from "react";
import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconLayoutBoard } from "@tabler/icons-react";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
@@ -63,6 +64,8 @@ const getMobilePresentation = (item: SectionItem) => {
const MobileBoardItem = ({ item }: { item: SectionItem }) => {
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const presentation = getMobilePresentation(item);
const [isNearViewport, setIsNearViewport] = useState(presentation.eager);
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
return (
<Box
@@ -71,16 +74,30 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
data-mobile-board-item={item.id}
data-mobile-display-mode={presentation.displayMode}
>
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
<DeferredMobileItem
eager={presentation.eager}
unmountWhenOffscreen={presentation.unmountWhenOffscreen}
onNearViewportChange={setIsNearViewport}
>
<BoardItemContent
item={item}
displayMode={presentation.displayMode}
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
onOpenDetails={presentation.usesGenericSummary ? detailsDisclosure.open : undefined}
/>
</DeferredMobileItem>
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
<MobileWidgetActions
item={item}
supportsDetails={presentation.supportsDetails}
detailsFromCard={presentation.usesGenericSummary}
detailsOpened={detailsOpened}
openDetails={detailsDisclosure.open}
closeDetails={detailsDisclosure.close}
widgetStateRef={widgetStateRef}
isNearViewport={isNearViewport}
/>
</Box>
);
};
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import {
isMobileContextActionVisible,
resolveMobileItemPresentation,
shouldKeepMobileWidgetActionsMounted,
shouldRenderMobileWidgetActions,
} from "./mobile-presentation";
@@ -42,7 +43,7 @@ describe("resolveMobileItemPresentation", () => {
});
});
test("keeps automatic footprint defaults when only details are configured", () => {
test("uses a compact one-row fallback when only details are configured", () => {
expect(
resolveMobileItemPresentation(
{ kind: "calendar", height: 7 },
@@ -52,8 +53,10 @@ describe("resolveMobileItemPresentation", () => {
),
).toMatchObject({
width: 2,
height: 3,
height: 1,
displayMode: "mobileSummary",
supportsDetails: true,
usesGenericSummary: true,
});
});
@@ -75,6 +78,7 @@ describe("resolveMobileItemPresentation", () => {
height: 1,
displayMode: "mobileSummary",
supportsDetails: false,
usesGenericSummary: false,
eager: true,
unmountWhenOffscreen: false,
});
@@ -101,6 +105,42 @@ describe("shouldRenderMobileWidgetActions", () => {
});
});
describe("shouldKeepMobileWidgetActionsMounted", () => {
test("does not expose actions before a deferred widget is near the viewport", () => {
expect(
shouldKeepMobileWidgetActionsMounted({
isNearViewport: false,
actionsOpened: false,
detailsOpened: false,
isOpeningDetails: false,
isCompletingAction: false,
actionTriggerHasFocus: false,
}),
).toBe(false);
});
test.each([
["near the viewport", { isNearViewport: true }],
["showing its action drawer", { actionsOpened: true }],
["showing its detail view", { detailsOpened: true }],
["transitioning to details", { isOpeningDetails: true }],
["completing an action", { isCompletingAction: true }],
["retaining keyboard focus", { actionTriggerHasFocus: true }],
])("keeps actions mounted while %s", (_label, activeState) => {
expect(
shouldKeepMobileWidgetActionsMounted({
isNearViewport: false,
actionsOpened: false,
detailsOpened: false,
isOpeningDetails: false,
isCompletingAction: false,
actionTriggerHasFocus: false,
...activeState,
}),
).toBe(true);
});
});
describe("isMobileContextActionVisible", () => {
test("requires actions to opt into the read-only mobile surface", () => {
const action = { key: "configure", label: "Configure", onClick: () => undefined };
@@ -4,14 +4,21 @@ import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/
export const resolveMobileItemPresentation = (
item: { kind: WidgetKind; height: number },
mobile: WidgetMobilePresentation | undefined,
) => ({
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))),
displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const),
supportsDetails: mobile?.supportsDetailView === true,
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
});
) => {
const supportsCompactSummary = mobile?.supportsCompactSummary === true;
const supportsDetails = mobile?.supportsDetailView === true;
const usesGenericSummary = supportsDetails && !supportsCompactSummary;
return {
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
height: mobile?.height ?? (item.kind === "app" || usesGenericSummary ? 1 : Math.max(1, Math.min(item.height, 3))),
displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const),
supportsDetails,
usesGenericSummary,
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
};
};
export const shouldRenderMobileWidgetActions = ({
supportsDetails,
@@ -23,5 +30,24 @@ export const shouldRenderMobileWidgetActions = ({
visibleContextActionCount: number;
}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0;
interface ShouldKeepMobileWidgetActionsMountedInput {
isNearViewport: boolean;
actionsOpened: boolean;
detailsOpened: boolean;
isOpeningDetails: boolean;
isCompletingAction: boolean;
actionTriggerHasFocus: boolean;
}
export const shouldKeepMobileWidgetActionsMounted = ({
isNearViewport,
actionsOpened,
detailsOpened,
isOpeningDetails,
isCompletingAction,
actionTriggerHasFocus,
}: ShouldKeepMobileWidgetActionsMountedInput) =>
isNearViewport || actionsOpened || detailsOpened || isOpeningDetails || isCompletingAction || actionTriggerHasFocus;
export const isMobileContextActionVisible = (action: WidgetContextMenuAction) =>
action.mobileVisible === true && !action.hidden;
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getMobilePreloadRootMargin, observeMobileViewportProximity } from "./mobile-viewport-observer";
class IntersectionObserverMock {
static instances: IntersectionObserverMock[] = [];
readonly observedElements = new Set<Element>();
readonly rootMargin: string;
disconnected = false;
constructor(
private readonly callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
this.rootMargin = options?.rootMargin ?? "0px";
IntersectionObserverMock.instances.push(this);
}
observe(element: Element) {
this.observedElements.add(element);
}
unobserve(element: Element) {
this.observedElements.delete(element);
}
disconnect() {
this.disconnected = true;
this.observedElements.clear();
}
emit(element: Element, isIntersecting: boolean) {
this.callback([{ target: element, isIntersecting } as IntersectionObserverEntry], this as never);
}
}
describe("observeMobileViewportProximity", () => {
beforeEach(() => {
IntersectionObserverMock.instances = [];
vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600, writable: true });
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("shares one observer across cards and recreates it once when the viewport margin changes", () => {
const firstElement = document.createElement("div");
const secondElement = document.createElement("div");
const firstListener = vi.fn();
const secondListener = vi.fn();
const stopObservingFirst = observeMobileViewportProximity(firstElement, firstListener);
const stopObservingSecond = observeMobileViewportProximity(secondElement, secondListener);
expect(IntersectionObserverMock.instances).toHaveLength(1);
expect(IntersectionObserverMock.instances[0]?.observedElements).toEqual(new Set([firstElement, secondElement]));
expect(IntersectionObserverMock.instances[0]?.rootMargin).toBe(getMobilePreloadRootMargin(600));
IntersectionObserverMock.instances[0]?.emit(firstElement, true);
expect(firstListener).toHaveBeenCalledWith(true);
expect(secondListener).not.toHaveBeenCalled();
window.dispatchEvent(new Event("resize"));
expect(IntersectionObserverMock.instances).toHaveLength(1);
window.innerHeight = 700;
window.dispatchEvent(new Event("resize"));
expect(IntersectionObserverMock.instances).toHaveLength(2);
expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true);
expect(IntersectionObserverMock.instances[1]?.observedElements).toEqual(new Set([firstElement, secondElement]));
expect(IntersectionObserverMock.instances[1]?.rootMargin).toBe(getMobilePreloadRootMargin(700));
stopObservingFirst();
stopObservingSecond();
expect(IntersectionObserverMock.instances[1]?.disconnected).toBe(true);
});
test("removes one-time subscriptions after their card first becomes near", () => {
const element = document.createElement("div");
const listener = vi.fn();
const stopObserving = observeMobileViewportProximity(element, listener, { once: true });
IntersectionObserverMock.instances[0]?.emit(element, true);
expect(listener).toHaveBeenCalledOnce();
expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true);
stopObserving();
});
test("mounts content immediately when IntersectionObserver is unavailable", () => {
vi.stubGlobal("IntersectionObserver", undefined);
const listener = vi.fn();
const stopObserving = observeMobileViewportProximity(document.createElement("div"), listener);
expect(listener).toHaveBeenCalledWith(true);
stopObserving();
});
});
@@ -0,0 +1,116 @@
export const mobilePreloadViewportMultiplier = 2;
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
interface MobileViewportSubscription {
listener: (isNearViewport: boolean) => void;
once: boolean;
}
interface ObserveMobileViewportOptions {
once?: boolean;
}
const subscriptions = new Map<Element, Set<MobileViewportSubscription>>();
let observer: IntersectionObserver | null = null;
let observedRootMargin: string | null = null;
let isListeningForResize = false;
const stopObservingIfIdle = () => {
if (subscriptions.size > 0) return;
observer?.disconnect();
observer = null;
observedRootMargin = null;
if (isListeningForResize) {
window.removeEventListener("resize", handleViewportResize);
isListeningForResize = false;
}
};
const removeSubscription = (element: Element, subscription: MobileViewportSubscription) => {
const elementSubscriptions = subscriptions.get(element);
if (!elementSubscriptions) return;
elementSubscriptions.delete(subscription);
if (elementSubscriptions.size > 0) return;
subscriptions.delete(element);
observer?.unobserve(element);
stopObservingIfIdle();
};
const handleIntersections: IntersectionObserverCallback = (entries) => {
for (const entry of entries) {
const elementSubscriptions = subscriptions.get(entry.target);
if (!elementSubscriptions) continue;
for (const subscription of elementSubscriptions) {
subscription.listener(entry.isIntersecting);
if (entry.isIntersecting && subscription.once) {
removeSubscription(entry.target, subscription);
}
}
}
};
const createObserver = (rootMargin: string) => {
observer?.disconnect();
observer = new IntersectionObserver(handleIntersections, { rootMargin });
observedRootMargin = rootMargin;
for (const element of subscriptions.keys()) {
observer.observe(element);
}
};
function handleViewportResize() {
const rootMargin = getMobilePreloadRootMargin(window.innerHeight);
if (rootMargin === observedRootMargin) return;
createObserver(rootMargin);
}
const ensureObserver = () => {
if (observer) return;
createObserver(getMobilePreloadRootMargin(window.innerHeight));
window.addEventListener("resize", handleViewportResize);
isListeningForResize = true;
};
export const observeMobileViewportProximity = (
element: Element,
listener: (isNearViewport: boolean) => void,
options: ObserveMobileViewportOptions = {},
) => {
if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") {
listener(true);
return () => undefined;
}
const subscription: MobileViewportSubscription = {
listener,
once: options.once ?? false,
};
const elementSubscriptions = subscriptions.get(element);
if (elementSubscriptions) {
elementSubscriptions.add(subscription);
} else {
subscriptions.set(element, new Set([subscription]));
}
if (observer) {
observer.observe(element);
} else {
ensureObserver();
}
return () => {
removeSubscription(element, subscription);
};
};
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject } from "react";
import { useMemo, useRef } from "react";
import { useMemo, useRef, useState } from "react";
import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react";
@@ -20,20 +20,40 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { useHasNestedDialog } from "./mobile-dialog-stack";
import classes from "./mobile-board.module.css";
import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
import {
isMobileContextActionVisible,
shouldKeepMobileWidgetActionsMounted,
shouldRenderMobileWidgetActions,
} from "./mobile-presentation";
interface MobileWidgetActionsProps {
item: SectionItem;
supportsDetails: boolean;
detailsFromCard: boolean;
detailsOpened: boolean;
openDetails: () => void;
closeDetails: () => void;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
isNearViewport: boolean;
}
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
export const MobileWidgetActions = ({
item,
supportsDetails,
detailsFromCard,
detailsOpened,
openDetails,
closeDetails,
widgetStateRef,
isNearViewport,
}: MobileWidgetActionsProps) => {
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
const [isOpeningDetails, setIsOpeningDetails] = useState(false);
const [isCompletingAction, setIsCompletingAction] = useState(false);
const [actionTriggerHasFocus, setActionTriggerHasFocus] = useState(false);
const actionTriggerRef = useRef<HTMLButtonElement | null>(null);
const detailsDialogRef = useRef<HTMLDivElement | null>(null);
const openDetailsAfterActionsRef = useRef(false);
const pendingContextActionRef = useRef<(() => void) | null>(null);
const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
const t = useI18n();
const settings = useSettings();
@@ -59,133 +79,169 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const hasActions = shouldRenderMobileWidgetActions({
supportsDetails,
supportsDetails: supportsDetails && !detailsFromCard,
supportsRefresh: queryKey !== undefined,
visibleContextActionCount: visibleContextActions.length,
});
const shouldKeepMounted = shouldKeepMobileWidgetActionsMounted({
isNearViewport,
actionsOpened,
detailsOpened,
isOpeningDetails,
isCompletingAction,
actionTriggerHasFocus,
});
if (!hasActions) return null;
if ((!hasActions && !detailsOpened) || !shouldKeepMounted) return null;
return (
<>
<ActionIcon
ref={actionTriggerRef}
className={classes.widgetAction}
variant="filled"
color="dark"
radius="xl"
size={44}
aria-label={t("board.mobile.actions", { title })}
onClick={actionsDisclosure.open}
>
<IconDotsVertical size={20} />
</ActionIcon>
{hasActions && (
<>
<ActionIcon
ref={actionTriggerRef}
className={classes.widgetAction}
variant="filled"
color="dark"
radius="xl"
size={44}
aria-label={t("board.mobile.actions", { title })}
onClick={actionsDisclosure.open}
onFocus={() => setActionTriggerHasFocus(true)}
onBlur={() => setActionTriggerHasFocus(false)}
>
<IconDotsVertical size={20} />
</ActionIcon>
<Drawer
opened={actionsOpened}
onClose={actionsDisclosure.close}
title={title}
position="bottom"
size="auto"
padding="md"
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
styles={{
content: {
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},
}}
onExitTransitionEnd={() => {
if (!openDetailsAfterActionsRef.current) return;
openDetailsAfterActionsRef.current = false;
detailsDisclosure.open();
}}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
>
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
{supportsDetails && (
<Button
variant="subtle"
justify="flex-start"
size="lg"
leftSection={<IconMaximize size={20} />}
onClick={() => {
openDetailsAfterActionsRef.current = true;
actionsDisclosure.close();
}}
>
{t("board.mobile.details")}
</Button>
)}
{visibleContextActions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.key}
variant="subtle"
color={action.color}
justify="flex-start"
size="lg"
disabled={action.disabled}
leftSection={Icon ? <Icon size={20} /> : undefined}
onClick={() => {
action.onClick();
actionsDisclosure.close();
}}
>
{String(translateIfNecessary(t, action.label))}
</Button>
);
})}
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
</Stack>
</Drawer>
<Drawer
opened={actionsOpened}
onClose={actionsDisclosure.close}
title={title}
position="bottom"
size="auto"
padding="md"
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
returnFocus={false}
styles={{
content: {
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},
}}
onExitTransitionEnd={() => {
if (isOpeningDetails) {
setIsOpeningDetails(false);
openDetails();
return;
}
<Modal
ref={detailsDialogRef}
opened={detailsOpened}
onClose={detailsDisclosure.close}
title={title}
fullScreen
padding="md"
returnFocus={false}
trapFocus={!hasNestedDialog}
closeOnEscape={!hasNestedDialog}
onExitTransitionEnd={() => actionTriggerRef.current?.focus()}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
styles={{
content: {
display: "flex",
flexDirection: "column",
height: "100dvh",
paddingTop: "env(safe-area-inset-top)",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},
body: {
flex: "1 1 auto",
minHeight: 0,
overflowY: "auto",
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
},
}}
>
<Box className={classes.detailContent}>
<BoardItemContent
item={item}
displayMode="mobileDetail"
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
/>
</Box>
</Modal>
const pendingContextAction = pendingContextActionRef.current;
if (pendingContextAction) {
pendingContextActionRef.current = null;
setIsCompletingAction(false);
pendingContextAction();
return;
}
actionTriggerRef.current?.focus();
}}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
>
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
{supportsDetails && !detailsFromCard && (
<Button
variant="subtle"
justify="flex-start"
size="lg"
leftSection={<IconMaximize size={20} />}
onClick={() => {
setIsOpeningDetails(true);
actionsDisclosure.close();
}}
>
{t("board.mobile.details")}
</Button>
)}
{visibleContextActions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.key}
variant="subtle"
color={action.color}
justify="flex-start"
size="lg"
disabled={action.disabled}
leftSection={Icon ? <Icon size={20} /> : undefined}
onClick={() => {
pendingContextActionRef.current = action.onClick;
setIsCompletingAction(true);
actionsDisclosure.close();
}}
>
{String(translateIfNecessary(t, action.label))}
</Button>
);
})}
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
</Stack>
</Drawer>
</>
)}
{supportsDetails && (
<Modal
ref={detailsDialogRef}
opened={detailsOpened}
onClose={closeDetails}
title={title}
fullScreen
padding="md"
returnFocus={detailsFromCard}
trapFocus={!hasNestedDialog}
closeOnEscape={!hasNestedDialog}
aria-hidden={hasNestedDialog || undefined}
inert={hasNestedDialog || undefined}
onExitTransitionEnd={() => {
if (!detailsFromCard) {
actionTriggerRef.current?.focus();
}
}}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
styles={{
content: {
display: "flex",
flexDirection: "column",
height: "100dvh",
paddingTop: "env(safe-area-inset-top)",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
},
body: {
flex: "1 1 auto",
minHeight: 0,
overflowY: "auto",
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
},
}}
>
<Box className={classes.detailContent}>
<BoardItemContent
item={item}
displayMode="mobileDetail"
disableContextMenu
disableItemConfiguration
widgetStateRef={widgetStateRef}
/>
</Box>
</Modal>
)}
</>
);
};
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,6 +18,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -104,7 +105,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
@@ -130,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
}, [category, board, t, openConfirmModal, settings]);
}, [category, board, currentLayoutId, t, openConfirmModal, settings]);
return {
addCategoryAbove,
@@ -1,7 +1,7 @@
import { Badge, Box, Card } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
@@ -14,7 +14,6 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
@@ -52,8 +51,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
{options.title}
</Badge>
)}
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
<GridStack section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
@@ -4,6 +4,8 @@ import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout } from "@homarr/boards/context";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
@@ -15,6 +17,12 @@ interface Props extends BoxProps {
}
export const GridStack = ({ section, ...props }: Props) => {
const currentLayoutId = useCurrentLayout();
return <GridStackForLayout key={currentLayoutId} section={section} {...props} />;
};
const GridStackForLayout = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
@@ -103,6 +103,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
// Updates the react-query state
moveAndResizeItem({
itemId: id,
layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -120,6 +121,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
if (type === "section") {
moveAndResizeInnerSection({
innerSectionId: id,
layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -136,7 +138,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
[moveAndResizeItem, moveAndResizeInnerSection],
[currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
@@ -149,6 +151,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
// Updates the react-query state
moveItemToSection({
itemId: id,
layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -167,6 +170,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
if (type === "section") {
moveInnerSectionToSection({
innerSectionId: id,
layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -184,7 +188,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
[moveItemToSection, moveInnerSectionToSection, section.id],
[currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
@@ -1,10 +1,10 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
innerSectionId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -12,6 +12,7 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -23,7 +24,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -31,12 +32,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
@@ -50,7 +49,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -59,12 +58,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { resolveMobileBoardViewport } from "./use-mobile-board";
import {
resolveIsAutomaticMobileBoard,
resolveMobileBoardViewport,
shouldShowMobileBoardViewportSkeleton,
} from "./use-mobile-board";
describe("resolveMobileBoardViewport", () => {
test("keeps phones mobile regardless of their viewport width", () => {
@@ -56,3 +60,26 @@ describe("resolveMobileBoardViewport", () => {
});
});
});
describe("resolveIsAutomaticMobileBoard", () => {
test.each([
[true, true, true],
[true, false, false],
[false, true, false],
[false, false, false],
])(
"requires both a mobile viewport and the server setting (viewport: %s, setting: %s)",
(isMobileViewport, enableAutomaticMobileLayout, expected) => {
expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected);
},
);
test("does not show the tablet resolution skeleton in legacy mode", () => {
expect(
shouldShowMobileBoardViewportSkeleton({
enableAutomaticMobileLayout: false,
isResolved: false,
}),
).toBe(false);
});
});
@@ -5,6 +5,8 @@ import { createContext, useContext, useEffect, useState } from "react";
import { Box, Group, Skeleton, Stack } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useSettings } from "@homarr/settings";
export const mobileBoardMediaQuery = "(max-width: 48em)";
export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop";
@@ -70,6 +72,7 @@ export const MobileBoardViewportProvider = ({
initialDeviceClass,
children,
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
const { enableAutomaticMobileLayout } = useSettings();
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
getInitialValueInEffect: true,
@@ -87,11 +90,42 @@ export const MobileBoardViewportProvider = ({
return (
<MobileBoardViewportContext.Provider value={viewport}>
{viewport.isResolved ? children : <MobileBoardViewportSkeleton />}
{shouldShowMobileBoardViewportSkeleton({
enableAutomaticMobileLayout,
isResolved: viewport.isResolved,
}) ? (
<MobileBoardViewportSkeleton />
) : (
children
)}
</MobileBoardViewportContext.Provider>
);
};
export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext);
export const useIsMobileBoard = () => useMobileBoardViewport().isMobile;
export const resolveIsAutomaticMobileBoard = ({
isMobileViewport,
enableAutomaticMobileLayout,
}: {
isMobileViewport: boolean;
enableAutomaticMobileLayout: boolean;
}) => isMobileViewport && enableAutomaticMobileLayout;
export const shouldShowMobileBoardViewportSkeleton = ({
enableAutomaticMobileLayout,
isResolved,
}: {
enableAutomaticMobileLayout: boolean;
isResolved: boolean;
}) => enableAutomaticMobileLayout && !isResolved;
export const useIsMobileBoard = () => {
const viewport = useMobileBoardViewport();
const { enableAutomaticMobileLayout } = useSettings();
return resolveIsAutomaticMobileBoard({
isMobileViewport: viewport.isMobile,
enableAutomaticMobileLayout,
});
};
@@ -1,5 +1,6 @@
"use client";
import { requestBoardEditAction } from "@homarr/boards/edit-mode";
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
import { LanguageCombobox } from "./language-combobox";
@@ -16,7 +17,7 @@ export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguage
return (
<LanguageCombobox
value={currentLocale}
onChange={changeLocale}
onChange={(locale) => requestBoardEditAction(() => changeLocale(locale))}
isPending={isPending}
width={width}
withinPortal={withinPortal}
+35 -15
View File
@@ -17,8 +17,10 @@ import {
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
import { requestBoardEditAction } from "@homarr/boards/edit-mode";
import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -47,22 +49,36 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
await signOut({
redirect: false,
});
openModal({
onTimeout: () => {
if (logoutUrl) {
window.location.assign(logoutUrl);
return;
}
router.push("/auth/login");
},
});
}, [logoutUrl, openModal, router]);
try {
const response = await signOut({
redirect: false,
});
if (typeof response.url !== "string" || response.url.length === 0) {
throw new Error("Sign out did not return a success URL");
}
return await new Promise<boolean>((resolve) => {
openModal({
onTimeout: () => {
resolve(true);
if (logoutUrl) {
window.location.replace(logoutUrl);
return;
}
router.replace("/auth/login");
},
});
});
} catch {
showErrorNotification({
title: t("logoutError.title"),
message: t("logoutError.message"),
});
return false;
}
}, [logoutUrl, openModal, router, t]);
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 availableUpdatesPromise={availableUpdatesPromise} />
@@ -100,7 +116,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
<Menu.Item
onClick={() => requestBoardEditAction(handleSignout)}
leftSection={<IconLogout size="1rem" />}
color="red"
>
{t("logout")}
</Menu.Item>
) : (