92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
import React, { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
|
|
import { useOptionalI18n } from "./i18n/I18nProvider";
|
|
|
|
type PromptOptions = {
|
|
title?: string;
|
|
message: string;
|
|
defaultValue?: string;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
};
|
|
|
|
type PromptContextValue = {
|
|
prompt: (options: PromptOptions) => Promise<string | null>;
|
|
};
|
|
|
|
type PromptState = PromptOptions & {
|
|
open: boolean;
|
|
resolver?: (value: string | null) => void;
|
|
};
|
|
|
|
const PromptContext = createContext<PromptContextValue | null>(null);
|
|
|
|
export function PromptProvider({ children }: { children: React.ReactNode }) {
|
|
const i18n = useOptionalI18n();
|
|
const messageId = useId();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [state, setState] = useState<PromptState>({
|
|
open: false,
|
|
title: "Enter value",
|
|
message: "",
|
|
defaultValue: "",
|
|
confirmLabel: "Save",
|
|
cancelLabel: "Cancel",
|
|
});
|
|
const [value, setValue] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!state.open) return;
|
|
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [state.open]);
|
|
|
|
const closeWith = useCallback((next: string | null) => {
|
|
setState((prev) => {
|
|
prev.resolver?.(next);
|
|
return { ...prev, open: false, resolver: undefined };
|
|
});
|
|
}, []);
|
|
|
|
const prompt = useCallback((options: PromptOptions) => {
|
|
return new Promise<string | null>((resolve) => {
|
|
setValue(options.defaultValue ?? "");
|
|
setState({
|
|
open: true,
|
|
title: options.title ?? i18n?.t("enterValue") ?? "Enter value",
|
|
message: options.message,
|
|
defaultValue: options.defaultValue ?? "",
|
|
confirmLabel: options.confirmLabel ?? i18n?.t("save") ?? "Save",
|
|
cancelLabel: options.cancelLabel ?? i18n?.t("cancel") ?? "Cancel",
|
|
resolver: resolve,
|
|
});
|
|
});
|
|
}, [i18n]);
|
|
|
|
const contextValue = useMemo(() => ({ prompt }), [prompt]);
|
|
|
|
return (
|
|
<PromptContext.Provider value={contextValue}>
|
|
{children}
|
|
<Dialog open={state.open} onClose={() => closeWith(null)} aria-describedby={messageId} fullWidth maxWidth="xs"
|
|
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
|
|
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
|
|
<DialogContent>
|
|
<Typography id={messageId} sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
|
|
<TextField inputRef={inputRef} autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180, "aria-labelledby": messageId }} helperText={`${value.length}/180`} />
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
|
|
<Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button>
|
|
<Button variant="contained" onClick={() => closeWith(value)}>{state.confirmLabel}</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</PromptContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function usePrompt() {
|
|
const ctx = useContext(PromptContext);
|
|
if (!ctx) throw new Error("usePrompt must be used within PromptProvider");
|
|
return ctx;
|
|
}
|