refactor(board): modernize item and application select modals with SelectableCard (#6642)

Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence.
This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 03:19:18 +02:00
1 parent bd4f6a92c6
commit f3014fe271
60 files changed
+3791 -1839

No files matched your search

@@ -17,6 +17,7 @@ import {
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
import type { GridTransaction } from "./dnd";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
@@ -48,6 +49,16 @@ export const FixedGridItem = ({
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const { isSelected, toggleSelectItem } = useBoardSelection();
const selected = item.type === "item" && isSelected(item.id);
const handleClickCapture = (event: React.MouseEvent) => {
if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
event.preventDefault();
event.stopPropagation();
toggleSelectItem(item.id);
};
const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready";
const placement = toPlacement(item);
@@ -178,6 +189,8 @@ export const FixedGridItem = ({
return (
<Box
{...semantics}
onClickCapture={handleClickCapture}
data-board-item-selected={selected ? "true" : undefined}
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
data-grid-item-id={isEditorActive ? undefined : item.id}
@@ -187,6 +200,7 @@ export const FixedGridItem = ({
data-kind={item.kind}
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
aria-describedby={isEditorActive ? instructionsId : undefined}
aria-selected={isEditorActive && item.type === "item" ? selected : undefined}
aria-keyshortcuts={
isEditorActive
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
@@ -32,6 +32,27 @@
opacity: 0.42;
}
.board-grid-entry[data-board-item-selected="true"] {
z-index: 15;
opacity: 0.56;
filter: saturate(0.72) brightness(0.82);
transition:
opacity 140ms ease,
filter 140ms ease;
}
.board-grid-entry[data-board-item-selected="true"]::after {
position: absolute;
inset: 0;
z-index: 100;
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
border: 0.125rem dashed var(--mantine-primary-color-filled);
border-radius: var(--board-item-radius, var(--mantine-radius-md));
box-shadow: inset 0 0 1.125rem color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
pointer-events: none;
}
.board-grid-drag-overlay[data-dnd-dragging] {
position: fixed !important;
z-index: 10000;
@@ -177,138 +198,115 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-handle {
position: absolute;
z-index: 20;
display: block;
z-index: 25;
display: grid;
padding: 0;
color: var(--mantine-color-dimmed);
appearance: none;
background: transparent;
border: 0;
background: var(--mantine-color-body);
border: 0.0625rem solid var(--mantine-color-default-border);
box-shadow: var(--mantine-shadow-xs);
opacity: 0;
scale: 0.88;
place-items: center;
touch-action: none;
transition:
opacity 120ms ease,
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 120ms ease;
}
body[data-board-grid-interacting] .board-grid-resize-handle {
.board-grid-resize-handle::before {
position: absolute;
inset: -0.375rem;
content: "";
}
.board-grid-resize-handle > svg {
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle::after {
opacity: 0;
}
.board-grid-resize-handle::after {
position: absolute;
inset: 50% auto auto 50%;
width: 12px;
height: 3px;
content: "";
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
border-radius: var(--mantine-radius-xl);
opacity: 0;
transform: translate(-50%, -50%);
transition: opacity 100ms ease;
}
.board-grid-entry:hover > .board-grid-resize-handle::after,
.board-grid-resize-handle:focus-visible::after {
.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
border-color: var(--mantine-color-dimmed);
opacity: 1;
}
.board-grid-resize-handle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -8px;
scale: 1;
}
.board-grid-resize-handle[data-grid-resize-handle="n"],
.board-grid-resize-handle[data-grid-resize-handle="s"] {
right: 44px;
left: 44px;
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
left: 50%;
width: 1.75rem;
height: 1rem;
border-radius: var(--mantine-radius-xl);
translate: -50% 0;
cursor: ns-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
top: 0;
top: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: 0;
bottom: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
.board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 44px;
bottom: 44px;
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
top: 50%;
width: 1rem;
height: 1.75rem;
border-radius: var(--mantine-radius-xl);
translate: 0 -50%;
cursor: ew-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
right: 0;
right: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
left: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
width: 3px;
height: 12px;
left: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"],
.board-grid-resize-handle[data-grid-resize-handle="se"],
.board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
z-index: 9;
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
width: 1.875rem;
height: 1.875rem;
border-radius: var(--board-item-radius, var(--mantine-radius-md));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: 0;
right: 0;
top: -0.625rem;
right: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
right: 0;
bottom: 0;
right: -0.625rem;
bottom: -0.625rem;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: 0;
left: 0;
bottom: -0.625rem;
left: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: 0;
left: 0;
top: -0.625rem;
left: -0.625rem;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
transform: translate(-50%, -50%) rotate(45deg);
}
@media (hover: none), (pointer: coarse) {
.board-grid-resize-handle::after {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.board-grid-entry,
.board-grid-placeholder,
.board-grid-resize-outline,
.board-grid-resize-handle::after {
.board-grid-resize-handle {
transition: none;
}
}
@@ -32,6 +32,7 @@ import type {
DragStartEvent,
} from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
@@ -61,6 +62,7 @@ import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -846,6 +848,9 @@ const DndGridEntryComponent = ({
}: DndGridEntryProps) => {
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const { isSelected } = useBoardSelection();
const board = useRequiredBoard();
const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
@@ -921,7 +926,12 @@ const DndGridEntryComponent = ({
<div
ref={setShellRef}
className="board-grid-entry"
style={getLogicalItemStyle(placement) as CSSProperties}
style={
{
...getLogicalItemStyle(placement),
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
@@ -930,6 +940,7 @@ const DndGridEntryComponent = ({
data-grid-h={placement.h}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
data-board-item-selected={isSelectedEntry ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
<GridResizeHandles
@@ -1102,19 +1113,27 @@ const GridResizeHandles = ({
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
};
return RESIZE_DIRECTIONS.map((direction) => (
<span
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
/>
));
return RESIZE_DIRECTIONS.map((direction) => {
const isHorizontalSide = direction === "n" || direction === "s";
const isVerticalSide = direction === "e" || direction === "w";
const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
return (
<span
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
>
<GripIcon size={isHorizontalSide || isVerticalSide ? 13 : 15} stroke={2.25} />
</span>
);
});
};
const getProjectedDragShape = (
@@ -1239,6 +1258,7 @@ const getPreferredRegisteredGridTarget = (
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
if (event.metaKey || event.ctrlKey) return true;
const target = event.target;
const activationElement = source.handle ?? source.element;
if (!(target instanceof Element) || target === activationElement) return false;
@@ -10,6 +10,8 @@ import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({
announce: vi.fn(),
commitSectionGrid: vi.fn(),
isSelected: vi.fn(() => false),
toggleSelectItem: vi.fn(),
editMode: true,
maxRowCount: null as number | null,
items: [
@@ -44,6 +46,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [mocks.editMode],
}));
vi.mock("~/components/board/selection/board-selection-context", () => ({
useBoardSelection: () => ({
isSelected: mocks.isSelected,
toggleSelectItem: mocks.toggleSelectItem,
}),
}));
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "layout",
}));