fix: clarify board grid editing affordances

This commit is contained in:
ajnart committed 2026-08-13 19:52:47 +02:00
1 parent bf91f92d56
commit 94074c368d
9 files changed
+214 -20

No files matched your search

@@ -13,4 +13,11 @@
.editModeInertContent {
display: contents;
pointer-events: none;
user-select: none;
}
.editModeInertContent * {
pointer-events: none !important;
user-select: none !important;
}
@@ -137,6 +137,7 @@ export const FixedGridItem = ({
onBlur={isEditMode ? handleBlur : undefined}
onKeyDown={isEditMode ? handleKeyboard : undefined}
>
{isEditMode && <span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />}
{children}
{isEditMode && (
<span id={instructionsId} className={classes.liveRegion}>
@@ -10,6 +10,7 @@ export const getDropSwapPlacements = (
initial: readonly SectionGridPlacement[],
current: readonly SectionGridPlacement[],
movedItemId: string,
bounds: { columnCount: number; rowCount?: number },
): SectionGridPlacement[] | null => {
const movedInitially = initial.find((placement) => placement.id === movedItemId);
const movedCurrently = current.find((placement) => placement.id === movedItemId);
@@ -24,16 +25,11 @@ export const getDropSwapPlacements = (
}
const displaced = initial.find(
(placement) =>
placement.id !== movedItemId &&
placement.x === movedCurrently.x &&
placement.y === movedCurrently.y &&
placement.w === movedCurrently.w &&
placement.h === movedCurrently.h,
(placement) => placement.id !== movedItemId && placement.x === movedCurrently.x && placement.y === movedCurrently.y,
);
if (!displaced) return null;
return initial.map((placement) => {
const swapped = initial.map((placement) => {
if (placement.id === movedItemId) {
return {
...placement,
@@ -50,4 +46,27 @@ export const getDropSwapPlacements = (
}
return placement;
});
return placementsFit(swapped, bounds) ? swapped : null;
};
const placementsFit = (
placements: readonly SectionGridPlacement[],
{ columnCount, rowCount }: { columnCount: number; rowCount?: number },
) =>
placements.every(
(placement) =>
placement.x >= 0 &&
placement.y >= 0 &&
placement.x + placement.w <= columnCount &&
(rowCount === undefined || placement.y + placement.h <= rowCount),
) &&
placements.every((placement, index) =>
placements.slice(index + 1).every((other) => !placementsOverlap(placement, other)),
);
const placementsOverlap = (first: SectionGridPlacement, second: SectionGridPlacement) =>
first.x < second.x + second.w &&
first.x + first.w > second.x &&
first.y < second.y + second.h &&
first.y + first.h > second.y;
@@ -8,6 +8,26 @@
filter: drop-shadow(var(--mantine-shadow-lg));
}
.grid-stack.grid-stack-editing > .grid-stack-item > .grid-stack-item-content {
border-radius: var(--mantine-radius-md);
outline: 1px solid transparent;
outline-offset: 2px;
transition:
outline-color 160ms ease,
background-color 160ms ease;
}
.grid-stack.grid-stack-editing > .grid-stack-item:hover > .grid-stack-item-content,
.grid-stack.grid-stack-editing > .grid-stack-item:focus-within > .grid-stack-item-content {
outline-color: color-mix(in srgb, var(--mantine-primary-color-filled) 72%, transparent);
}
.grid-stack.grid-stack-editing > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content,
.grid-stack.grid-stack-editing > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-handle {
z-index: 8;
}
@@ -25,13 +45,86 @@
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
width: 44px;
height: 44px;
background-size: 16px 16px;
color: var(--mantine-color-dimmed);
background-image: none;
border-radius: var(--mantine-radius-sm);
transform: none;
z-index: 9;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
position: absolute;
bottom: 8px;
width: 14px;
height: 14px;
content: "";
border-bottom: 2px solid currentColor;
opacity: 0.9;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se::after {
right: 8px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw::after {
left: 8px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se:hover,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw:hover {
color: var(--mantine-primary-color-filled);
background-color: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
position: absolute;
content: "";
background-color: var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-xl);
opacity: 0;
transition: opacity 160ms ease;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
top: 25%;
bottom: 25%;
width: 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e::after {
right: 5px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w::after {
left: 5px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s::after {
right: 25%;
bottom: 5px;
left: 25%;
height: 3px;
}
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-e:hover::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-w:hover::after,
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-s:hover::after {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.grid-stack.grid-stack-editing,
.grid-stack.grid-stack-editing .grid-stack-item {
.grid-stack.grid-stack-editing .grid-stack-item,
.grid-stack.grid-stack-editing .grid-stack-item-content,
.grid-stack.grid-stack-editing .ui-resizable-handle::after {
transition: none !important;
}
}
@@ -220,6 +220,10 @@ export default function GridEditor({
dragSnapshot.placements,
getGridPlacements(changedGrid),
dragSnapshot.itemId,
{
columnCount: dimensionsRef.current.columnCount,
rowCount: dimensionsRef.current.fixedRowCount ? dimensionsRef.current.rowCount : undefined,
},
);
if (swapped) {
applyingControlledLayoutRef.current = true;
@@ -28,10 +28,12 @@
}
.editorEntry {
position: relative;
width: 100%;
height: 100%;
overflow: visible;
cursor: grab;
user-select: none;
}
.editorEntry:active {
@@ -45,6 +47,46 @@
border-radius: var(--mantine-radius-md);
}
.dragAffordance {
position: absolute;
top: 6px;
left: 50%;
z-index: 7;
display: grid;
width: 28px;
height: 18px;
color: var(--mantine-color-dimmed);
pointer-events: none;
place-items: center;
background-color: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-xl);
opacity: 0.8;
transform: translateX(-50%);
transition:
color 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
opacity 160ms ease;
}
.dragAffordance::before {
width: 12px;
height: 8px;
content: "";
background-image: radial-gradient(circle, currentColor 1.25px, transparent 1.5px);
background-size: 6px 4px;
}
.editorEntry:hover .dragAffordance,
.editorEntry:focus-visible .dragAffordance,
.editorEntry[data-keyboard-editing="true"] .dragAffordance {
color: var(--mantine-primary-color-filled);
background-color: var(--mantine-color-body);
border-color: var(--mantine-primary-color-filled);
opacity: 1;
}
.editorLoading {
position: absolute;
inset: 0;
@@ -71,7 +113,8 @@
}
@media (prefers-reduced-motion: reduce) {
.staticItem {
.staticItem,
.dragAffordance {
transition: none;
}
}
@@ -156,7 +156,7 @@ describe("fixed grid item behavior", () => {
);
});
test("uses the whole tile as the focusable move surface without a grab button", () => {
test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
getWeatherMock().advancedOptions.title = " Balcony forecast ";
renderWeather(root);
@@ -165,7 +165,7 @@ describe("fixed grid item behavior", () => {
expect(entry.getAttribute("aria-label")).toBe("Balcony forecast, column 1, row 1");
expect(entry.tabIndex).toBe(0);
expect(entry.getAttribute("aria-describedby")).not.toBeNull();
expect(container.querySelector('[data-testid="board-grid-drag-handle"]')).toBeNull();
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).not.toBeNull();
});
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
@@ -7,23 +7,43 @@ const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w:
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
const initial = [first, second, third];
const bounds = { columnCount: 4 };
describe("grid drop swapping", () => {
test("swaps equally-sized items when one takes the other's exact slot", () => {
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
expect(getDropSwapPlacements(initial, current, "first")).toEqual([{ ...first, x: 1 }, { ...second, x: 0 }, third]);
expect(getDropSwapPlacements(initial, current, "first", bounds)).toEqual([
{ ...first, x: 1 },
{ ...second, x: 0 },
third,
]);
});
test("keeps push behavior for different sizes or partial placement", () => {
test("swaps different-sized items when both destinations remain valid", () => {
const wide: SectionGridPlacement = { id: "wide", type: "item", x: 0, y: 0, w: 2, h: 1 };
const compact: SectionGridPlacement = { id: "compact", type: "item", x: 2, y: 0, w: 1, h: 1 };
const current = [
{ ...wide, x: 2 },
{ ...compact, y: 1 },
];
expect(getDropSwapPlacements([wide, compact], current, "wide", bounds)).toEqual([
{ ...wide, x: 2 },
{ ...compact, x: 0 },
]);
});
test("keeps push behavior when a swap would overlap another item or exceed the section", () => {
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
expect(getDropSwapPlacements(initial, differentSize, "first")).toBeNull();
expect(getDropSwapPlacements(initial, partialPlacement, "third")).toBeNull();
expect(getDropSwapPlacements(initial, differentSize, "first", bounds)).toBeNull();
expect(getDropSwapPlacements(initial, partialPlacement, "third", bounds)).toBeNull();
expect(getDropSwapPlacements(initial, [{ ...third, x: 1 }, first, second], "third", { columnCount: 2 })).toBeNull();
});
test("does nothing when the item did not move", () => {
expect(getDropSwapPlacements(initial, initial, "first")).toBeNull();
expect(getDropSwapPlacements(initial, initial, "first", bounds)).toBeNull();
});
});
+10 -3
View File
@@ -207,9 +207,16 @@ describe("Board grid", () => {
await expect(mainSection).toHaveAttribute("data-grid-editable", "true");
await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group");
await expectTargetSizeAsync(getEditorEntry(firstItem));
await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'));
await expectLogicalTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'), 44);
await expect(firstItem.locator("[data-board-grid-inert-content]")).toHaveAttribute("inert", "");
const dragAffordance = firstItem.getByTestId("board-grid-drag-affordance");
const diagonalResizeHandle = firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]');
const inertContent = firstItem.locator("[data-board-grid-inert-content]");
await expect(dragAffordance).toBeVisible();
await expect(dragAffordance).toHaveCSS("pointer-events", "none");
await expectTargetSizeAsync(diagonalResizeHandle);
await expectLogicalTargetSizeAsync(diagonalResizeHandle, 44);
await expect(diagonalResizeHandle).toHaveCSS("transform", "none");
await expect(inertContent).toHaveAttribute("inert", "");
await expect(inertContent).toHaveCSS("pointer-events", "none");
await page.getByRole("button", { name: "Add board content" }).click();
await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible();