metis-desk/packages/rdx-ui/src/components/dialog-help.tsx
David ddebfadc1c feat: add contextual help components and dialog support
- Introduced `ContextHelpContent` for displaying contextual help with title, description, examples, and guidance.
- Created `DialogHelp` component to manage help dialogs, including popover and panel presentations.
- Implemented `useDialogHelpLayout` hook for handling dialog layout and state management.
- Added context for dialog help management with `DialogHelpContext` and `DialogHelpLabelsContext`.
- Developed utility functions for determining dialog help presentation modes.
- Included tests for dialog help functionality and layout behavior.
- Created a Python script for auditing contextual help in real forms.
2026-10-25 17:47:54 +01:00

127 lines
4.0 KiB
TypeScript

import { Button } from "@repo/shadcn-ui/components/button";
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "@repo/shadcn-ui/components/popover";
import type { ReactNode } from "react";
import { useContext, useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import { DialogHelpContext, DialogHelpLabelsContext } from "../lib/dialog-help-context";
import type { DialogHelpPresentation } from "../lib/dialog-help-presentation";
import { CircleHelpIcon } from "../lib/icons";
/** Slot declarativo: los portales conservan contexto, traducciones y una única copia del contenido. */
export function DialogHelp({
title,
children,
presentation = "auto",
}: {
title: string;
children: ReactNode;
presentation?: DialogHelpPresentation;
}) {
const dialog = useContext(DialogHelpContext);
const labels = useContext(DialogHelpLabelsContext);
const contentId = useId();
const titleId = useId();
const headingRef = useRef<HTMLHeadingElement>(null);
const wasOpen = useRef(false);
const triggerRef = dialog?.triggerRef;
const open = dialog?.open ?? false;
const mode = dialog?.mode;
// El destino cambia al redimensionar. El foco debe salir del formulario antes de ocultarlo.
useEffect(() => {
if (open && mode !== "popover") {
headingRef.current?.focus();
} else if (!open && wasOpen.current) {
triggerRef?.current?.focus();
}
wasOpen.current = open;
}, [open, mode, triggerRef]);
if (!(dialog?.actionsHost && dialog.contentHost)) {
return null;
}
const close = () => {
dialog.setPresentation(null);
dialog.triggerRef.current?.focus();
};
const trigger = (
<Button
aria-controls={open ? contentId : undefined}
aria-expanded={open}
aria-label={labels.help}
onClick={() => (open ? close() : dialog.setPresentation(presentation))}
ref={dialog.triggerRef}
size="icon-sm"
type="button"
variant="ghost"
>
<CircleHelpIcon aria-hidden="true" />
</Button>
);
return (
<>
{createPortal(
mode === "popover" || (!open && presentation === "compact") ? (
<Popover
onOpenChange={(next) => dialog.setPresentation(next ? presentation : null)}
open={open && mode === "popover"}
>
<PopoverTrigger render={trigger} />
<PopoverContent
align="end"
className="max-h-[min(60dvh,var(--available-height))] max-w-[calc(100vw-2rem)] overflow-y-auto break-words motion-reduce:animate-none! motion-reduce:transition-none!"
finalFocus={dialog.triggerRef}
id={contentId}
initialFocus
>
<PopoverTitle className="text-sm font-semibold">{title}</PopoverTitle>
{children}
<Button onClick={close} size="sm" type="button" variant="outline">
{labels.closeHelp}
</Button>
</PopoverContent>
</Popover>
) : (
trigger
),
dialog.actionsHost
)}
{createPortal(
open && mode !== "popover" ? (
<section
aria-labelledby={titleId}
className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto overscroll-contain break-words p-5"
data-slot="dialog-help"
id={contentId}
>
<h2
className="text-base font-semibold focus-visible:outline-ring"
id={titleId}
ref={headingRef}
tabIndex={-1}
>
{title}
</h2>
<div className="min-h-[3lh] flex-1 overflow-y-auto overscroll-contain px-1">
{children}
</div>
<Button className="shrink-0 self-start" onClick={close} type="button" variant="outline">
{mode === "view" ? labels.backToForm : labels.closeHelp}
</Button>
</section>
) : null,
dialog.contentHost
)}
</>
);
}