Files
siftlode/frontend/src/components/ConfirmProvider.tsx
T
peter fc186e1230 refactor(ui): address /code-review on .btn-primary — tone family + doc fixes
- Generalize the recipe into a .btn-{tone} family: add .btn-danger / .btn-warn
  siblings sharing one hover-dim, and migrate the two hand-rolled tone-action
  buttons (ConfirmProvider danger confirm, Banner warning action) onto them, so
  primary/danger/warn action paints can't drift.
- Reword the recipe comment: it owns paint + one standard hover-dim (not
  'PAINT-ONLY' — that mislabel obscured the hover behaviour).
- Drop the now-redundant hover:opacity-90 from Banner's action-button base (each
  tone's recipe supplies the hover).

The 3 previously hover-less primary buttons keep the standard hover-dim gained in
the prior commit (intentional consistency; now accurately documented). Gate green
(tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed btn-danger/btn-warn
compute to their tone/tone-fg tokens.
2026-08-11 04:20:10 +02:00

130 lines
4.5 KiB
TypeScript

import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
// if (await confirm({ message: "…", danger: true })) { … }
//
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
// went ahead and deleted).
interface ConfirmOptions {
title?: string;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
interface Choice {
id: string;
label: string;
danger?: boolean;
}
export interface ChoiceOptions {
title?: string;
message: ReactNode;
cancelLabel?: string;
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
choices: Choice[];
}
type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
export function useConfirm(): ConfirmFn {
const ask = useContext(ChoiceContext);
return useCallback(
async (opts: ConfirmOptions) => {
const picked = await ask({
title: opts.title,
message: opts.message,
cancelLabel: opts.cancelLabel,
choices: [{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger }],
});
return picked === "confirm";
},
[ask],
);
}
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
export function useChoice(): ChoiceFn {
return useContext(ChoiceContext);
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [state, setState] = useState<{
opts: ChoiceOptions;
resolve: (id: string | null) => void;
} | null>(null);
const ask = useCallback<ChoiceFn>(
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
[],
);
function close(id: string | null) {
state?.resolve(id);
setState(null);
}
const choices = state?.opts.choices ?? [];
// Focus Cancel when any offered action is destructive: the confirm button used to take
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
// "Clear audit log" without the user ever looking at it.
const anyDanger = choices.some((c) => c.danger);
const multi = choices.length > 1;
return (
<ChoiceContext.Provider value={ask}>
{children}
{state && (
<Modal
title={state.opts.title ?? t("common.confirmTitle")}
onClose={() => close(null)}
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
>
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
these are peers. Ordered least to most destructive, top to bottom. */}
<div
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
>
<button
autoFocus={anyDanger}
onClick={() => close(null)}
className={`px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition ${
multi ? "w-full order-last" : ""
}`}
>
{state.opts.cancelLabel ?? t("common.cancel")}
</button>
{choices.map((c, i) => (
<button
key={c.id}
autoFocus={!anyDanger && i === choices.length - 1}
onClick={() => close(c.id)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
multi ? "w-full" : ""
} ${c.danger ? "btn-danger" : "btn-primary"}`}
>
{c.label || t("common.confirm")}
</button>
))}
</div>
</Modal>
)}
</ChoiceContext.Provider>
);
}