Files
kandev/apps/web/components/confirmation/mobile-action-confirmation.tsx
T
2026-09-12 14:58:49 +02:00

164 lines
5.3 KiB
TypeScript

"use client";
import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@kandev/ui/drawer";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import {
MobileConfirmationContent,
type MobileConfirmationContentProps,
} from "./mobile-confirmation-content";
import { useMobileConfirmationHost } from "./mobile-confirmation-host";
import {
useMobileConfirmationSubmit,
type ConfirmationCompletionPolicy,
} from "./use-mobile-confirmation-submit";
export type MobileActionConfirmationProps = Omit<
MobileConfirmationContentProps,
"onConfirm" | "onCancel"
> & {
open: boolean;
targetKey: string;
onOpenChange: (open: boolean) => void;
onCancel?: () => void;
/** Local owner dismissal before dispatch, when it differs from cancellation. */
onClose?: () => void;
onConfirm: () => void | Promise<void>;
completionPolicy?: ConfirmationCompletionPolicy;
focusReturnRef?: RefObject<HTMLElement | null>;
fallback?: ReactNode;
};
/** Keep this adapter mounted across responsive branches so a boundary change cancels the request. */
export function MobileActionConfirmation(props: MobileActionConfirmationProps) {
const { isMobile, changed } = useConfirmationBoundary(
props.open,
props.targetKey,
props.onOpenChange,
);
if (changed || !props.open) return null;
if (!isMobile) return props.fallback ?? null;
return <OpenMobileConfirmation {...props} />;
}
export function useConfirmationBoundary(
open: boolean,
targetKey: string,
onOpenChange: (open: boolean) => void,
) {
const viewport = useResponsiveBreakpoint();
const { isMobile } = viewport;
const previous = useRef({ isMobile, targetKey, open });
const changed =
previous.current.open &&
open &&
(previous.current.isMobile !== isMobile || previous.current.targetKey !== targetKey);
useLayoutEffect(() => {
previous.current = { isMobile, targetKey, open };
if (changed && open) onOpenChange(false);
});
return { ...viewport, changed };
}
function OpenMobileConfirmation({
onOpenChange,
onCancel,
onClose,
onConfirm,
completionPolicy = "close-before-dispatch",
focusReturnRef,
fallback: _fallback,
open: _open,
targetKey: _targetKey,
...content
}: MobileActionConfirmationProps) {
const host = useMobileConfirmationHost();
const id = useId();
const token = useRef(Symbol());
const [closed, setClosed] = useState(false);
const { submit, submitted, pending } = useMobileConfirmationSubmit({
closed,
disabled: !!(content.disabled || content.confirmDisabled),
onConfirm,
completionPolicy,
close: () => {
setClosed(true);
if (onClose) onClose();
else onOpenChange(false);
},
});
const initialFocus = useRef(document.activeElement as HTMLElement | null);
const latest = useRef({ onOpenChange, onCancel, disabled: content.disabled });
useLayoutEffect(() => {
latest.current = { onOpenChange, onCancel, disabled: content.disabled };
});
const titleId = `${id}-title`;
const descriptionId = `${id}-description`;
const cancel = useRef((invalidated = false) => {
if (!invalidated && (submitted.current || latest.current.disabled)) return;
setClosed(true);
latest.current.onCancel?.();
latest.current.onOpenChange(false);
queueMicrotask(() => {
const target = focusReturnRef?.current ?? initialFocus.current;
if (host) host.restoreFocus(target);
else if (target?.isConnected && !target.closest("[inert]"))
target.focus({ preventScroll: true });
});
}).current;
const register = host?.register;
const release = host?.release;
useLayoutEffect(() => {
if (!register || !release || closed) return;
const requestToken = token.current;
register({ token: requestToken, titleId, descriptionId, cancel });
return () => release(requestToken);
}, [register, release, closed, titleId, descriptionId, cancel]);
const body = closed ? null : (
<MobileConfirmationContent
{...content}
disabled={content.disabled || pending}
titleId={titleId}
descriptionId={descriptionId}
onBack={host ? () => cancel() : undefined}
onCancel={() => cancel()}
onConfirm={submit}
/>
);
if (host)
return host.outlet && host.request?.token === token.current
? createPortal(body, host.outlet)
: null;
return (
<Drawer
open={!closed}
onOpenChange={(next) => {
if (!next) cancel();
}}
>
<DrawerContent
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="data-[vaul-drawer-direction=bottom]:max-h-[calc(100dvh-1rem)] data-[vaul-drawer-direction=bottom]:mt-0 overflow-hidden"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => {
event.preventDefault();
cancel();
}}
>
<DrawerTitle asChild>
<span hidden>{content.title}</span>
</DrawerTitle>
<DrawerDescription asChild>
<span hidden>{content.subject ?? content.title}</span>
</DrawerDescription>
{body}
</DrawerContent>
</Drawer>
);
}