- The dragged row trailed the cursor by 150ms. The row carried Tailwind's bare `transition` utility (copied from DataTable's <tr>), which animates TRANSFORM — exactly the property dnd-kit drives from the pointer. transition-colors now, and the row being dragged drops dnd-kit's own transition too. - The list looked sorted by Channel when it was really in its stored order that happens to be channel-grouped, and nothing said so. Sorting by "#" ascending IS the stored order, so it is now the neutral state: the header shows a real arrow for it, cycling off any column lands back on it, and it stays the state where drag is enabled and no save is offered. - A choice dialog with two or more actions stacks vertically. Side by side they wrapped raggedly, and the wrapped one read as a lesser second group when the two are peers. Cancel goes last, actions run least to most destructive.
140 lines
4.8 KiB
TypeScript
140 lines
4.8 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).
|
|
export interface ConfirmOptions {
|
|
title?: string;
|
|
message: ReactNode;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
danger?: boolean;
|
|
}
|
|
|
|
export 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
|
|
? "bg-red-500 text-white hover:bg-red-600"
|
|
: "bg-accent text-accent-fg hover:opacity-90"
|
|
}`}
|
|
>
|
|
{c.label || t("common.confirm")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</ChoiceContext.Provider>
|
|
);
|
|
}
|