feat: add mobile layout preview

This commit is contained in:
ajnart committed 2026-08-08 12:21:53 +02:00
1 parent 5a21df86e2
commit 2c1e697f68
11 files changed
+375 -29

No files matched your search

@@ -90,10 +90,14 @@ The available controls depend on the server-wide **Use automatic mobile layout**
When automatic mode is enabled, this section configures the desktop column count. Items and sections can be moved and resized on desktop while the board is in edit mode.
Automatic mode is enabled by default on fresh Homarr installations. It is a server-wide setting, so creating another account on an existing installation does not change the saved choice.
Phones and small tablets display the same widgets in a generated, read-only two-column grid. Widget content and controls are unchanged. Items follow desktop visual order and retain their desktop footprint, clamped to two columns wide and three rows high.
Categories and dynamic sections are flattened: their items participate directly in the generated grid instead of rendering nested layout containers. To rearrange, add, remove, or resize items, open the board on a larger screen.
In desktop edit mode, select the floating **Mobile preview** button to expand a lightweight map of the generated layout. The map updates with unsaved board changes and can be collapsed back to the button without leaving edit mode.
<img src={layoutLargeImage} height={256} />
#### Saved responsive layouts
@@ -5,6 +5,7 @@ import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { MobileBoard } from "~/components/board/mobile/mobile-board";
import { MobileBoardPreview } from "~/components/board/mobile/mobile-preview";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
@@ -35,6 +36,7 @@ export const ClientBoard = () => {
)}
</Stack>
)}
<MobileBoardPreview />
</Box>
);
};
@@ -1,8 +1,9 @@
.grid {
container-type: inline-size;
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
grid-auto-rows: min(calc((100cqw - 42px) / var(--mobile-column-count)), 12rem);
gap: 10px;
padding-bottom: 10px;
}
@@ -5,7 +5,7 @@ import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
import { createMobileBoardItems, getMobileRootSection } from "./mobile-layout";
import { createMobileBoardItems, createMobileBoardPreviewItems, getMobileRootSection } from "./mobile-layout";
describe("createMobileBoardItems", () => {
test("selects the first root section in visual order", () => {
@@ -115,3 +115,37 @@ describe("createMobileBoardItems", () => {
]);
});
});
describe("createMobileBoardPreviewItems", () => {
test("returns no tiles for an empty board", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([]);
});
test("uses the production mobile order, footprint, kind, and custom title", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
board.sections.push(rootSection);
board.items.push(
new ItemMockBuilder({ id: "wide", kind: "notebook" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 8, height: 6, yOffset: 1 })
.build(),
new ItemMockBuilder({ id: "first", kind: "clock" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 1, height: 1, yOffset: 0 })
.build(),
);
const wideItem = board.items.find((item) => item.id === "wide");
if (!wideItem) throw new Error("Expected wide board item");
wideItem.advancedOptions.title = "Notes";
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([
{ id: "first", kind: "clock", width: 1, height: 1, title: null },
{ id: "wide", kind: "notebook", width: 2, height: 3, title: "Notes" },
]);
});
});
@@ -115,3 +115,12 @@ export const createMobileBoardItems = (board: Board, desktopLayoutId: string): S
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
}));
};
export const createMobileBoardPreviewItems = (board: Board, desktopLayoutId: string) =>
createMobileBoardItems(board, desktopLayoutId).map((item) => ({
id: item.id,
kind: item.kind,
width: item.width,
height: item.height,
title: item.advancedOptions.title,
}));
@@ -0,0 +1,39 @@
.panel {
width: min(390px, calc(100vw - var(--mantine-spacing-xl)));
max-height: min(70vh, 700px);
overflow: hidden;
}
.viewport {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: 42px;
gap: var(--mantine-spacing-xs);
}
.item {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
}
.itemContent {
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.panel,
.item {
scroll-behavior: auto;
transition: none;
}
}
@@ -0,0 +1,115 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { ActionIcon, Affix, Button, Center, Group, Paper, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconDeviceMobile, IconX } from "@tabler/icons-react";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { useIsMobileBoard } from "../use-mobile-board";
import classes from "./mobile-preview.module.css";
import { createMobileBoardPreviewItems, mobileColumnCount } from "./mobile-layout";
export const MobileBoardPreview = () => {
const [isEditMode] = useEditMode();
const [isExpanded, setIsExpanded] = useState(false);
const isMobileBoard = useIsMobileBoard();
const { enableAutomaticMobileLayout } = useSettings();
const t = useI18n();
useEffect(() => {
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) {
setIsExpanded(false);
}
}, [enableAutomaticMobileLayout, isEditMode, isMobileBoard]);
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) return null;
return (
<Affix position={{ bottom: "var(--mantine-spacing-md)", right: "var(--mantine-spacing-md)" }} zIndex={200}>
{isExpanded ? (
<Paper className={classes.panel} radius="md" shadow="md" data-mobile-board-preview>
<Group justify="space-between" wrap="nowrap" p="sm">
<Group gap="xs" wrap="nowrap">
<IconDeviceMobile size={20} aria-hidden />
<Text fw={600}>{t("board.mobilePreview.title")}</Text>
</Group>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setIsExpanded(false)}
aria-label={t("board.mobilePreview.collapse")}
>
<IconX size={18} />
</ActionIcon>
</Group>
<MobileBoardPreviewMap />
</Paper>
) : (
<Button
leftSection={<IconDeviceMobile size={20} />}
onClick={() => setIsExpanded(true)}
data-mobile-board-preview-toggle
>
{t("board.mobilePreview.action")}
</Button>
)}
</Affix>
);
};
const MobileBoardPreviewMap = () => {
const board = useRequiredBoard();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardPreviewItems(board, desktopLayout.id), [board, desktopLayout.id]);
const t = useI18n();
return (
<ScrollArea.Autosize className={classes.viewport} mah="calc(min(70vh, 700px) - 52px)" p="sm">
{items.length === 0 ? (
<Center mih={160}>
<Stack gap={4} align="center">
<IconDeviceMobile size={28} color="var(--mantine-color-dimmed)" aria-hidden />
<Text size="sm" c="dimmed" ta="center">
{t("board.mobilePreview.empty")}
</Text>
</Stack>
</Center>
) : (
<div
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
data-mobile-board-preview-grid
>
{items.map((item) => {
const WidgetIcon = widgetCatalogIcons[item.kind];
const label = item.title?.trim() || t(`widget.${item.kind}.name`);
return (
<div
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-preview-item={item.id}
>
<Group className={classes.itemContent} gap={6} wrap="nowrap" px="xs">
<ThemeIcon variant="light" size="sm">
<WidgetIcon size={14} aria-hidden />
</ThemeIcon>
<Text size="xs" fw={500} truncate title={label}>
{label}
</Text>
</Group>
</div>
);
})}
</div>
)}
</ScrollArea.Autosize>
);
};
+107 -26
View File
@@ -1,7 +1,6 @@
import type { Page } from "@playwright/test";
import { chromium, devices, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { stringify } from "superjson";
import { parse, stringify } from "superjson";
import { describe, test } from "vitest";
import { eq } from "drizzle-orm";
@@ -19,23 +18,12 @@ const credentials = {
const boardName = "mobile-e2e";
const setAutomaticMobileLayoutAsync = async (page: Page, enabled: boolean) => {
await page.goto(new URL("/manage/settings", page.url()).toString());
const toggle = page.getByRole("switch", { name: "Use automatic mobile layout" });
await expect(toggle).toBeVisible();
if ((await toggle.isChecked()) !== enabled) {
await toggle.setChecked(enabled);
await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Settings saved successfully")).toBeVisible();
}
};
const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
const boardId = createId();
const desktopLayoutId = createId();
const mobileLayoutId = createId();
const sectionId = createId();
const emptySectionId = createId();
const notebookId = createId();
const clockId = createId();
@@ -60,14 +48,23 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
breakpoint: 0,
},
]);
await db.insert(sqliteSchema.sections).values({
id: sectionId,
boardId,
kind: "category",
name: "Dashboard",
xOffset: 0,
yOffset: 0,
});
await db.insert(sqliteSchema.sections).values([
{
id: sectionId,
boardId,
kind: "category",
name: "Dashboard",
xOffset: 0,
yOffset: 0,
},
{
id: emptySectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 1,
},
]);
await db.insert(sqliteSchema.items).values([
{
id: notebookId,
@@ -133,10 +130,42 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
};
describe("Automatic mobile board", () => {
test("enables automatic mobile layout for a fresh database", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const container = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
try {
const boardSettings = await db.query.serverSettings.findFirst({
where: eq(sqliteSchema.serverSettings.settingKey, "board"),
});
if (!boardSettings) throw new Error("Expected board server settings to be seeded");
expect(parse<{ enableAutomaticMobileLayout: boolean }>(boardSettings.value).enableAutomaticMobileLayout).toBe(
true,
);
} finally {
await container.stop();
}
}, 90_000);
test("squeezes unchanged widgets into a read-only two-column grid", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, credentials);
await seedBoardAsync(db, userId);
await db.insert(sqliteSchema.serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
const container = await createHomarrContainer({
environment: {
@@ -157,7 +186,26 @@ describe("Automatic mobile board", () => {
await loginPage.locator("#password").fill(credentials.password);
await loginPage.locator("button[type='submit']").click();
await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
await setAutomaticMobileLayoutAsync(loginPage, true);
await loginPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(loginPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await loginPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click();
const previewToggle = loginPage.getByRole("button", { name: "Mobile preview", exact: true });
await expect(previewToggle).toBeVisible();
await previewToggle.click();
await expect(loginPage.locator("[data-mobile-board-preview]")).toBeVisible();
await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(2);
await loginPage
.locator("header button")
.filter({ has: loginPage.locator("svg.tabler-icon-plus") })
.click();
await loginPage.getByRole("menuitem", { name: "New item", exact: true }).click();
await loginPage.getByPlaceholder("Filter items...").fill("Weather");
await loginPage.getByPlaceholder("Filter items...").press("Enter");
await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(3);
await loginPage.getByRole("button", { name: "Cancel", exact: true }).click();
await loginPage.getByRole("button", { name: "Collapse mobile preview", exact: true }).click();
await expect(loginPage.locator("[data-mobile-board-preview]")).toHaveCount(0);
await expect(previewToggle).toBeVisible();
const storageState = await loginContext.storageState();
const mobileContext = await browser.newContext({
@@ -173,6 +221,7 @@ describe("Automatic mobile board", () => {
const mobileBoard = mobilePage.locator("[data-mobile-board]");
const items = mobilePage.locator("[data-mobile-board-item]");
await expect(mobileBoard).toBeVisible({ timeout: 15_000 });
await expect(mobilePage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await expect(items).toHaveCount(2);
await expect(mobilePage.locator(".grid-stack-item")).toHaveCount(0);
await expect(mobilePage.getByText("Normal notebook content")).toBeVisible();
@@ -204,11 +253,43 @@ describe("Automatic mobile board", () => {
expect(metrics.firstToSecondHeightRatio).toBeGreaterThan(1.8);
expect(metrics.horizontalOverflow).toBeLessThanOrEqual(0);
await mobileContext.close();
} finally {
await loginContext.close();
await browser.close();
await container.stop();
}
}, 180_000);
await setAutomaticMobileLayoutAsync(loginPage, false);
test("keeps preview and generated mobile grid disabled in legacy mode", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, credentials);
await seedBoardAsync(db, userId);
const container = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
const browser = await chromium.launch();
const desktopContext = await browser.newContext();
try {
const desktopPage = await desktopContext.newPage();
await desktopPage.goto(`${baseUrl}/auth/login`);
await desktopPage.getByLabel("Username").fill(credentials.username);
await desktopPage.locator("#password").fill(credentials.password);
await desktopPage.locator("button[type='submit']").click();
await desktopPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
await desktopPage.goto(`${baseUrl}/boards/${boardName}`);
await desktopPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click();
await expect(desktopPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
const legacyContext = await browser.newContext({
storageState: await loginContext.storageState(),
storageState: await desktopContext.storageState(),
viewport: { width: 390, height: 844 },
userAgent: devices["iPhone 13"].userAgent,
hasTouch: true,
@@ -221,7 +302,7 @@ describe("Automatic mobile board", () => {
await expect(legacyPage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toBeVisible();
await legacyContext.close();
} finally {
await loginContext.close();
await desktopContext.close();
await browser.close();
await container.stop();
}
+1 -1
View File
@@ -55,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards returned by the public-board listing tool or API, using each board's id field, or be null",
},
})
.input(boardServerSettingsUpdateSchema)
+55
View File
@@ -72,6 +72,61 @@ describe("automatic mobile layout upgrade default", () => {
});
});
test("persists the enabled default when a fresh instance is seeded repeatedly", async () => {
const db = createDb();
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
test("backfills the enabled default for canonical layouts", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values({
id: createId(),
boardId,
name: "Base",
columnCount: 12,
breakpoint: 0,
});
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
test("does not override an explicit disabled choice", async () => {
const db = createDb();
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
});
test("backfills legacy responsive instances once without overriding an explicit choice", async () => {
const db = createDb();
const boardId = createId();
+6
View File
@@ -4183,6 +4183,12 @@
}
},
"board": {
"mobilePreview": {
"title": "Mobile preview",
"action": "Mobile preview",
"collapse": "Collapse mobile preview",
"empty": "Add items to the board to preview the automatic mobile layout."
},
"action": {
"duplicate": {
"title": "Duplicate board",