- 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.
160 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|