Merge sidebar stability fix into dnd kit

This commit is contained in:
Thomas Camlong committed 2026-08-09 18:03:37 +02:00
commit bcbde92c2e
4 files changed
+51 -67

No files matched your search

+2 -2
View File
@@ -134,8 +134,8 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f
Enabling or widening a rail reflows canvas items into the remaining columns.
Disabling a rail moves its contents back into the next available canvas positions.
Rails remain visible while the page scrolls.
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode.
Rails fill the available window height and remain fixed while the page scrolls.
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary.
#### Breakpoint
@@ -9,14 +9,46 @@
}
.gutter {
position: relative;
top: 0;
--board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1));
--board-gutter-viewport-height: calc(
(100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1)
);
position: sticky;
top: var(--board-gutter-sticky-top);
align-self: start;
box-sizing: border-box;
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md));
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
transform: translateY(var(--board-gutter-sticky-offset, 0px));
}
.gutter[data-board-editing="true"] {
outline: 1px dashed var(--mantine-color-default-border);
outline-offset: -1px;
border-radius: var(--mantine-radius-md);
}
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,9 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { Box } from "@mantine/core";
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import {
@@ -23,66 +23,12 @@ import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
const useFixedBoardGutters = () => {
const [columns, setColumns] = useState<HTMLDivElement | null>(null);
useEffect(() => {
if (!columns) return;
let frame = 0;
const update = () => {
frame = 0;
const columnsRect = columns.getBoundingClientRect();
const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0;
const stickyTop = Math.max(0, headerBottom + 16);
for (const gutter of columns.querySelectorAll<HTMLElement>("[data-board-gutter]")) {
const rect = gutter.getBoundingClientRect();
if (gutter.offsetWidth <= 0) continue;
const effectiveScale = rect.width / gutter.offsetWidth;
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue;
const previousOffset = Number(gutter.dataset.stickyOffset ?? 0);
const uncorrectedTop = rect.top - previousOffset * effectiveScale;
const maximumTop = columnsRect.bottom - rect.height;
const targetTop = Math.min(stickyTop, maximumTop);
const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale;
gutter.dataset.stickyOffset = String(nextOffset);
gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`);
}
};
const scheduleUpdate = () => {
if (frame !== 0) return;
frame = window.requestAnimationFrame(update);
};
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(columns);
document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true });
window.addEventListener("scroll", scheduleUpdate, { passive: true });
window.addEventListener("resize", scheduleUpdate, { passive: true });
scheduleUpdate();
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
document.removeEventListener("scroll", scheduleUpdate, { capture: true });
window.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
};
}, [columns]);
return setColumns;
};
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
const columnsRef = useFixedBoardGutters();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
if (!currentLayout) throw new Error("Expected the board to contain a layout");
@@ -122,16 +68,13 @@ export const ClientBoard = () => {
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div
ref={columnsRef}
className={classes.columns}
style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}
>
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
@@ -157,6 +100,7 @@ export const ClientBoard = () => {
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
+8
View File
@@ -245,6 +245,14 @@ describe("Board grid", () => {
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0);
await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0);
await expect(rail).toHaveAttribute("data-board-editing", "true");
await expect(rail).toHaveCSS("position", "sticky");
await expect(rail).toHaveCSS("transform", "none");
const railBox = await expectBoundingBoxAsync(rail);
expect(railBox.height + railBox.y).toBeCloseTo(page.viewportSize()?.height ?? 0, 0);
expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe(
"Left dashboard rail",
);
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
await logicalTile.evaluate((element) => {