234 lines
8.4 KiB
TypeScript
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);
|
|
});
|