fix(board): harden grid edit transitions

This commit is contained in:
Thomas Camlong committed 2026-08-21 15:49:10 +02:00
1 parent 5cd627d66c
commit 59b6f08a36
7 files changed
+135 -43

No files matched your search

@@ -32,6 +32,7 @@ import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel";
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
@@ -105,8 +106,8 @@ const WidgetDefinitionLoadError = ({
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width: measuredWidth, height: measuredHeight } = useElementSize<HTMLDivElement>();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
const { getEntryRuntime } = useBoardGridPortalHost();
const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime);
const width = measuredWidth || getLogicalTrackSize(item.width);
const height = measuredHeight || getLogicalTrackSize(item.height);
@@ -137,6 +138,11 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
);
};
const createBoardItemRuntime = () => ({
widgetStateRef: { current: null } as MutableRefObject<Record<string, unknown> | null>,
widgetRuntimeRef: { current: createWidgetRuntimeState() } as WidgetRuntimeRef,
});
interface LoadedBoardItemContentProps {
item: SectionItem;
width: number;
@@ -121,8 +121,8 @@ export const FixedGridItem = ({
current: placements,
};
}
const placements = keyboardGridRef.current.current;
const current = placements.find((candidate) => candidate.id === item.id);
const currentPlacements = keyboardGridRef.current.current;
const current = currentPlacements.find((candidate) => candidate.id === item.id);
if (!current) return;
let transaction: GridTransaction<SectionGridPlacement>;
@@ -134,7 +134,7 @@ export const FixedGridItem = ({
id: section.id,
columnCount,
maxRowCount,
placements,
placements: currentPlacements,
},
],
},
@@ -142,8 +142,8 @@ export const FixedGridItem = ({
);
} catch {
keyboardGridRef.current = {
source: placements,
current: placements,
source: currentPlacements,
current: currentPlacements,
};
setKeyboardEditing(false);
return;
@@ -46,6 +46,7 @@ export interface GridEditorRenderProps {
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
transactionPlacements: readonly SectionGridPlacement[];
className: string;
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
items: SectionItem[];
@@ -718,6 +718,7 @@ export default function GridEditor({
rowCount,
maxRowCount,
placements,
transactionPlacements,
className,
section,
items,
@@ -776,11 +777,11 @@ export default function GridEditor({
maxRowCount,
parentGridId,
ownerPlacementId,
placements,
placements: transactionPlacements,
depth,
element,
});
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, registerGrid, sectionId, transactionPlacements]);
return (
<div
@@ -1,35 +1,52 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import classes from "./section-grid.module.css";
interface GridPortalHostContextValue {
announce: (message: string) => void;
integrations: RouterOutputs["integration"]["all"] | undefined;
getEntryRuntime: <TRuntime>(entryId: string, createRuntime: () => TRuntime) => TRuntime;
}
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
/** Shared board-grid services for editor announcements and integration data. */
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode,
});
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
const entryRuntimesRef = useRef(new Map<string, unknown>());
const announce = useCallback((message: string) => {
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
}, []);
const getEntryRuntime = useCallback(<TRuntime,>(entryId: string, createRuntime: () => TRuntime): TRuntime => {
const existing = entryRuntimesRef.current.get(entryId);
if (existing !== undefined) return existing as TRuntime;
const runtime = createRuntime();
entryRuntimesRef.current.set(entryId, runtime);
return runtime;
}, []);
const liveItemIds = useMemo(() => new Set(board.items.map((item) => item.id)), [board.items]);
useEffect(() => {
for (const entryId of entryRuntimesRef.current.keys()) {
if (!liveItemIds.has(entryId)) entryRuntimesRef.current.delete(entryId);
}
}, [liveItemIds]);
const value = useMemo<GridPortalHostContextValue>(
() => ({ announce, integrations }),
[announce, integrations],
() => ({ announce, integrations, getEntryRuntime }),
[announce, getEntryRuntime, integrations],
);
return (
@@ -1,6 +1,6 @@
"use client";
import type { CSSProperties } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -23,7 +23,7 @@ import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { SectionContent } from "../content";
import { useCollapsedSectionIds } from "../section-collapse";
import { useCollapsedSectionIds, useExpandSectionsForEditing } from "../section-collapse";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host";
@@ -57,13 +57,11 @@ export const SectionGrid = ({
const { items, innerSections } = useSectionItems(section.id);
const { announce, integrations } = useBoardGridPortalHost();
const collapsedSectionIds = useCollapsedSectionIds();
const minimumBySectionId = useMemo(
() => {
const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
},
[board, currentLayoutId, innerSections],
);
const expandSectionsForEditing = useExpandSectionsForEditing();
const minimumBySectionId = useMemo(() => {
const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
}, [board, currentLayoutId, innerSections]);
const placements = useMemo(
() =>
@@ -86,7 +84,10 @@ export const SectionGrid = ({
[columnCount, innerSections, items, minimumBySectionId],
);
const collapsibleSectionIds = useMemo(
() => new Set(innerSections.filter((section) => section.options.collapsible).map((section) => section.id)),
() =>
new Set(
innerSections.filter((innerSection) => innerSection.options.collapsible).map((innerSection) => innerSection.id),
),
[innerSections],
);
@@ -131,15 +132,29 @@ export const SectionGrid = ({
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
// Collapsed display geometry is intentionally transient and must never be
// committed as the expanded layout. Expanding explicitly enables editing.
const isInteractionDisabled =
directCollapsedIds.size > 0 || (section.kind === "container" && collapsedSectionIds.has(section.id));
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
const canvasAttributes =
isEditMode && !isInteractionDisabled
? getEditableCanvasAttributes({ label, columnCount, rowCount })
: getReadonlyCanvasAttributes({ label });
const editorClassName = combineClasses("board-grid-editor", classes.editorGrid);
const expandCollapsedSectionsForPointerEdit = (event: PointerEvent<HTMLDivElement>) => {
if (!isEditMode || directCollapsedIds.size === 0 || event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
const entry = target.closest('[data-editor-grid-entry="true"]');
if (!entry || !event.currentTarget.contains(entry)) return;
if (!target.closest(".board-grid-resize-handle") && target.closest(INTERACTIVE_GRID_SELECTOR)) return;
expandSectionsForEditing(directCollapsedIds);
};
const expandCollapsedSectionsForKeyboardEdit = (event: KeyboardEvent<HTMLDivElement>) => {
if (!isEditMode || directCollapsedIds.size === 0 || !EDIT_ACTIVATION_KEYS.has(event.key)) return;
const target = event.target;
if (!(target instanceof Element) || !target.matches('[data-editor-grid-entry="true"]')) return;
expandSectionsForEditing(directCollapsedIds);
};
useLayoutEffect(() => {
const host = editorHostRef.current;
@@ -156,6 +171,7 @@ export const SectionGrid = ({
rowCount,
maxRowCount,
placements: displayPlacements,
transactionPlacements: placements,
className: editorClassName,
entryElementStore,
});
@@ -169,6 +185,7 @@ export const SectionGrid = ({
entryElementStore,
isInteractionDisabled,
maxRowCount,
placements,
rowCount,
section,
]);
@@ -191,15 +208,19 @@ export const SectionGrid = ({
<Box
{...canvasAttributes}
className={combineClasses(classes.viewport, className)}
style={{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties}
style={
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-section-id={section.id}
data-section-kind={section.kind}
data-rail-placement={railPlacement}
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
>
<Box
className={classes.staticGrid}
@@ -216,6 +237,10 @@ export const SectionGrid = ({
);
};
const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
const [visualHeight, setVisualHeight] = useState(0);
@@ -6,18 +6,22 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from "./section-collapse-storage";
interface SectionCollapseContextValue {
collapsedSectionIds: ReadonlySet<string>;
visuallyCollapsedSectionIds: ReadonlySet<string>;
setCollapsed: (sectionId: string, collapsed: boolean) => void;
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
}
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
@@ -26,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
),
);
const collapsedByIdRef = useRef(collapsedById);
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
collapsedByIdRef.current = collapsedById;
useEffect(() => {
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
}, [isEditMode]);
useEffect(() => {
setCollapsedById((previous) => {
const next = { ...previous };
@@ -56,6 +65,12 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
setTemporarilyExpandedIds((current) => {
if (!current.has(sectionId)) return current;
const next = new Set(current);
next.delete(sectionId);
return next;
});
if (status === "authenticated") {
mutate(
@@ -75,17 +90,33 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
[mutate, status],
);
const value = useMemo<SectionCollapseContextValue>(
() => ({
collapsedSectionIds: new Set(
Object.entries(collapsedById)
.filter(([, collapsed]) => collapsed)
.map(([sectionId]) => sectionId),
const expandForEditing = useCallback(
(sectionIds: ReadonlySet<string>) => {
if (!isEditMode || sectionIds.size === 0) return;
setTemporarilyExpandedIds((current) => {
const next = new Set(current);
for (const sectionId of sectionIds) next.add(sectionId);
return next.size === current.size ? current : next;
});
},
[isEditMode],
);
const value = useMemo<SectionCollapseContextValue>(() => {
const collapsedSectionIds = new Set(
Object.entries(collapsedById)
.filter(([, collapsed]) => collapsed)
.map(([sectionId]) => sectionId),
);
return {
collapsedSectionIds,
visuallyCollapsedSectionIds: new Set(
[...collapsedSectionIds].filter((sectionId) => !isEditMode || !temporarilyExpandedIds.has(sectionId)),
),
setCollapsed,
}),
[collapsedById, setCollapsed],
);
expandForEditing,
};
}, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
};
@@ -98,11 +129,12 @@ export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: stri
}
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
return {
isCollapsed,
isVisuallyCollapsed: isCollapsed,
isVisuallyCollapsed,
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
};
};
@@ -113,5 +145,15 @@ export const useCollapsedSectionIds = () => {
throw new Error("BoardSectionCollapseProvider is required");
}
return context.collapsedSectionIds;
return context.visuallyCollapsedSectionIds;
};
export const useExpandSectionsForEditing = () => {
const context = useContext(SectionCollapseContext);
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
return context.expandForEditing;
};