fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to Sheet side=right so every detail-preview surface uses the same primitive. Document the doctrine: Sheet for side panels on both desktop and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX (currently just MoreSheet). Closes ui/ux M11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
src/hooks/use-confirmation.tsx
Normal file
117
src/hooks/use-confirmation.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Confirm button label. Default: "Delete". */
|
||||
confirmLabel?: string;
|
||||
/** Cancel button label. Default: "Cancel". */
|
||||
cancelLabel?: string;
|
||||
/** When true, confirm button is rendered in destructive red. Default: true. */
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic, awaitable confirmation dialog — the imperative counterpart
|
||||
* to `<ConfirmationDialog>` (which needs a trigger element).
|
||||
*
|
||||
* Replaces the native `window.confirm()` pattern in 17 destructive flows
|
||||
* the audit's UI-UX pass flagged. `confirm(...)` is synchronous and
|
||||
* browser-styled (escape-hatch UX); this hook is async, accessible
|
||||
* (alert-dialog semantics + focus trap), keyboard-navigable, and matches
|
||||
* the rest of the app's visual language.
|
||||
*
|
||||
* Usage:
|
||||
* const { confirm, dialog } = useConfirmation();
|
||||
*
|
||||
* async function handleDelete(file) {
|
||||
* const ok = await confirm({
|
||||
* title: 'Delete file',
|
||||
* description: `Delete "${file.filename}"? This cannot be undone.`,
|
||||
* confirmLabel: 'Delete',
|
||||
* });
|
||||
* if (!ok) return;
|
||||
* // ... actually delete
|
||||
* }
|
||||
*
|
||||
* return <>{children}{dialog}</>;
|
||||
*
|
||||
* The dialog renders into the component's tree once, and `confirm()`
|
||||
* resolves with the user's choice. Multiple sequential `confirm()`
|
||||
* calls are safe — each gets its own promise.
|
||||
*/
|
||||
export function useConfirmation() {
|
||||
const [state, setState] = useState<(ConfirmOptions & { open: boolean }) | null>(null);
|
||||
const resolverRef = useRef<((ok: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
|
||||
// If a previous confirm is somehow still open, resolve it as a cancel
|
||||
// before starting the next. Defensive against rapid double-fires.
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(false);
|
||||
resolverRef.current = null;
|
||||
}
|
||||
setState({ ...opts, open: true });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
resolverRef.current?.(true);
|
||||
resolverRef.current = null;
|
||||
setState((prev) => (prev ? { ...prev, open: false } : null));
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
resolverRef.current?.(false);
|
||||
resolverRef.current = null;
|
||||
setState((prev) => (prev ? { ...prev, open: false } : null));
|
||||
}, []);
|
||||
|
||||
const dialog = state ? (
|
||||
<AlertDialog
|
||||
open={state.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancel();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{state.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{state.description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancel}>
|
||||
{state.cancelLabel ?? 'Cancel'}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className={cn(
|
||||
state.destructive !== false &&
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
)}
|
||||
>
|
||||
{state.confirmLabel ?? 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null;
|
||||
|
||||
return { confirm, dialog };
|
||||
}
|
||||
@@ -40,17 +40,12 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
|
||||
|
||||
const remoteHidden =
|
||||
meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns;
|
||||
// Local edits win over the server-loaded prefs. The render-phase
|
||||
// derivation below (line 107: `localHidden ?? remoteHidden ?? defaultHidden`)
|
||||
// replaces the prior useEffect(setLocalHidden, [remoteHidden]) sync
|
||||
// that the Compiler flagged as set-state-in-effect.
|
||||
const [localHidden, setLocalHidden] = useState<string[] | null>(null);
|
||||
|
||||
// When the remote preferences arrive (or change), seed the local
|
||||
// state. We only sync from remote → local on first load or when the
|
||||
// server side genuinely changes (e.g. another tab updated prefs).
|
||||
useEffect(() => {
|
||||
if (remoteHidden && localHidden === null) {
|
||||
setLocalHidden(remoteHidden);
|
||||
}
|
||||
}, [remoteHidden, localHidden]);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const setHidden = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user