From 0a8e4fead0ddf5cb9f15f31f5455ca7536cc44ca Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:41:08 +0200 Subject: [PATCH] fix: stabilize board rails --- apps/docs/docs/management/boards/index.mdx | 4 +- .../boards/(content)/_client.module.css | 40 +++++++++-- .../app/[locale]/boards/(content)/_client.tsx | 66 ++----------------- e2e/board-grid.spec.ts | 8 +++ 4 files changed, 51 insertions(+), 67 deletions(-) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fb9467ef..0ea8d256e 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -133,8 +133,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 diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 158ad16ae..d0f0252e6 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -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); } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 689cb95ee..19bf1d0fa 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -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(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("[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 = () => { > -
+
{leftColumnCount > 0 && leftSection && (