Files
homarr/apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx
T

234 lines
8.4 KiB
TypeScript

"use client";
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
import { useId, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import {
getEditableGridCellAttributes,
getLogicalItemStyle,
getReadonlyGridItemAttributes,
} from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
import type { GridTransaction } from "./dnd";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
import classes from "./section-grid.module.css";
interface FixedGridItemProps {
item: ContainerSectionItem | SectionItem;
minWidth?: number;
minHeight?: number;
}
/**
* Static SSR geometry in view mode and the stable content/keyboard handle in
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
* handles pointer and touch input on the outer editor shell.
*/
export const FixedGridItem = ({
item,
minWidth = 1,
minHeight = 1,
children,
}: PropsWithChildren<FixedGridItemProps>) => {
const [isEditMode] = useEditMode();
const runtimeStatus = useGridEditorRuntimeStatus();
const currentLayoutId = useCurrentLayout();
const t = useI18n();
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready";
const placement = toPlacement(item);
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
const keyboardGridRef = useRef<{
source: readonly SectionGridPlacement[];
current: readonly SectionGridPlacement[];
}>({
source: renderedPlacements,
current: renderedPlacements,
});
const displayName =
item.type === "item"
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
: item.options.title.trim() || t("section.container.untitled");
const accessibleLabel = t("item.moveResize.entryLabel", {
name: displayName,
column: String(placement.x + 1),
row: String(placement.y + 1),
});
const semantics = isEditorActive
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
: getReadonlyGridItemAttributes(placement);
const setKeyboardEditing = (next: boolean) => {
setIsKeyboardEditing(next);
announce(
`${accessibleLabel}: ${t(next ? "item.moveResize.keyboard.started" : "item.moveResize.keyboard.stopped")}`,
);
};
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget || !isEditorActive || event.altKey || event.ctrlKey || event.metaKey) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setKeyboardEditing(!isKeyboardEditing);
return;
}
if (event.key === "Escape" && isKeyboardEditing) {
event.preventDefault();
setKeyboardEditing(false);
return;
}
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
event.preventDefault();
if (document.body.hasAttribute("data-board-grid-interacting")) {
setKeyboardEditing(false);
return;
}
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
keyboardGridRef.current = {
source: renderedPlacements,
current: renderedPlacements,
};
}
const placements = keyboardGridRef.current.current;
const current = placements.find((candidate) => candidate.id === item.id);
if (!current) return;
let transaction: GridTransaction<SectionGridPlacement>;
try {
transaction = beginGridTransaction(
{
grids: [
{
id: section.id,
columnCount,
maxRowCount,
placements,
},
],
},
{ activeId: current.id, sourceGridId: section.id },
);
} catch {
keyboardGridRef.current = {
source: renderedPlacements,
current: renderedPlacements,
};
setKeyboardEditing(false);
return;
}
const delta = getKeyboardDelta(event.key);
const result = event.shiftKey
? previewGridResize(transaction, {
direction: "se",
deltaColumns: delta.x,
deltaRows: delta.y,
minWidth,
minHeight,
})
: previewGridMove(transaction, {
targetGridId: section.id,
x: current.x + delta.x,
y: current.y + delta.y,
});
const next = result.transaction.preview.grids[0]?.placements.find((candidate) => candidate.id === item.id);
if (!result.accepted || !next || arePlacementsEqual(current, next)) {
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
return;
}
const committed = commitGridTransaction(result.transaction).grids[0];
if (!committed) return;
keyboardGridRef.current = {
...keyboardGridRef.current,
current: committed.placements,
};
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: committed.placements });
announce(
`${displayName}: ${t("item.moveResize.field.xOffset.label")} ${next.x + 1}, ${t(
"item.moveResize.field.yOffset.label",
)} ${next.y + 1}, ${t("item.moveResize.field.width.label")} ${next.w}, ${t(
"item.moveResize.field.height.label",
)} ${next.h}`,
);
};
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (isKeyboardEditing && event.target === event.currentTarget) {
setKeyboardEditing(false);
}
};
return (
<Box
{...semantics}
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}
data-grid-item-type={isEditorActive ? undefined : item.type}
data-editor-grid-entry={isEditorActive ? "true" : undefined}
data-type={item.type}
data-kind={item.kind}
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
aria-describedby={isEditorActive ? instructionsId : undefined}
aria-keyshortcuts={
isEditorActive
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
: undefined
}
onBlur={isEditorActive ? handleBlur : undefined}
onKeyDown={isEditorActive ? handleKeyboard : undefined}
>
{children}
{isEditorActive && (
<span id={instructionsId} className={classes.liveRegion}>
{t("item.moveResize.keyboard.instructions")}
</span>
)}
</Box>
);
};
const toPlacement = (item: ContainerSectionItem | SectionItem): SectionGridPlacement => ({
id: item.id,
type: item.type,
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
});
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
const getKeyboardDelta = (key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown") => ({
x: key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0,
y: key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0,
});
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
const arePlacementListsEqual = (first: readonly GridPlacement[], second: readonly GridPlacement[]) =>
first.length === second.length &&
first.every((placement, index) => {
const candidate = second[index];
return candidate?.id === placement.id && arePlacementsEqual(placement, candidate);
});