metis-desk/packages/rdx-ui/src/hooks/use-contextual-detail.ts
David f9384c9376 feat: Introduce contextual detail layout and budget management for assets and resources
- Added `INVENTORY_DETAIL_BUDGET` and `RESOURCE_DETAIL_BUDGET` to define layout parameters for inventory and resource detail views.
- Implemented `useContextualDetail` hook to manage visibility and focus transitions between inline and overlay modes.
- Created `contextual-detail-layout` module to handle budget calculations and state management for contextual details.
- Updated documentation to reflect changes in asset and resource handling, ensuring consistent UI behavior across different viewports.
- Conducted regression tests to validate functionality and ensure no existing features were broken during the implementation.
2026-10-22 01:24:37 +02:00

160 lines
4.8 KiB
TypeScript

import { useLayoutEffect, useReducer, useRef } from "react";
import type { ContextualDetailBudget } from "../lib/contextual-detail-layout";
import { contextualDetailMode, contextualDetailReducer } from "../lib/contextual-detail-layout";
/** Mide la caja exterior estable; selección y datos siguen perteneciendo al consumidor. */
export function useContextualDetail({
budget,
initialVisible = false,
}: {
budget: ContextualDetailBudget;
initialVisible?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const reopenRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const restorePanelFocusRef = useRef(false);
const transferDialogFocusRef = useRef(false);
const restoreFrameRef = useRef<number | undefined>(undefined);
const [state, dispatch] = useReducer(contextualDetailReducer, {
mode: "overlay",
visible: initialVisible,
});
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) {
return;
}
let mode = root.dataset.detailMode === "inline" ? "inline" : "overlay";
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const next = contextualDetailMode(entry.contentRect.width, budget);
if (next === mode) {
return;
}
if (
mode === "inline" &&
root.querySelector(":scope > [data-slot=selection-panel]")?.contains(document.activeElement)
) {
restorePanelFocusRef.current = true;
}
if (mode === "overlay" && root.dataset.detailOpen === "true") {
transferDialogFocusRef.current = true;
}
mode = next;
dispatch({ type: "capacity", mode: next });
});
observer.observe(root);
return () => {
observer.disconnect();
if (restoreFrameRef.current !== undefined) {
cancelAnimationFrame(restoreFrameRef.current);
restoreFrameRef.current = undefined;
}
};
}, [budget]);
useLayoutEffect(() => {
if (!state.visible && restorePanelFocusRef.current) {
restorePanelFocusRef.current = false;
(reopenRef.current ?? containerRef.current)?.focus({ preventScroll: true });
}
if (state.mode === "inline" && state.visible && transferDialogFocusRef.current) {
transferDialogFocusRef.current = false;
// Tras la liberación de modalidad y guardas de Base UI, enfocar el control equivalente.
const frame = requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLButtonElement>("[data-slot=selection-panel] header button")
?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}
}, [state]);
function setVisible(visible: boolean) {
if (visible) {
captureTrigger();
} else if (
containerRef.current
?.querySelector(":scope > [data-slot=selection-panel]")
?.contains(document.activeElement)
) {
restorePanelFocusRef.current = true;
}
dispatch({ type: "visibility", visible });
if (!visible && state.mode === "overlay") {
// finalFocus puede consultar el disparador antes de que vuelva a ser visible en el commit.
restoreFocus();
}
}
function captureTrigger() {
const active = document.activeElement;
triggerRef.current = active instanceof HTMLElement && active !== document.body ? active : null;
}
function triggerFinalFocus() {
const root = containerRef.current;
const trigger = triggerRef.current;
return (
(trigger?.isConnected &&
trigger.getClientRects().length > 0 &&
!trigger.matches(":disabled, [aria-disabled=true]")
? trigger
: null) ??
(reopenRef.current?.getClientRects().length ? reopenRef.current : null) ??
root?.querySelector<HTMLInputElement>("input[data-quick-search]:enabled") ??
root ??
true
);
}
function finalFocus() {
// El modal contextual que pasa a panel termina en su control de cierre equivalente.
return (
containerRef.current?.querySelector<HTMLButtonElement>(
"[data-slot=selection-panel] header button"
) ?? triggerFinalFocus()
);
}
function restoreFocus() {
if (restoreFrameRef.current !== undefined) {
cancelAnimationFrame(restoreFrameRef.current);
}
// Un diálogo explícito puede retirar y reponer el panel sin cambiar el modo de capacidad.
restoreFrameRef.current = requestAnimationFrame(() => {
restoreFrameRef.current = undefined;
const target = finalFocus();
if (target instanceof HTMLElement && target.isConnected) {
target.focus({ preventScroll: true });
}
});
}
return {
...state,
containerRef,
reopenRef,
setVisible,
captureTrigger,
finalFocus,
triggerFinalFocus,
restoreFocus,
};
}