154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
/**
|
|
* @file GitModal.tsx
|
|
* @description The shared modal shell of the Changes panel's git tools (merge
|
|
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
|
|
* portal over the screen with `useDialog`'s focus contract, a title row with a
|
|
* close button, a scrolling body and an optional footer. `wide` widens it for
|
|
* the three-pane merge editor; `tall` gives a viewer (blame, browse at a
|
|
* revision, compare refs) the full height and a column body its own scrolling
|
|
* child can fill; on a phone every size is the full width. `ModalButton` is
|
|
* the History tools' footer button over the same BTN classes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useId, useRef, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { X } from "lucide-react";
|
|
import { useDialog } from "../../../hooks/useDialog";
|
|
|
|
export const BTN =
|
|
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
|
|
export const BTN_PRIMARY =
|
|
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
|
|
export const BTN_DANGER =
|
|
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
|
|
|
|
export function GitModal({
|
|
title,
|
|
onClose,
|
|
testId,
|
|
wide = false,
|
|
tall = false,
|
|
footer,
|
|
children,
|
|
}: {
|
|
title: ReactNode;
|
|
onClose: () => void;
|
|
testId?: string;
|
|
wide?: boolean;
|
|
/** A viewer: the full height, and a flex-column body. */
|
|
tall?: boolean;
|
|
footer?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
const { t } = useTranslation(["gitTools"]);
|
|
const panel = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
useDialog(true, panel, onClose);
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
|
|
onClick={onClose}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={panel}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
data-testid={testId}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
|
|
wide || tall ? "max-w-6xl" : "max-w-lg"
|
|
} ${tall ? "h-[92vh]" : ""}`}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
<h2
|
|
id={titleId}
|
|
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
|
|
>
|
|
{title}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t("close")}
|
|
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
className={`min-h-0 flex-1 px-3 py-2 text-xs text-fg-secondary ${
|
|
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
|
|
}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer && (
|
|
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
/** A red inline error line, or nothing. */
|
|
export function ErrorLine({ error }: { error: string | null }) {
|
|
if (!error) return null;
|
|
return (
|
|
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
|
|
{error}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** An amber explanation box shown before a rewrite runs. */
|
|
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
|
|
return (
|
|
<div
|
|
data-testid={testId}
|
|
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
|
|
export function ModalButton({
|
|
onClick,
|
|
children,
|
|
primary = false,
|
|
danger = false,
|
|
disabled = false,
|
|
testId,
|
|
title,
|
|
}: {
|
|
onClick: () => void;
|
|
children: ReactNode;
|
|
primary?: boolean;
|
|
danger?: boolean;
|
|
disabled?: boolean;
|
|
testId?: string;
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid={testId}
|
|
title={title}
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|