feat: add mobile layout preview
This commit is contained in:
1 parent
c805d5c631
commit
bb5df3a2e6
9 files changed
+369
-29
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user