feat: add automatic mobile board layout
This commit is contained in:
1 parent
51a6cbe6d4
commit
f133e09dc6
15 files changed
+441
-370
No files matched your search
@@ -0,0 +1,20 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.item > :global(.grid-stack-item-content) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
|
||||
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { SectionProvider } from "../sections/section-context";
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { createMobileBoardItems } from "./mobile-layout";
|
||||
|
||||
export const MobileBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
const rootSection = board.sections.find((section) => section.kind !== "dynamic");
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
||||
const gridstackRef = useRef<GridStackInstance | null>(null);
|
||||
|
||||
if (!rootSection) return null;
|
||||
|
||||
return (
|
||||
<ReadOnlyEditModeProvider>
|
||||
<SectionProvider
|
||||
value={{
|
||||
section: rootSection,
|
||||
items,
|
||||
innerSections: [],
|
||||
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
|
||||
}}
|
||||
>
|
||||
<Box className={classes.grid} data-mobile-board>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
className={classes.item}
|
||||
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
|
||||
data-mobile-board-item={item.id}
|
||||
>
|
||||
<BoardItemContent item={item} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
</ReadOnlyEditModeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CategorySectionMockBuilder } from "../items/actions/test/mocks/category-section-mock";
|
||||
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 } from "./mobile-layout";
|
||||
|
||||
describe("createMobileBoardItems", () => {
|
||||
test("flattens categories and dynamic sections in visual order", () => {
|
||||
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", yOffset: 0 }).build();
|
||||
const category = new CategorySectionMockBuilder({ id: "category", yOffset: 1, collapsed: true }).build();
|
||||
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: rootSection.id, xOffset: 0, yOffset: 1 })
|
||||
.build();
|
||||
const nestedDynamicSection = new DynamicSectionMockBuilder({ id: "nested-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
|
||||
.build();
|
||||
|
||||
board.sections.push(rootSection, category, dynamicSection, nestedDynamicSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "second" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 1, yOffset: 0 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "first" })
|
||||
.addLayout({
|
||||
layoutId: desktopLayoutId,
|
||||
sectionId: rootSection.id,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 8,
|
||||
height: 6,
|
||||
})
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-nested-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-collapsed-category" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: category.id })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createMobileBoardItems(board, desktopLayoutId);
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
"inside-dynamic",
|
||||
"inside-nested-dynamic",
|
||||
"inside-collapsed-category",
|
||||
]);
|
||||
expect(result[0]).toMatchObject({ width: 2, height: 3, xOffset: 0, yOffset: 0 });
|
||||
});
|
||||
|
||||
test("uses the desktop layout and keeps orphaned items", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const legacyLayoutId = board.layouts.at(0)?.id;
|
||||
if (!legacyLayoutId) throw new Error("Expected a legacy layout");
|
||||
const desktopLayoutId = "desktop";
|
||||
board.layouts.push({ id: desktopLayoutId, name: "Large", columnCount: 16, breakpoint: 1200 });
|
||||
board.sections.push(new EmptySectionMockBuilder({ id: "root" }).build());
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "first" })
|
||||
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 2 })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section", yOffset: 0, width: 0, height: 0 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "second" })
|
||||
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 0 })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: "root", yOffset: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createMobileBoardItems(board, desktopLayoutId);
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
|
||||
expect(result[1]).toMatchObject({ width: 1, height: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
const mobileColumnCount = 2;
|
||||
const mobileMaxHeight = 3;
|
||||
|
||||
type PositionedElement = DynamicSectionItem | SectionItem;
|
||||
type PositionedLayout = {
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
|
||||
elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
|
||||
|
||||
const getLayout = <TLayout extends PositionedLayout>(
|
||||
layouts: TLayout[],
|
||||
desktopLayoutId: string,
|
||||
layoutPriority: Map<string, number>,
|
||||
) =>
|
||||
layouts.find((layout) => layout.layoutId === desktopLayoutId) ??
|
||||
layouts.toSorted(
|
||||
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
|
||||
)[0];
|
||||
|
||||
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
|
||||
const rootSections = board.sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset || sectionA.xOffset - sectionB.xOffset);
|
||||
const defaultSectionId = rootSections[0]?.id ?? "mobile";
|
||||
const layoutPriority = new Map(
|
||||
board.layouts
|
||||
.toSorted(
|
||||
(layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint || layoutA.columnCount - layoutB.columnCount,
|
||||
)
|
||||
.map((layout, index) => [layout.id, index]),
|
||||
);
|
||||
|
||||
const items = board.items.map(({ layouts, ...item }, index): SectionItem => {
|
||||
const layout =
|
||||
getLayout(layouts, desktopLayoutId, layoutPriority) ??
|
||||
({
|
||||
layoutId: desktopLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: 1,
|
||||
height: 1,
|
||||
sectionId: defaultSectionId,
|
||||
} satisfies ItemLayout);
|
||||
|
||||
return { ...item, ...layout, type: "item" };
|
||||
});
|
||||
|
||||
const dynamicSections = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map(({ layouts, ...section }): DynamicSectionItem | null => {
|
||||
const layout = getLayout(layouts, desktopLayoutId, layoutPriority);
|
||||
return layout ? { ...section, ...layout, type: "section" } : null;
|
||||
})
|
||||
.filter((section) => section !== null);
|
||||
|
||||
const elementsBySection = new Map<string, PositionedElement[]>();
|
||||
for (const element of [...items, ...dynamicSections]) {
|
||||
const parentSectionId = element.type === "item" ? element.sectionId : element.parentSectionId;
|
||||
const elements = elementsBySection.get(parentSectionId) ?? [];
|
||||
elements.push(element);
|
||||
elementsBySection.set(parentSectionId, elements);
|
||||
}
|
||||
|
||||
const result: SectionItem[] = [];
|
||||
const visitedItems = new Set<string>();
|
||||
const visitedSections = new Set<string>();
|
||||
|
||||
const appendSectionItems = (sectionId: string) => {
|
||||
if (visitedSections.has(sectionId)) return;
|
||||
visitedSections.add(sectionId);
|
||||
|
||||
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
|
||||
if (element.type === "section") {
|
||||
appendSectionItems(element.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!visitedItems.has(element.id)) {
|
||||
visitedItems.add(element.id);
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rootSections.forEach((section) => appendSectionItems(section.id));
|
||||
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
|
||||
items.toSorted(comparePosition).forEach((item) => {
|
||||
if (!visitedItems.has(item.id)) result.push(item);
|
||||
});
|
||||
|
||||
return result.map((item, index) => ({
|
||||
...item,
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: Math.max(1, Math.min(item.width, mobileColumnCount)),
|
||||
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
export const mobileBoardMediaQuery = "(max-width: 48em)";
|
||||
|
||||
export const useIsMobileBoard = () => useMediaQuery(mobileBoardMediaQuery);
|
||||
Reference in new issue
Block a user