Merge custom option test fix into mobile boards
This commit is contained in:
32 files changed
+798
-238
No files matched your search
@@ -6,21 +6,21 @@ tags:
|
||||
|
||||
Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| ---------------------------------------------- | ------------------------------------------ |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` while hovering a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
| Shortcut | Description |
|
||||
| ----------------------------------------------- | ------------------------------------------ |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
|
||||
:::tip
|
||||
|
||||
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
|
||||
|
||||
Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal
|
||||
dimensions instead.
|
||||
dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ Underscores and hyphen-minus are allowed.
|
||||
|
||||
The column count defines how many fixed-width cells are available in each row.
|
||||
One cell contains a `200 × 200` logical-pixel tile.
|
||||
Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small.
|
||||
Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling.
|
||||
The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge.
|
||||
|
||||
### Public
|
||||
|
||||
@@ -95,6 +96,14 @@ In this section you can configure the different responsive layouts. Homarr selec
|
||||
|
||||
Every board has two protected layouts:
|
||||
|
||||
Every `1 × 1` tile has a `200 × 200` logical-pixel content area.
|
||||
The `24` logical-pixel gap between tiles is separate from that size.
|
||||
A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them.
|
||||
Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller.
|
||||
When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size.
|
||||
On larger canvases, the complete board grows together like browser zoom.
|
||||
The complete canvas always fits the available width and never adds horizontal board scrolling.
|
||||
|
||||
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
||||
|
||||
@@ -104,9 +113,10 @@ Each layout stores independent positions and sizes. Moving or resizing a widget
|
||||
|
||||
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
||||
|
||||
Every `1 × 1` tile has a `200 × 200` logical-pixel content area, with a separate `24` logical-pixel gap between tiles. Homarr scales the whole canvas to the available width without shrinking below 75%; narrower screens scroll horizontally so controls remain accessible.
|
||||
|
||||
The board edge is a hard drag boundary. Moving a tile outside it cancels the placement, while adding content can still create the rows it needs. New items use their widget's default size and new Containers use a useful `4 × 3` size when space allows.
|
||||
The canvas is at least one window tall, even when it is empty.
|
||||
Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward.
|
||||
Nested Containers keep their own resizable bounds.
|
||||
New items use their widget's default size and new Containers use a useful `4 × 3` size when space allows.
|
||||
|
||||
#### Name
|
||||
|
||||
@@ -118,7 +128,18 @@ The total number of columns in the layout. Changing it projects the saved positi
|
||||
|
||||
#### Fixed rails
|
||||
|
||||
Each layout can reserve a fixed left rail, a fixed right rail, or both. Set a rail width to zero to disable it, or choose one to three columns. Reserved rail columns reduce the main canvas width. Apps, widgets, and Containers in rails use the same fixed cell sizes and remain visible while the page scrolls.
|
||||
Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
|
||||
Set a rail width to zero to disable it for that layout.
|
||||
Choose a width from one to three columns with the three-step slider.
|
||||
The preview shows how much space remains for the main canvas.
|
||||
|
||||
Rail columns are reserved from the layout's total column count.
|
||||
For example, a 12-column layout with a two-column left rail leaves ten columns for the canvas.
|
||||
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 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
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ description: "Stats of your containers"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
@@ -44,8 +42,6 @@ import dockerTable from "./img/table.png";
|
||||
|
||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
|
||||
Advanced mode exposes every available column regardless of the compact-mode column selection.
|
||||
|
||||
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
|
||||
|
||||
- **View logs** - opens a dedicated terminal page streaming the container's logs
|
||||
@@ -53,8 +49,6 @@ Each container row has an action menu, accessible via the three-dot icon or by r
|
||||
- **Remove** - permanently remove the container after a second confirmation click
|
||||
- **Add to Homarr** - create a bookmark app from the container
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
@@ -9,14 +9,48 @@
|
||||
}
|
||||
|
||||
.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-x: hidden;
|
||||
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;
|
||||
inset-inline-start: 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, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getRepresentativeLayoutWidth } from "../_layout-utils";
|
||||
@@ -25,68 +25,14 @@ 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 tPreview = useScopedI18n("board.setting.section.layout.preview");
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
const layoutOverrideId = useLayoutOverride();
|
||||
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");
|
||||
|
||||
@@ -128,16 +74,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}`}
|
||||
@@ -163,6 +106,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}`}
|
||||
|
||||
@@ -16,9 +16,15 @@
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
overscroll-behavior: contain;
|
||||
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation-fill-mode: backwards;
|
||||
animation-play-state: paused;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.surfaceReady {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
.manualSurface {
|
||||
position: fixed;
|
||||
max-width: none;
|
||||
@@ -82,6 +88,58 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.holdIndicator {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: var(--mantine-z-index-modal);
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--mantine-color-text);
|
||||
pointer-events: none;
|
||||
transform: translate3d(var(--advanced-focus-pointer-x), var(--advanced-focus-pointer-y), 0) translate(-50%, -50%);
|
||||
place-items: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.holdIndicatorRing {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 1px 3px rgb(0 0 0 / 35%));
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.holdIndicatorTrack,
|
||||
.holdIndicatorProgress {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.holdIndicatorTrack {
|
||||
fill: color-mix(in srgb, var(--mantine-color-body) 82%, transparent);
|
||||
stroke: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
|
||||
}
|
||||
|
||||
.holdIndicatorProgress {
|
||||
fill: none;
|
||||
stroke: var(--mantine-primary-color-filled);
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
animation: advanced-focus-hold-progress 500ms linear forwards;
|
||||
}
|
||||
|
||||
.holdIndicatorLabel {
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
@keyframes focus-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -110,12 +168,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes advanced-focus-hold-progress {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop,
|
||||
.surface,
|
||||
.manualSurface {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.holdIndicatorProgress {
|
||||
stroke-dashoffset: 0;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
@@ -123,4 +192,21 @@
|
||||
background: Canvas;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.holdIndicatorTrack {
|
||||
fill: Canvas;
|
||||
}
|
||||
|
||||
.holdIndicatorTrack {
|
||||
stroke: GrayText;
|
||||
}
|
||||
|
||||
.holdIndicatorProgress {
|
||||
stroke: Highlight;
|
||||
}
|
||||
|
||||
.holdIndicatorLabel {
|
||||
color: CanvasText;
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { BoardAdvancedFocusProvider, useAdvancedFocus } from "./context";
|
||||
|
||||
const sourceRect = {
|
||||
left: 20,
|
||||
top: 30,
|
||||
right: 220,
|
||||
bottom: 150,
|
||||
width: 200,
|
||||
height: 120,
|
||||
x: 20,
|
||||
y: 30,
|
||||
toJSON: () => undefined,
|
||||
};
|
||||
|
||||
describe("BoardAdvancedFocusProvider preview interaction", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let focus: ReturnType<typeof useAdvancedFocus>;
|
||||
|
||||
const Harness = () => {
|
||||
focus = useAdvancedFocus();
|
||||
return createElement(
|
||||
"div",
|
||||
null,
|
||||
createElement("div", { "data-testid": "source" }),
|
||||
createElement("div", { "data-advanced-focus-surface": true, "data-testid": "surface" }),
|
||||
createElement(
|
||||
"div",
|
||||
{ "data-portal": true },
|
||||
createElement("button", { role: "menuitem", "data-testid": "portalled-menu-item" }),
|
||||
),
|
||||
createElement("div", { "data-testid": "outside" }),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
);
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => []),
|
||||
});
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(
|
||||
EditModeProvider,
|
||||
null,
|
||||
createElement(BoardAdvancedFocusProvider, null, createElement(Harness)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const source = host.querySelector<HTMLElement>("[data-testid='source']");
|
||||
if (!source) throw new Error("Expected source fixture");
|
||||
vi.spyOn(source, "getBoundingClientRect").mockReturnValue(sourceRect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const startPreview = async () => {
|
||||
const source = host.querySelector<HTMLElement>("[data-testid='source']");
|
||||
if (!source) throw new Error("Expected source fixture");
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 48, clientY: 52 }));
|
||||
focus.hover("widget-1", source);
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }));
|
||||
});
|
||||
};
|
||||
|
||||
test("shows pointer-following hold feedback for the full 500 ms delay", async () => {
|
||||
await startPreview();
|
||||
|
||||
const indicator = document.querySelector<HTMLElement>("[data-advanced-focus-hold-indicator]");
|
||||
expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-x")).toBe("48px");
|
||||
expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-y")).toBe("52px");
|
||||
expect(focus.active).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(499));
|
||||
expect(focus.active).toBeNull();
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).not.toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(1));
|
||||
expect(focus.active?.itemId).toBe("widget-1");
|
||||
expect(focus.active?.activation).toBe("preview");
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
});
|
||||
|
||||
test("does not show hold feedback when Shift is pressed without a hovered source", async () => {
|
||||
await act(async () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })));
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(500));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("cancels the hold feedback and activation when Shift is released early", async () => {
|
||||
await startPreview();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })));
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("cancels the hold feedback and activation when the source is left early", async () => {
|
||||
await startPreview();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
await act(async () => focus.leave("widget-1"));
|
||||
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(250));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the preview open across the source-to-portal gap and closes after leaving both", async () => {
|
||||
await startPreview();
|
||||
await act(async () => vi.advanceTimersByTime(500));
|
||||
|
||||
await act(async () => focus.leave("widget-1"));
|
||||
await act(async () => vi.advanceTimersByTime(40));
|
||||
const surface = host.querySelector<HTMLElement>("[data-testid='surface']");
|
||||
if (!surface) throw new Error("Expected surface fixture");
|
||||
await act(async () =>
|
||||
surface.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 300, clientY: 200 })),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTime(80));
|
||||
expect(focus.active?.phase).toBe("visible");
|
||||
|
||||
const outside = host.querySelector<HTMLElement>("[data-testid='outside']");
|
||||
if (!outside) throw new Error("Expected outside fixture");
|
||||
await act(async () =>
|
||||
outside.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 500, clientY: 500 })),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTime(79));
|
||||
expect(focus.active?.phase).toBe("visible");
|
||||
await act(async () => vi.advanceTimersByTime(1));
|
||||
expect(focus.active?.phase).toBe("closing");
|
||||
await act(async () => vi.advanceTimersByTime(180));
|
||||
expect(focus.active).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the preview open while a widget-owned portalled popup is hovered", async () => {
|
||||
await startPreview();
|
||||
await act(async () => vi.advanceTimersByTime(500));
|
||||
|
||||
await act(async () => focus.leave("widget-1"));
|
||||
await act(async () => vi.advanceTimersByTime(40));
|
||||
const menuItem = host.querySelector<HTMLElement>("[data-testid='portalled-menu-item']");
|
||||
if (!menuItem) throw new Error("Expected portalled menu fixture");
|
||||
await act(async () =>
|
||||
menuItem.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 320, clientY: 210 })),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTime(80));
|
||||
|
||||
expect(focus.active?.phase).toBe("visible");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { CSSProperties, PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Overlay, Portal } from "@mantine/core";
|
||||
import { useIsomorphicEffect } from "@mantine/hooks";
|
||||
@@ -36,10 +36,9 @@ interface AdvancedFocusContextValue {
|
||||
}
|
||||
|
||||
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
|
||||
const HOVER_DELAY_MS = 150;
|
||||
const HOVER_DELAY_MS = 500;
|
||||
const POINTER_TRANSITION_GRACE_MS = 80;
|
||||
const CLOSE_DURATION_MS = 180;
|
||||
const interactiveSelector =
|
||||
"a[href], button, input, textarea, select, [contenteditable='true'], [role='button'], [role='link'], [role='menuitem'], [role='option']";
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null) =>
|
||||
target instanceof HTMLElement &&
|
||||
@@ -48,16 +47,28 @@ const isEditableTarget = (target: EventTarget | null) =>
|
||||
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [active, setActive] = useState<ActiveFocus | null>(null);
|
||||
const [isHoldPending, setIsHoldPending] = useState(false);
|
||||
const activeRef = useRef<ActiveFocus | null>(null);
|
||||
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
|
||||
const shiftHeldRef = useRef(false);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previewLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const holdIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const pointerPositionRef = useRef({ x: -100, y: -100 });
|
||||
const pointerFrameRef = useRef<number | null>(null);
|
||||
|
||||
const cancelHoverTimer = useCallback(() => {
|
||||
if (hoverTimerRef.current === null) return;
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
setIsHoldPending(false);
|
||||
}, []);
|
||||
|
||||
const cancelPreviewLeaveTimer = useCallback(() => {
|
||||
if (previewLeaveTimerRef.current === null) return;
|
||||
clearTimeout(previewLeaveTimerRef.current);
|
||||
previewLeaveTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const updateActive = useCallback((next: ActiveFocus | null) => {
|
||||
@@ -75,6 +86,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
(itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => {
|
||||
if (isEditMode) return;
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
cancelCloseTimer();
|
||||
const rect = source.getBoundingClientRect();
|
||||
updateActive({
|
||||
@@ -87,12 +99,13 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
phase: "visible",
|
||||
});
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive],
|
||||
[cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, isEditMode, updateActive],
|
||||
);
|
||||
|
||||
const close = useCallback(
|
||||
(restoreFocus = true) => {
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
const current = activeRef.current;
|
||||
if (!current || current.phase === "closing") return;
|
||||
updateActive({ ...current, phase: "closing" });
|
||||
@@ -103,22 +116,43 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
|
||||
}, closeDuration);
|
||||
},
|
||||
[cancelHoverTimer, updateActive],
|
||||
[cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
|
||||
);
|
||||
|
||||
const schedulePreviewClose = useCallback(
|
||||
(itemId: string) => {
|
||||
if (previewLeaveTimerRef.current !== null) return;
|
||||
previewLeaveTimerRef.current = setTimeout(() => {
|
||||
previewLeaveTimerRef.current = null;
|
||||
const current = activeRef.current;
|
||||
if (current?.activation !== "preview" || current.itemId !== itemId) return;
|
||||
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
|
||||
close(current.restorePreviewFocus);
|
||||
}, POINTER_TRANSITION_GRACE_MS);
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
const startHoverTimer = useCallback(() => {
|
||||
cancelHoverTimer();
|
||||
const hovered = hoveredRef.current;
|
||||
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
|
||||
hoverTimerRef.current = setTimeout(
|
||||
() => open(hovered.itemId, hovered.source, { activation: "preview" }),
|
||||
HOVER_DELAY_MS,
|
||||
);
|
||||
if (pointerPositionRef.current.x < 0 || pointerPositionRef.current.y < 0) {
|
||||
const rect = hovered.source.getBoundingClientRect();
|
||||
pointerPositionRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
setIsHoldPending(true);
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
hoverTimerRef.current = null;
|
||||
setIsHoldPending(false);
|
||||
open(hovered.itemId, hovered.source, { activation: "preview" });
|
||||
}, HOVER_DELAY_MS);
|
||||
}, [cancelHoverTimer, open]);
|
||||
|
||||
const hover = useCallback(
|
||||
(itemId: string, source: HTMLElement) => {
|
||||
hoveredRef.current = { itemId, source };
|
||||
cancelPreviewLeaveTimer();
|
||||
const current = activeRef.current;
|
||||
if (current?.activation === "preview" && current.itemId !== itemId) {
|
||||
cancelCloseTimer();
|
||||
@@ -127,18 +161,38 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
startHoverTimer();
|
||||
},
|
||||
[cancelCloseTimer, startHoverTimer, updateActive],
|
||||
[cancelCloseTimer, cancelPreviewLeaveTimer, startHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
|
||||
if (holdIndicatorRef.current && pointerFrameRef.current === null) {
|
||||
pointerFrameRef.current = requestAnimationFrame(() => {
|
||||
pointerFrameRef.current = null;
|
||||
const indicator = holdIndicatorRef.current;
|
||||
if (!indicator) return;
|
||||
indicator.style.setProperty("--advanced-focus-pointer-x", `${pointerPositionRef.current.x}px`);
|
||||
indicator.style.setProperty("--advanced-focus-pointer-y", `${pointerPositionRef.current.y}px`);
|
||||
});
|
||||
}
|
||||
|
||||
const current = activeRef.current;
|
||||
if (!shiftHeldRef.current || current?.activation !== "preview") return;
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest(interactiveSelector)?.closest("[data-advanced-focus-surface]")
|
||||
)
|
||||
if (event.target instanceof Element && event.target.closest("[data-advanced-focus-surface], [data-portal]")) {
|
||||
cancelPreviewLeaveTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const isInsideSourceRect =
|
||||
event.clientX >= current.sourceRect.left &&
|
||||
event.clientX <= current.sourceRect.left + current.sourceRect.width &&
|
||||
event.clientY >= current.sourceRect.top &&
|
||||
event.clientY <= current.sourceRect.top + current.sourceRect.height;
|
||||
if (isInsideSourceRect) {
|
||||
cancelPreviewLeaveTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const underlyingSource = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
@@ -151,7 +205,10 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
)
|
||||
.find((source) => source !== null && source !== current.source);
|
||||
const itemId = underlyingSource?.dataset.itemId;
|
||||
if (!underlyingSource || !itemId) return;
|
||||
if (!underlyingSource || !itemId) {
|
||||
schedulePreviewClose(current.itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
|
||||
hover(itemId, underlyingSource);
|
||||
@@ -160,31 +217,38 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
schedulePreviewClose(current.itemId);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
}, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]);
|
||||
window.addEventListener("pointerover", handlePointerMove, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
window.removeEventListener("pointerover", handlePointerMove, { capture: true });
|
||||
if (pointerFrameRef.current !== null) cancelAnimationFrame(pointerFrameRef.current);
|
||||
};
|
||||
}, [cancelHoverTimer, cancelPreviewLeaveTimer, hover, schedulePreviewClose]);
|
||||
|
||||
const leave = useCallback(
|
||||
(itemId: string) => {
|
||||
if (hoveredRef.current?.itemId !== itemId) return;
|
||||
hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current?.activation === "preview" && activeRef.current.itemId === itemId)
|
||||
schedulePreviewClose(itemId);
|
||||
},
|
||||
[cancelHoverTimer],
|
||||
[cancelHoverTimer, schedulePreviewClose],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(itemId: string) => {
|
||||
if (activeRef.current?.itemId !== itemId) return;
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, updateActive],
|
||||
[cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -223,9 +287,10 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
cancelHoverTimer();
|
||||
cancelPreviewLeaveTimer();
|
||||
cancelCloseTimer();
|
||||
};
|
||||
}, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]);
|
||||
}, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && activeRef.current) close(false);
|
||||
@@ -252,6 +317,28 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<AdvancedFocusContext.Provider value={value}>
|
||||
{children}
|
||||
{isHoldPending && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={holdIndicatorRef}
|
||||
data-advanced-focus-hold-indicator
|
||||
className={classes.holdIndicator}
|
||||
style={
|
||||
{
|
||||
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
|
||||
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
|
||||
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
|
||||
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
|
||||
</svg>
|
||||
<span className={classes.holdIndicatorLabel}>⇧</span>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
{active?.activation === "preview" && (
|
||||
<Portal>
|
||||
<Overlay
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { startAdvancedFocusEntrance } from "./entrance";
|
||||
|
||||
describe("startAdvancedFocusEntrance", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("moves the persistent portal before releasing its source-frame animation", () => {
|
||||
let releaseFrame: FrameRequestCallback | undefined;
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
releaseFrame = callback;
|
||||
return 7;
|
||||
}),
|
||||
);
|
||||
|
||||
const compactHost = document.createElement("div");
|
||||
const previewHost = document.createElement("div");
|
||||
const portalTarget = document.createElement("div");
|
||||
const surface = document.createElement("div");
|
||||
surface.classList.add("surface-ready");
|
||||
portalTarget.append(surface);
|
||||
compactHost.append(portalTarget);
|
||||
document.body.append(compactHost, previewHost);
|
||||
|
||||
expect(startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready")).toBe(7);
|
||||
expect(portalTarget.parentElement).toBe(previewHost);
|
||||
expect(surface.classList.contains("surface-ready")).toBe(false);
|
||||
|
||||
releaseFrame?.(0);
|
||||
expect(surface.classList.contains("surface-ready")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not release a detached surface", () => {
|
||||
let releaseFrame: FrameRequestCallback | undefined;
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
releaseFrame = callback;
|
||||
return 8;
|
||||
}),
|
||||
);
|
||||
|
||||
const previewHost = document.createElement("div");
|
||||
const portalTarget = document.createElement("div");
|
||||
const surface = document.createElement("div");
|
||||
portalTarget.append(surface);
|
||||
document.body.append(previewHost);
|
||||
|
||||
startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready");
|
||||
portalTarget.remove();
|
||||
releaseFrame?.(0);
|
||||
|
||||
expect(surface.classList.contains("surface-ready")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const startAdvancedFocusEntrance = (
|
||||
portalTarget: HTMLElement,
|
||||
host: HTMLElement,
|
||||
surface: HTMLElement,
|
||||
readyClass: string,
|
||||
) => {
|
||||
host.append(portalTarget);
|
||||
surface.classList.remove(readyClass);
|
||||
|
||||
return requestAnimationFrame(() => {
|
||||
if (surface.isConnected) surface.classList.add(readyClass);
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
|
||||
import { Suspense, use, useEffect, useRef, useState } from "react";
|
||||
import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
@@ -27,6 +27,7 @@ import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
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";
|
||||
@@ -153,6 +154,7 @@ const LoadedBoardItemContent = ({
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const sourceRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const previewEntranceFrameRef = useRef<number | null>(null);
|
||||
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
@@ -213,14 +215,52 @@ const LoadedBoardItemContent = ({
|
||||
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
}, [isAdvanced, isManual, manualSurface]);
|
||||
|
||||
const mountPortalTarget = (node: HTMLDivElement | null, activation: "compact" | "preview" | "manual") => {
|
||||
if (!node || !surfacePortalTarget) return;
|
||||
const shouldMount =
|
||||
(activation === "compact" && !isAdvanced) ||
|
||||
(activation === "preview" && isPreview) ||
|
||||
(activation === "manual" && isManual);
|
||||
if (shouldMount) node.append(surfacePortalTarget);
|
||||
};
|
||||
const cancelPreviewEntrance = useCallback(() => {
|
||||
if (previewEntranceFrameRef.current === null) return;
|
||||
cancelAnimationFrame(previewEntranceFrameRef.current);
|
||||
previewEntranceFrameRef.current = null;
|
||||
}, []);
|
||||
|
||||
const mountCompactHost = useCallback(
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || isAdvanced) return;
|
||||
cancelPreviewEntrance();
|
||||
host.append(surfacePortalTarget);
|
||||
},
|
||||
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
||||
);
|
||||
|
||||
const mountPreviewHost = useCallback(
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || !isPreview) {
|
||||
if (!host) cancelPreviewEntrance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeFocus?.phase !== "visible") {
|
||||
host.append(surfacePortalTarget);
|
||||
return;
|
||||
}
|
||||
const card = cardRef.current;
|
||||
const readyClass = advancedFocusClasses.surfaceReady;
|
||||
if (!card || !readyClass) return;
|
||||
|
||||
cancelPreviewEntrance();
|
||||
previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass);
|
||||
},
|
||||
[activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget],
|
||||
);
|
||||
|
||||
const mountManualHost = useCallback(
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || !isManual) return;
|
||||
cancelPreviewEntrance();
|
||||
host.append(surfacePortalTarget);
|
||||
},
|
||||
[cancelPreviewEntrance, isManual, surfacePortalTarget],
|
||||
);
|
||||
|
||||
useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]);
|
||||
|
||||
const previewStyle =
|
||||
isPreview && advancedRect && activeFocus
|
||||
@@ -331,16 +371,13 @@ const LoadedBoardItemContent = ({
|
||||
}}
|
||||
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
||||
>
|
||||
<div ref={(node) => mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}>
|
||||
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
|
||||
{!supportsAdvancedFocus && widgetCard}
|
||||
</div>
|
||||
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
|
||||
{isPreview && (
|
||||
<Portal reuseTargetNode={false}>
|
||||
<div
|
||||
ref={(node) => mountPortalTarget(node, "preview")}
|
||||
className={advancedFocusClasses.surfaceHostAdvanced}
|
||||
/>
|
||||
<div ref={mountPreviewHost} className={advancedFocusClasses.surfaceHostAdvanced} />
|
||||
</Portal>
|
||||
)}
|
||||
{isManual && activeFocus && advancedRect && closePosition && (
|
||||
@@ -357,10 +394,7 @@ const LoadedBoardItemContent = ({
|
||||
contentRef={setManualSurface}
|
||||
onClose={() => close()}
|
||||
>
|
||||
<div
|
||||
ref={(node) => mountPortalTarget(node, "manual")}
|
||||
className={advancedFocusClasses.manualSurfaceMount}
|
||||
/>
|
||||
<div ref={mountManualHost} className={advancedFocusClasses.manualSurfaceMount} />
|
||||
</AdvancedFocusManualSurface>
|
||||
)}
|
||||
</Box>
|
||||
@@ -400,7 +434,7 @@ interface InnerContentProps {
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -474,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
||||
if (when) throw error;
|
||||
|
||||
@@ -5,12 +5,14 @@ The board uses fixed logical units:
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
||||
- the canvas is transformed once with one uniform scale derived from its available width.
|
||||
- the canvas is zoomed once with one uniform scale derived from its available width.
|
||||
|
||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||
stable.
|
||||
stable. The visual canvas always fits the available width, every root lane
|
||||
keeps at least one viewport of height, and edit-mode previews extend it downward
|
||||
as needed.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||
export const LOGICAL_GRID_GAP = 24;
|
||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||
@@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert
|
||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
|
||||
|
||||
export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => {
|
||||
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
|
||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
|
||||
|
||||
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
|
||||
};
|
||||
|
||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
columnCount: number,
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
@@ -18,8 +16,6 @@
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
transform-origin: top left;
|
||||
will-change: transform;
|
||||
|
||||
/*
|
||||
* Keep dashboard UI at its normal readable density while the fixed canvas
|
||||
@@ -44,9 +40,3 @@
|
||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
export { MIN_ACCESSIBLE_CANVAS_SCALE };
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
@@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
|
||||
return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
|
||||
return availableWidth / logicalWidth;
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
@@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps {
|
||||
|
||||
/**
|
||||
* Scales the complete board as one surface while keeping widget layout in
|
||||
* fixed logical pixels. The scale floor deliberately allows horizontal
|
||||
* overflow so browser zoom still magnifies content instead of being cancelled
|
||||
* by responsive downscaling.
|
||||
* fixed logical pixels. The complete canvas always fits its viewport so board
|
||||
* content can never widen the document or require horizontal scrolling.
|
||||
*/
|
||||
export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
@@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({
|
||||
[logicalWidth, resolvedAvailableWidth],
|
||||
);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const inverseScale = scale > 0 ? 1 / scale : 1;
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
|
||||
@@ -84,8 +81,9 @@ export const ScaledBoardCanvas = ({
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
transform: `scale(${scale})`,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index";
|
||||
import {
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
normalizeGridPlacement,
|
||||
} from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
@@ -24,4 +30,12 @@ describe("fixed dashboard geometry", () => {
|
||||
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
|
||||
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
|
||||
});
|
||||
|
||||
test("derives enough logical rows to fill the visual viewport at any canvas scale", () => {
|
||||
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
||||
|
||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
|
||||
|
||||
describe("board canvas scale policy", () => {
|
||||
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
|
||||
test("fits the logical canvas exactly", () => {
|
||||
const scale = calculateBoardCanvasScale(840, 1000);
|
||||
|
||||
expect(scale).toBe(0.84);
|
||||
@@ -14,16 +14,16 @@ describe("board canvas scale policy", () => {
|
||||
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
|
||||
});
|
||||
|
||||
test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
|
||||
test("keeps fitting narrow viewports without horizontal overflow", () => {
|
||||
const availableWidth = 600;
|
||||
const logicalWidth = 1000;
|
||||
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||
|
||||
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
|
||||
expect(scale).toBe(0.6);
|
||||
expect(logicalWidth * scale).toBe(availableWidth);
|
||||
});
|
||||
|
||||
test("keeps content materially magnified at 200% effective zoom", () => {
|
||||
test("keeps the board fitted at 200% effective zoom", () => {
|
||||
const logicalWidth = 1696;
|
||||
const normalAvailableWidth = 1872;
|
||||
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
|
||||
@@ -31,8 +31,8 @@ describe("board canvas scale policy", () => {
|
||||
const normalPaintedCellSize = 200 * normalScale;
|
||||
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
|
||||
|
||||
expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
|
||||
expect(zoomedScale).toBeCloseTo(normalScale / 2);
|
||||
expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize);
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
@@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
|
||||
if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
|
||||
if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null;
|
||||
|
||||
return (
|
||||
<SectionGrid
|
||||
|
||||
@@ -86,6 +86,10 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.board-grid-placeholder {
|
||||
z-index: 0;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
@@ -178,7 +182,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: 32px;
|
||||
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
@@ -194,7 +198,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: 32px;
|
||||
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
@@ -217,8 +221,8 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
z-index: 9;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract";
|
||||
import { pointerIntersection } from "@dnd-kit/collision";
|
||||
import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom";
|
||||
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers";
|
||||
import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities";
|
||||
import type {
|
||||
BeforeDragStartEvent,
|
||||
@@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
|
||||
if (!previewGrid || !interaction) return placements;
|
||||
|
||||
if (interaction.sourceGridId === sectionId) {
|
||||
if (interaction.mode === "resize") return previewGrid.placements;
|
||||
const original = controlledById.get(interaction.activeId);
|
||||
if (!original) return previewGrid.placements;
|
||||
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
|
||||
@@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
|
||||
);
|
||||
const targetPlacement =
|
||||
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
|
||||
const growsDuringDrag =
|
||||
interaction?.mode === "drag" &&
|
||||
(interaction.targetGridId === sectionId ||
|
||||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
|
||||
const participatesInInteraction =
|
||||
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
|
||||
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
|
||||
const renderedRowCount =
|
||||
section.kind !== "container" && interaction?.mode === "resize"
|
||||
? Math.max(rowCount, getLayoutRowCount(renderPlacements))
|
||||
section.kind !== "container" && participatesInInteraction
|
||||
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
|
||||
: rowCount;
|
||||
|
||||
const { ref: droppableRef, isDropTarget } = useDroppable<GridTargetData>({
|
||||
@@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
|
||||
],
|
||||
[],
|
||||
);
|
||||
const modifiers = useMemo(
|
||||
() => [
|
||||
RestrictToElement.configure({
|
||||
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const {
|
||||
ref: draggableRef,
|
||||
handleRef,
|
||||
@@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
|
||||
type: GRID_ENTRY_TYPE,
|
||||
data: sourceData,
|
||||
sensors,
|
||||
modifiers,
|
||||
});
|
||||
const setShellRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
@@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types
|
||||
import {
|
||||
getCollapsedDisplayLayout,
|
||||
getEditableCanvasAttributes,
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -81,6 +83,7 @@ export const SectionGrid = ({
|
||||
className,
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const canvasScale = useBoardCanvasScale();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
@@ -144,8 +147,9 @@ export const SectionGrid = ({
|
||||
);
|
||||
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount);
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
@@ -199,6 +203,24 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
const [visualHeight, setVisualHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
|
||||
update();
|
||||
window.addEventListener("resize", update, { passive: true });
|
||||
window.visualViewport?.addEventListener("resize", update, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.visualViewport?.removeEventListener("resize", update);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
||||
};
|
||||
|
||||
const getContainerMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("board grid import boundary", () => {
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/abstract" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/collision" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/modifiers" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/utilities" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/react" },
|
||||
]);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { Button, Modal } from "@mantine/core";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { modalSizeSelect } from "@homarr/modals";
|
||||
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
|
||||
@@ -14,7 +16,7 @@ export function WorkshopCssImportButton({ onImport }: { onImport(css: string): v
|
||||
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
|
||||
Import from Workshop
|
||||
</Button>
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size="xl">
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}>
|
||||
<WorkshopBrowser
|
||||
type="customCss"
|
||||
onUseCss={(css) => {
|
||||
|
||||
@@ -130,7 +130,11 @@ describe("Board advanced interactions", () => {
|
||||
await interactivePreviewProbe.evaluate((element) => element.remove());
|
||||
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
|
||||
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
|
||||
await expect(previewSurface).toBeVisible();
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
await page.mouse.move(4, 4);
|
||||
await expect(previewSurface).toBeHidden();
|
||||
await otherWidget.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
|
||||
+72
-52
@@ -6,11 +6,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { stringify as stringifySuperJSON } from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import {
|
||||
LOGICAL_GRID_CELL_SIZE,
|
||||
LOGICAL_GRID_GAP,
|
||||
MIN_ACCESSIBLE_CANVAS_SCALE,
|
||||
} from "../apps/nextjs/src/components/board/layout/constants";
|
||||
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP } from "../apps/nextjs/src/components/board/layout/constants";
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
@@ -26,7 +22,6 @@ const gridRuntimeMarker = "homarr-dnd-kit-v1";
|
||||
const dndKitPayloadMarker = "dnd-kit-description";
|
||||
const logicalCellSize = LOGICAL_GRID_CELL_SIZE;
|
||||
const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE;
|
||||
|
||||
describe("Board grid", () => {
|
||||
test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => {
|
||||
@@ -100,6 +95,11 @@ describe("Board grid", () => {
|
||||
await expect(rail).toBeVisible();
|
||||
await expect(rail).toHaveAttribute("data-board-gutter", "left");
|
||||
await expect(railRootSection).toHaveAttribute("data-rail-placement", "left");
|
||||
await expect(rail).toHaveCSS("overflow-x", "hidden");
|
||||
const initialRailBox = await expectBoundingBoxAsync(rail);
|
||||
const initialRailRootBox = await expectBoundingBoxAsync(railRootSection);
|
||||
expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1);
|
||||
expect(initialRailRootBox.height).toBeGreaterThanOrEqual(initialRailBox.height - 1);
|
||||
await expect(railItem).toHaveAttribute("data-grid-x", "0");
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
|
||||
@@ -174,21 +174,15 @@ describe("Board grid", () => {
|
||||
expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01);
|
||||
|
||||
// CSS zoom exercises Chromium's browser-zoom layout path without relying
|
||||
// on desktop browser chrome. At 200% the scale floor must preserve
|
||||
// magnification and expose horizontal scrolling instead of shrinking the
|
||||
// board by the inverse zoom factor.
|
||||
// on desktop browser chrome. The board must keep fitting without adding
|
||||
// horizontal scrolling at any effective zoom.
|
||||
await setDocumentZoomAsync(page, 2);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "true");
|
||||
const zoomedCanvasScale = await readCanvasScaleAsync(canvas);
|
||||
expect(zoomedCanvasScale).toBeLessThan(normalCanvasScale);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
|
||||
await expectFixedLogicalTileAsync(logicalTile);
|
||||
await expectUniformVisualScaleAsync(logicalTile);
|
||||
const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => {
|
||||
return Number.parseFloat(getComputedStyle(element).fontSize);
|
||||
});
|
||||
expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25);
|
||||
const zoomedTileBox = await expectBoundingBoxAsync(logicalTile);
|
||||
expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25);
|
||||
await expectHorizontalOverflowAsync(canvas);
|
||||
await expect(canvas).toHaveCSS("overflow-x", "clip");
|
||||
await expectDocumentNotHorizontallyScrollableAsync(page);
|
||||
const overflowRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" }));
|
||||
@@ -251,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) => {
|
||||
@@ -337,8 +339,11 @@ describe("Board grid", () => {
|
||||
await expect(itemSelectDialog).toHaveCount(0);
|
||||
|
||||
const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`);
|
||||
const editorViewport = page.viewportSize();
|
||||
if (!editorViewport) throw new Error("Expected a browser viewport");
|
||||
expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1);
|
||||
const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid);
|
||||
await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem);
|
||||
await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem);
|
||||
await firstItem.locator("[data-grid-item-content]").evaluate((element) => {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.dataset.e2eDragIframe = "true";
|
||||
@@ -367,8 +372,8 @@ describe("Board grid", () => {
|
||||
.evaluateAll((elements) => elements.forEach((element) => element.remove()));
|
||||
|
||||
await page.setViewportSize({ width: 600, height: 1200 });
|
||||
await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
|
||||
await expectHorizontalOverflowAsync(canvas);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
|
||||
await expect(canvas).toHaveCSS("overflow-x", "clip");
|
||||
await expectDocumentNotHorizontallyScrollableAsync(page);
|
||||
for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) {
|
||||
await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`));
|
||||
@@ -718,13 +723,12 @@ describe("Board grid", () => {
|
||||
expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale);
|
||||
|
||||
await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 });
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
|
||||
await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1");
|
||||
expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1);
|
||||
const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor);
|
||||
const liveContainerBox = await expectBoundingBoxAsync(containerSection);
|
||||
expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1);
|
||||
expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1);
|
||||
expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1);
|
||||
await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip");
|
||||
await page.mouse.up();
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
|
||||
await expect(resizeOutline).toHaveCount(0);
|
||||
@@ -759,15 +763,26 @@ describe("Board grid", () => {
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
|
||||
|
||||
const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]');
|
||||
await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale);
|
||||
const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height;
|
||||
const southResizeBox = await expectBoundingBoxAsync(southResizeHandle);
|
||||
const southResizeStart = {
|
||||
x: southResizeBox.x + southResizeBox.width / 2,
|
||||
y: southResizeBox.y + southResizeBox.height / 2,
|
||||
};
|
||||
await page.mouse.move(southResizeStart.x, southResizeStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 });
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "3");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
|
||||
await expect
|
||||
.poll(async () => (await expectBoundingBoxAsync(mainGrid)).height)
|
||||
.toBeGreaterThanOrEqual(gridHeightBeforeSouthResize);
|
||||
await page.mouse.up();
|
||||
expect(
|
||||
gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)),
|
||||
).toBe(false);
|
||||
|
||||
// Auto-scroll needs real document overflow. Grow the canvas through an
|
||||
// explicit resize instead of relying on edit mode to add empty rows.
|
||||
// Grow the canvas further to exercise sustained document auto-scroll.
|
||||
await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6);
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "9");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "9");
|
||||
@@ -843,14 +858,10 @@ describe("Board grid", () => {
|
||||
false,
|
||||
);
|
||||
|
||||
const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" }));
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const y = (await expectBoundingBoxAsync(rail)).y;
|
||||
return y >= 0 && y <= stickyRailY + 0.5;
|
||||
})
|
||||
.toBe(true);
|
||||
const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" }));
|
||||
await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeCloseTo(stickyRailY, 0);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
|
||||
|
||||
await setDocumentZoomAsync(page, 1.25);
|
||||
@@ -979,6 +990,15 @@ describe("Board grid", () => {
|
||||
const emptyRightGutter = page.getByRole("complementary", { name: "Right dashboard rail" });
|
||||
await expect(emptyRightGutter).toHaveCount(1);
|
||||
expect((await expectBoundingBoxAsync(emptyRightGutter)).width).toBeGreaterThan(0);
|
||||
await page.getByTestId("board-edit-mode-toggle").click();
|
||||
await expect(emptyRightGutter).toHaveAttribute("data-board-editing", "true");
|
||||
expect(
|
||||
await emptyRightGutter.evaluate((element) => ({
|
||||
content: getComputedStyle(element, "::before").content.replaceAll('"', ""),
|
||||
insetInlineStart: getComputedStyle(element, "::before").insetInlineStart,
|
||||
top: getComputedStyle(element, "::before").top,
|
||||
})),
|
||||
).toEqual({ content: "Right dashboard rail", insetInlineStart: "0px", top: "0px" });
|
||||
expect(pageErrors).toEqual([]);
|
||||
} finally {
|
||||
runtimeResources?.stop();
|
||||
@@ -1713,9 +1733,10 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
|
||||
};
|
||||
|
||||
const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => {
|
||||
const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => {
|
||||
const snapshot = await readEditorGridSnapshotAsync(grid);
|
||||
const gridBox = await expectBoundingBoxAsync(grid);
|
||||
const canvasBox = await expectBoundingBoxAsync(canvas);
|
||||
const itemBox = await expectBoundingBoxAsync(locator);
|
||||
const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 };
|
||||
|
||||
@@ -1723,14 +1744,25 @@ const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator,
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 8, start.y);
|
||||
await expect(locator).toHaveAttribute("data-dnd-drag-source", "true");
|
||||
await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 });
|
||||
await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0);
|
||||
const activeGridBox = await expectBoundingBoxAsync(grid);
|
||||
expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1);
|
||||
await expect.poll(async () => (await expectBoundingBoxAsync(grid)).height).toBeGreaterThan(gridBox.height);
|
||||
|
||||
await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, {
|
||||
steps: 12,
|
||||
});
|
||||
await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1);
|
||||
const activeGridBox = await expectBoundingBoxAsync(grid);
|
||||
expect(activeGridBox.height).toBeGreaterThan(gridBox.height);
|
||||
|
||||
await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 });
|
||||
const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]"));
|
||||
expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.mouse.up();
|
||||
await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting");
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
|
||||
await locator.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" }));
|
||||
await expectGridEntryGeometrySettledAsync(locator);
|
||||
};
|
||||
|
||||
const expectInvalidNestedDropRestoresSnapshotAsync = async (
|
||||
@@ -2036,18 +2068,6 @@ const expectUniformVisualScaleAsync = async (locator: Locator) => {
|
||||
expect(scale.horizontal).toBeCloseTo(scale.vertical, 3);
|
||||
};
|
||||
|
||||
const expectHorizontalOverflowAsync = async (locator: Locator) => {
|
||||
const dimensions = await locator.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
return {
|
||||
clientWidth: htmlElement.clientWidth,
|
||||
scrollWidth: htmlElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth);
|
||||
};
|
||||
|
||||
const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => {
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -71,17 +71,16 @@ describe("lean Custom Widget options", () => {
|
||||
});
|
||||
|
||||
it("replaces stale values with current defaults", () => {
|
||||
const options = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Updated options",
|
||||
sources: {},
|
||||
requests: {},
|
||||
options: {
|
||||
limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 },
|
||||
label: { label: "Label", control: "text", default: "Default" },
|
||||
},
|
||||
template: "<Text>{options.limit}</Text>",
|
||||
}).options;
|
||||
const options = {
|
||||
limit: customWidgetOptionSchema.parse({
|
||||
label: "Limit",
|
||||
control: "slider",
|
||||
default: 5,
|
||||
min: 1,
|
||||
max: 10,
|
||||
}),
|
||||
label: customWidgetOptionSchema.parse({ label: "Label", control: "text", default: "Default" }),
|
||||
};
|
||||
|
||||
const normalized = normalizeCustomWidgetOptions(options, {
|
||||
limit: "5",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { integrationDefs } from "../integration";
|
||||
|
||||
describe("Icon url's of integrations should be valid and return 200", () => {
|
||||
objectEntries(integrationDefs).forEach(([integration, { iconUrl }]) => {
|
||||
it.concurrent(`should return 200 for ${integration}`, async () => {
|
||||
it(`should return 200 for ${integration}`, { concurrent: true, retry: 2, timeout: 15_000 }, async () => {
|
||||
const res = await fetch(iconUrl);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -88,8 +88,16 @@ describe("section behavior validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["category", "dynamic"])("rejects the legacy %s kind", (kind) => {
|
||||
expect(() => sectionSchema.parse({ id: "legacy", kind, layouts: [] })).toThrow();
|
||||
test("normalizes the legacy dynamic kind to a container", () => {
|
||||
expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({
|
||||
id: "legacy",
|
||||
kind: "container",
|
||||
layouts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects the legacy category kind", () => {
|
||||
expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
@@ -93,4 +93,8 @@ const containerSectionSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema]);
|
||||
const legacyDynamicSectionSchema = containerSectionSchema
|
||||
.extend({ kind: z.literal("dynamic") })
|
||||
.transform((section) => ({ ...section, kind: "container" as const }));
|
||||
|
||||
export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema, legacyDynamicSectionSchema]);
|
||||
@@ -10,7 +10,6 @@ const advancedFocusWidgetKinds = [
|
||||
"bookmarks",
|
||||
"calendar",
|
||||
"clock",
|
||||
"dockerContainers",
|
||||
"downloads",
|
||||
"firewall",
|
||||
"healthMonitoring",
|
||||
|
||||
@@ -25,7 +25,6 @@ const columnTranslationKeyMap = {
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||
icon: IconBrandDocker,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: 30,
|
||||
createOptions() {
|
||||
|
||||
Reference in new issue
Block a user