- 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.
127 lines
4.0 KiB
TypeScript
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
|
|
)}
|
|
</>
|
|
);
|
|
}
|