"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) => { 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) => { 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; 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) => { if (isKeyboardEditing && event.target === event.currentTarget) { setKeyboardEditing(false); } }; return ( {children} {isEditorActive && ( {t("item.moveResize.keyboard.instructions")} )} ); }; 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); });