feat: refine mobile dashboard experience
This commit is contained in:
1 parent
2e3dca3e87
commit
84db2f9d49
68 files changed
+1425
-1311
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./deferred-mobile-item";
|
||||
|
||||
describe("getMobilePreloadRootMargin", () => {
|
||||
test.each([
|
||||
[568, "1136px 0px"],
|
||||
[844, "1688px 0px"],
|
||||
[932, "1864px 0px"],
|
||||
])("preloads %i px viewports using vertical pixels", (viewportHeight, expected) => {
|
||||
expect(getMobilePreloadRootMargin(viewportHeight)).toBe(expected);
|
||||
});
|
||||
|
||||
test("keeps the preload policy explicit and prevents negative margins", () => {
|
||||
expect(mobilePreloadViewportMultiplier).toBe(2);
|
||||
expect(getMobilePreloadRootMargin(-100)).toBe("0px 0px");
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,25 @@ import classes from "./mobile-board.module.css";
|
||||
|
||||
interface DeferredMobileItemProps extends PropsWithChildren {
|
||||
eager: boolean;
|
||||
unmountWhenOffscreen: boolean;
|
||||
}
|
||||
|
||||
export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => {
|
||||
export const mobilePreloadViewportMultiplier = 2;
|
||||
|
||||
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
|
||||
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
|
||||
|
||||
export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => {
|
||||
const [hasMounted, setHasMounted] = useState(eager);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasMountedOnceRef = useRef(eager);
|
||||
|
||||
useEffect(() => {
|
||||
if (eager || hasMounted) return;
|
||||
if (eager) {
|
||||
hasMountedOnceRef.current = true;
|
||||
setHasMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = wrapperRef.current;
|
||||
if (!element || typeof IntersectionObserver === "undefined") {
|
||||
@@ -23,17 +34,37 @@ export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting) return;
|
||||
setHasMounted(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: "200% 0px" },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [eager, hasMounted]);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const observe = () => {
|
||||
if (hasMountedOnceRef.current && !unmountWhenOffscreen) return;
|
||||
observer?.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const isNearViewport = entry?.isIntersecting ?? false;
|
||||
|
||||
if (isNearViewport) {
|
||||
hasMountedOnceRef.current = true;
|
||||
setHasMounted(true);
|
||||
if (!unmountWhenOffscreen) observer?.disconnect();
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.boardTitle {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
|
||||
@@ -18,8 +30,6 @@
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
}
|
||||
|
||||
.deferredItem {
|
||||
@@ -34,9 +44,18 @@
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
|
||||
padding-block: var(--mantine-spacing-xs);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
outline-offset: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -48,14 +67,21 @@
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
inset-inline-end: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.detailContent,
|
||||
.detailContent > :global(.grid-stack-item-content) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.detailContent :global(.mantine-ActionIcon-root),
|
||||
.detailContent :global(.mantine-Button-root) {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { useBoardPermissions } from "../permissions/client";
|
||||
import { SectionProvider } from "../sections/section-context";
|
||||
import { DeferredMobileItem } from "./deferred-mobile-item";
|
||||
import classes from "./mobile-board.module.css";
|
||||
@@ -70,19 +71,16 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
data-mobile-board-item={item.id}
|
||||
data-mobile-display-mode={presentation.displayMode}
|
||||
>
|
||||
<DeferredMobileItem eager={presentation.eager}>
|
||||
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode={presentation.displayMode}
|
||||
disableContextMenu
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
<MobileWidgetActions
|
||||
item={item}
|
||||
supportsDetails={presentation.supportsDetails}
|
||||
isReadOnly
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</DeferredMobileItem>
|
||||
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -90,6 +88,7 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
export const MobileBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
@@ -110,6 +109,9 @@ export const MobileBoard = () => {
|
||||
}}
|
||||
>
|
||||
<Box className={classes.stream} data-mobile-board>
|
||||
<Title order={1} className={classes.boardTitle}>
|
||||
{board.name}
|
||||
</Title>
|
||||
{elements.length === 0 ? (
|
||||
<Center className={classes.empty}>
|
||||
<Stack align="center" gap="xs" ta="center" maw={360}>
|
||||
@@ -120,7 +122,7 @@ export const MobileBoard = () => {
|
||||
{t("board.mobile.empty.title")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("board.mobile.empty.description")}
|
||||
{t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -130,10 +132,12 @@ export const MobileBoard = () => {
|
||||
<Title
|
||||
key={block.id}
|
||||
id={block.anchorId}
|
||||
order={2}
|
||||
size="h4"
|
||||
order={block.headingLevel}
|
||||
size={block.headingLevel === 2 ? "h4" : block.headingLevel === 3 ? "h5" : "h6"}
|
||||
fw={block.headingLevel === 2 ? 700 : 600}
|
||||
className={classes.sectionHeading}
|
||||
tabIndex={-1}
|
||||
style={{ marginInlineStart: Math.max(0, block.headingLevel - 2) * 12 }}
|
||||
>
|
||||
{block.title}
|
||||
</Title>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { hasOpenDialogOutside } from "./mobile-dialog-stack";
|
||||
|
||||
describe("hasOpenDialogOutside", () => {
|
||||
test("detects an open child dialog outside the details portal", () => {
|
||||
const root = document.createElement("div");
|
||||
const details = document.createElement("div");
|
||||
const child = document.createElement("div");
|
||||
details.setAttribute("role", "dialog");
|
||||
child.setAttribute("role", "dialog");
|
||||
root.append(details, child);
|
||||
|
||||
expect(hasOpenDialogOutside(details, root)).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores hidden and closed dialogs", () => {
|
||||
const root = document.createElement("div");
|
||||
const details = document.createElement("div");
|
||||
const hiddenChild = document.createElement("div");
|
||||
details.setAttribute("role", "dialog");
|
||||
hiddenChild.setAttribute("role", "dialog");
|
||||
hiddenChild.setAttribute("aria-hidden", "true");
|
||||
root.append(details, hiddenChild);
|
||||
|
||||
expect(hasOpenDialogOutside(details, root)).toBe(false);
|
||||
});
|
||||
|
||||
test("does not treat the current details dialog as nested", () => {
|
||||
const details = document.createElement("div");
|
||||
details.setAttribute("role", "dialog");
|
||||
|
||||
expect(hasOpenDialogOutside(details, details)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const getDialogElement = (element: HTMLElement | null) => {
|
||||
if (!element) return null;
|
||||
if (element.matches('[role="dialog"]')) return element;
|
||||
return element.closest<HTMLElement>('[role="dialog"]') ?? element.querySelector<HTMLElement>('[role="dialog"]');
|
||||
};
|
||||
|
||||
const isOpenDialog = (element: Element) =>
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
element.getAttribute("data-hidden") !== "true" &&
|
||||
element.getAttribute("data-state") !== "closed" &&
|
||||
!element.hasAttribute("hidden");
|
||||
|
||||
export const hasOpenDialogOutside = (element: HTMLElement | null, root: ParentNode = document) => {
|
||||
const currentDialog = getDialogElement(element);
|
||||
|
||||
return Array.from(root.querySelectorAll('[role="dialog"]')).some(
|
||||
(dialog) => dialog !== currentDialog && isOpenDialog(dialog),
|
||||
);
|
||||
};
|
||||
|
||||
export const useHasNestedDialog = (opened: boolean, dialogRef: RefObject<HTMLDivElement | null>) => {
|
||||
const [hasNestedDialog, setHasNestedDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || typeof MutationObserver === "undefined") return;
|
||||
|
||||
const update = () => setHasNestedDialog(hasOpenDialogOutside(dialogRef.current));
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-hidden", "data-hidden", "data-state", "hidden"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [dialogRef, opened]);
|
||||
|
||||
return opened && hasNestedDialog;
|
||||
};
|
||||
@@ -147,7 +147,10 @@ describe("createMobileBoardItems", () => {
|
||||
type: "sectionHeading",
|
||||
sectionId: category.id,
|
||||
anchorId: getMobileSectionAnchorId(category.id),
|
||||
headingLevel: 2,
|
||||
});
|
||||
expect(result[2]).toMatchObject({ type: "sectionHeading", headingLevel: 3 });
|
||||
expect(result[4]).toMatchObject({ type: "sectionHeading", headingLevel: 4 });
|
||||
});
|
||||
|
||||
test("omits headings for empty and unnamed sections while preserving their items", () => {
|
||||
@@ -180,4 +183,31 @@ describe("createMobileBoardItems", () => {
|
||||
expect(result.map((element) => element.type)).toEqual(["item", "item"]);
|
||||
expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]);
|
||||
});
|
||||
|
||||
test("does not skip heading levels when unnamed sections are flattened", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
|
||||
const unnamedCategory = new CategorySectionMockBuilder({ id: "category", name: " " }).build();
|
||||
const namedDynamicSection = new DynamicSectionMockBuilder({
|
||||
id: "dynamic",
|
||||
options: { title: "Services", borderColor: "", customCssClasses: [] },
|
||||
})
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id })
|
||||
.build();
|
||||
|
||||
board.sections.push(unnamedCategory, namedDynamicSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: namedDynamicSection.id })
|
||||
.build(),
|
||||
);
|
||||
|
||||
expect(createMobileBoardElements(board, desktopLayoutId)[0]).toMatchObject({
|
||||
type: "sectionHeading",
|
||||
title: "Services",
|
||||
headingLevel: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export type MobileBoardSectionHeading = {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
anchorId: string;
|
||||
headingLevel: 2 | 3 | 4 | 5 | 6;
|
||||
};
|
||||
|
||||
export type MobileBoardElement = SectionItem | MobileBoardSectionHeading;
|
||||
@@ -51,7 +52,10 @@ const getLayout = <TLayout extends PositionedLayout>(
|
||||
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
|
||||
)[0];
|
||||
|
||||
const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => {
|
||||
const createSectionHeading = (
|
||||
section: CategorySection | DynamicSectionItem,
|
||||
headingDepth: number,
|
||||
): MobileBoardSectionHeading | null => {
|
||||
const title = (section.kind === "category" ? section.name : section.options.title).trim();
|
||||
if (title.length === 0) return null;
|
||||
|
||||
@@ -61,6 +65,7 @@ const createSectionHeading = (section: CategorySection | DynamicSectionItem): Mo
|
||||
sectionId: section.id,
|
||||
title,
|
||||
anchorId: getMobileSectionAnchorId(section.id),
|
||||
headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -112,16 +117,18 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
|
||||
|
||||
const createSectionElements = (
|
||||
section: CategorySection | DynamicSectionItem | (typeof rootSections)[number],
|
||||
headingDepth = 0,
|
||||
): { elements: MobileBoardElement[]; items: SectionItem[] } => {
|
||||
if (visitedSections.has(section.id)) return { elements: [], items: [] };
|
||||
const sectionId = section.id;
|
||||
visitedSections.add(sectionId);
|
||||
const heading = section.kind === "empty" ? null : createSectionHeading(section, headingDepth);
|
||||
|
||||
const sectionElements: MobileBoardElement[] = [];
|
||||
const sectionItems: SectionItem[] = [];
|
||||
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
|
||||
if (element.type === "section") {
|
||||
const nestedResult = createSectionElements(element);
|
||||
const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth);
|
||||
sectionElements.push(...nestedResult.elements);
|
||||
sectionItems.push(...nestedResult.items);
|
||||
continue;
|
||||
@@ -136,7 +143,6 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
|
||||
|
||||
if (sectionItems.length === 0) return { elements: [], items: [] };
|
||||
|
||||
const heading = section.kind === "empty" ? null : createSectionHeading(section);
|
||||
return {
|
||||
elements: heading ? [heading, ...sectionElements] : sectionElements,
|
||||
items: sectionItems,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveMobileItemPresentation } from "./mobile-presentation";
|
||||
import {
|
||||
isMobileContextActionVisible,
|
||||
resolveMobileItemPresentation,
|
||||
shouldRenderMobileWidgetActions,
|
||||
} from "./mobile-presentation";
|
||||
|
||||
describe("resolveMobileItemPresentation", () => {
|
||||
test("renders apps as eager compact tiles", () => {
|
||||
@@ -9,19 +13,51 @@ describe("resolveMobileItemPresentation", () => {
|
||||
height: 1,
|
||||
eager: true,
|
||||
supportsDetails: false,
|
||||
unmountWhenOffscreen: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults widgets to full width and clamps desktop height", () => {
|
||||
test("defaults widgets to full width without inventing a detail view", () => {
|
||||
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
|
||||
width: 2,
|
||||
height: 3,
|
||||
eager: false,
|
||||
supportsDetails: false,
|
||||
unmountWhenOffscreen: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("enables details only when the widget explicitly supports them", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "calendar", height: 2 },
|
||||
{
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
supportsDetails: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers explicit presentation metadata", () => {
|
||||
test("keeps automatic footprint defaults when only details are configured", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "calendar", height: 7 },
|
||||
{
|
||||
supportsDetailView: true,
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
width: 2,
|
||||
height: 3,
|
||||
supportsDetails: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers explicit presentation metadata while eager mounting takes precedence", () => {
|
||||
expect(
|
||||
resolveMobileItemPresentation(
|
||||
{ kind: "clock", height: 3 },
|
||||
@@ -31,6 +67,7 @@ describe("resolveMobileItemPresentation", () => {
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: false,
|
||||
eager: true,
|
||||
unmountWhenOffscreen: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
@@ -39,6 +76,37 @@ describe("resolveMobileItemPresentation", () => {
|
||||
displayMode: "mobileSummary",
|
||||
supportsDetails: false,
|
||||
eager: true,
|
||||
unmountWhenOffscreen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRenderMobileWidgetActions", () => {
|
||||
test("does not render an action trigger without an explicit capability", () => {
|
||||
expect(
|
||||
shouldRenderMobileWidgetActions({
|
||||
supportsDetails: false,
|
||||
supportsRefresh: false,
|
||||
visibleContextActionCount: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ supportsDetails: true, supportsRefresh: false, visibleContextActionCount: 0 },
|
||||
{ supportsDetails: false, supportsRefresh: true, visibleContextActionCount: 0 },
|
||||
{ supportsDetails: false, supportsRefresh: false, visibleContextActionCount: 1 },
|
||||
])("renders an action trigger for an explicit capability", (capabilities) => {
|
||||
expect(shouldRenderMobileWidgetActions(capabilities)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMobileContextActionVisible", () => {
|
||||
test("requires actions to opt into the read-only mobile surface", () => {
|
||||
const action = { key: "configure", label: "Configure", onClick: () => undefined };
|
||||
|
||||
expect(isMobileContextActionVisible(action)).toBe(false);
|
||||
expect(isMobileContextActionVisible({ ...action, mobileVisible: true })).toBe(true);
|
||||
expect(isMobileContextActionVisible({ ...action, mobileVisible: true, hidden: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { WidgetMobilePresentation } from "@homarr/widgets";
|
||||
import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets";
|
||||
|
||||
export const resolveMobileItemPresentation = (
|
||||
item: { kind: WidgetKind; height: number },
|
||||
@@ -8,6 +8,20 @@ export const resolveMobileItemPresentation = (
|
||||
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 ?? item.kind !== "app",
|
||||
supportsDetails: mobile?.supportsDetailView === true,
|
||||
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
|
||||
unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
|
||||
});
|
||||
|
||||
export const shouldRenderMobileWidgetActions = ({
|
||||
supportsDetails,
|
||||
supportsRefresh,
|
||||
visibleContextActionCount,
|
||||
}: {
|
||||
supportsDetails: boolean;
|
||||
supportsRefresh: boolean;
|
||||
visibleContextActionCount: number;
|
||||
}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0;
|
||||
|
||||
export const isMobileContextActionVisible = (action: WidgetContextMenuAction) =>
|
||||
action.mobileVisible === true && !action.hidden;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { focusMobileBoardSection } from "./mobile-section-navigation";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("focusMobileBoardSection", () => {
|
||||
test.each([
|
||||
[false, "smooth"],
|
||||
[true, "auto"],
|
||||
] as const)("scrolls to and focuses the heading (reduced motion: %s)", (reduceMotion, behavior) => {
|
||||
const heading = document.createElement("h2");
|
||||
heading.id = "section";
|
||||
heading.tabIndex = -1;
|
||||
heading.scrollIntoView = vi.fn();
|
||||
document.body.append(heading);
|
||||
|
||||
expect(focusMobileBoardSection({ anchorId: heading.id, reduceMotion })).toBe(true);
|
||||
expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior, block: "start" });
|
||||
expect(document.activeElement).toBe(heading);
|
||||
});
|
||||
|
||||
test("does nothing when the section no longer exists", () => {
|
||||
expect(focusMobileBoardSection({ anchorId: "missing", reduceMotion: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
interface FocusMobileBoardSectionOptions {
|
||||
anchorId: string;
|
||||
reduceMotion: boolean;
|
||||
}
|
||||
|
||||
export const focusMobileBoardSection = ({ anchorId, reduceMotion }: FocusMobileBoardSectionOptions): boolean => {
|
||||
const heading = document.getElementById(anchorId);
|
||||
if (!heading) return false;
|
||||
|
||||
heading.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
|
||||
heading.focus({ preventScroll: true });
|
||||
return true;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useRef } 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";
|
||||
@@ -18,8 +18,9 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { useItemActions } from "../items/item-actions";
|
||||
import { useHasNestedDialog } from "./mobile-dialog-stack";
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
|
||||
|
||||
interface MobileWidgetActionsProps {
|
||||
item: SectionItem;
|
||||
@@ -30,18 +31,16 @@ interface MobileWidgetActionsProps {
|
||||
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
|
||||
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
|
||||
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
|
||||
const actionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const detailsDialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const openDetailsAfterActionsRef = useRef(false);
|
||||
const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const definition = widgetImports[item.kind].definition;
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = useMemo(
|
||||
() => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[definition, item.kind],
|
||||
);
|
||||
const isFetching = useIsFetching({ queryKey }) > 0;
|
||||
const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined;
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
[item.kind, item.options, settings],
|
||||
@@ -51,36 +50,32 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
|
||||
return definition.contextActions({
|
||||
options,
|
||||
setOptions: (partial: Record<string, unknown>) =>
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: { ...options, ...partial },
|
||||
}),
|
||||
setOptions: () => undefined,
|
||||
integrationIds: item.integrationIds,
|
||||
context: { isEditMode, boardId: board.id, itemId: item.id },
|
||||
widgetStateRef,
|
||||
} as never) as WidgetContextMenuAction[];
|
||||
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]);
|
||||
const visibleContextActions = contextActions.filter((action) => !action.hidden);
|
||||
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]);
|
||||
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
|
||||
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0;
|
||||
const hasActions = shouldRenderMobileWidgetActions({
|
||||
supportsDetails,
|
||||
supportsRefresh: queryKey !== undefined,
|
||||
visibleContextActionCount: visibleContextActions.length,
|
||||
});
|
||||
|
||||
if (!hasActions) return null;
|
||||
|
||||
const refresh = () => {
|
||||
void queryClient.refetchQueries({ queryKey, type: "all" });
|
||||
actionsDisclosure.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionIcon
|
||||
ref={actionTriggerRef}
|
||||
className={classes.widgetAction}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={44}
|
||||
aria-label={t("board.mobile.actions")}
|
||||
aria-label={t("board.mobile.actions", { title })}
|
||||
onClick={actionsDisclosure.open}
|
||||
>
|
||||
<IconDotsVertical size={20} />
|
||||
@@ -94,6 +89,21 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
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 && (
|
||||
@@ -103,8 +113,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
size="lg"
|
||||
leftSection={<IconMaximize size={20} />}
|
||||
onClick={() => {
|
||||
openDetailsAfterActionsRef.current = true;
|
||||
actionsDisclosure.close();
|
||||
detailsDisclosure.open();
|
||||
}}
|
||||
>
|
||||
{t("board.mobile.details")}
|
||||
@@ -130,37 +140,74 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={isFetching}
|
||||
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
|
||||
onClick={refresh}
|
||||
>
|
||||
{t("item.menu.label.refresh")}
|
||||
</Button>
|
||||
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<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: {
|
||||
height: "calc(100dvh - 4rem)",
|
||||
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 widgetStateRef={widgetStateRef} />
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode="mobileDetail"
|
||||
disableContextMenu
|
||||
isReadOnly
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => {
|
||||
const t = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const isFetching = useIsFetching({ queryKey }) > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={isFetching}
|
||||
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
|
||||
onClick={() => {
|
||||
void queryClient.refetchQueries({ queryKey, type: "all" });
|
||||
onComplete();
|
||||
}}
|
||||
>
|
||||
{t("item.menu.label.refresh")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user