feat: refine mobile dashboard experience

This commit is contained in:
ajnart committed 2026-08-13 19:55:11 +02:00
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>
);
};