fix: clarify board grid editing affordances
This commit is contained in:
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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user