348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import clsx from "clsx";
|
|
import { AriaAttributes, forwardRef, lazy, ReactNode, Ref, RefObject, Suspense, useEffect, useId, useImperativeHandle, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
|
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
|
import MonacoEditor from "~/components/editors/MonacoEditor";
|
|
import { translateLabel } from "~/utils/i18n/translateLabel";
|
|
import { TextInput } from "~/ui/components/TextInput";
|
|
import { TextArea } from "~/ui/components/TextArea";
|
|
|
|
const NovelEditor = lazy(() => import("~/modules/novel/ui/editor"));
|
|
|
|
export interface RefInputText {
|
|
input: RefObject<HTMLInputElement | null> | RefObject<HTMLTextAreaElement | null>;
|
|
textArea: RefObject<HTMLTextAreaElement | null>;
|
|
}
|
|
|
|
type EditorSize = "sm" | "md" | "lg" | "auto" | "full" | "screen";
|
|
|
|
type WithDefaultValue = { defaultValue: string | undefined };
|
|
type WithValueAndSetValue = { value: string | undefined; setValue: React.Dispatch<React.SetStateAction<string>> };
|
|
|
|
// Subframe TextInput/TextArea accept a constrained `type` union.
|
|
type SubframeFieldType = "text" | "password" | "email" | "number" | "tel" | "url" | "search";
|
|
const SUBFRAME_FIELD_TYPES: SubframeFieldType[] = ["text", "password", "email", "number", "tel", "url", "search"];
|
|
function toSubframeType(type: string | undefined): SubframeFieldType {
|
|
return (SUBFRAME_FIELD_TYPES as string[]).includes(type ?? "") ? (type as SubframeFieldType) : "text";
|
|
}
|
|
|
|
export type InputTextProps = (WithDefaultValue | WithValueAndSetValue) & {
|
|
id?: string;
|
|
name?: string;
|
|
title?: string;
|
|
withLabel?: boolean;
|
|
className?: string;
|
|
classNameBg?: string;
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
readOnly?: boolean;
|
|
disabled?: boolean;
|
|
required?: boolean;
|
|
autoComplete?: string;
|
|
withTranslation?: boolean;
|
|
translationParams?: string[];
|
|
placeholder?: string;
|
|
pattern?: string;
|
|
rows?: number;
|
|
button?: ReactNode;
|
|
// lowercase?: boolean;
|
|
// uppercase?: boolean;
|
|
type?: string;
|
|
darkMode?: boolean;
|
|
hint?: ReactNode;
|
|
help?: string;
|
|
icon?: string;
|
|
editor?: string; // monaco
|
|
editorLanguage?: string; // "javascript" | "typescript" | "html" | "css" | "json";
|
|
editorHideLineNumbers?: boolean;
|
|
editorTheme?: "vs-dark" | "light";
|
|
editorFontSize?: number;
|
|
editorOptions?: any;
|
|
onBlur?: () => void;
|
|
onFocus?: () => void;
|
|
borderless?: boolean;
|
|
editorSize?: EditorSize;
|
|
autoFocus?: boolean;
|
|
isError?: boolean;
|
|
isSuccess?: boolean;
|
|
ariaDescribedBy?: string;
|
|
ariaInvalid?: AriaAttributes["aria-invalid"];
|
|
hideChars?: boolean;
|
|
dataUiId?: string;
|
|
onPaste?: (e: React.ClipboardEvent<HTMLInputElement>) => void;
|
|
};
|
|
const InputText = (props: InputTextProps, ref: Ref<RefInputText>) => {
|
|
const {
|
|
id,
|
|
name,
|
|
title,
|
|
withLabel = true,
|
|
className,
|
|
classNameBg,
|
|
help,
|
|
disabled = false,
|
|
readOnly = false,
|
|
required = false,
|
|
minLength,
|
|
maxLength,
|
|
autoComplete,
|
|
withTranslation = false,
|
|
translationParams = [],
|
|
placeholder,
|
|
pattern,
|
|
hint,
|
|
rows,
|
|
button,
|
|
// lowercase,
|
|
// uppercase,
|
|
type = "text",
|
|
darkMode,
|
|
icon,
|
|
editor,
|
|
editorLanguage,
|
|
editorHideLineNumbers,
|
|
editorTheme = "vs-dark",
|
|
editorFontSize,
|
|
editorOptions,
|
|
onBlur,
|
|
onFocus,
|
|
borderless,
|
|
editorSize = "sm",
|
|
autoFocus,
|
|
isError,
|
|
// isSuccess,
|
|
ariaDescribedBy,
|
|
ariaInvalid,
|
|
hideChars,
|
|
dataUiId,
|
|
onPaste,
|
|
} = props;
|
|
|
|
const { t, i18n } = useTranslation();
|
|
const resolvedTitle = title ? translateLabel(t, i18n, title) : undefined;
|
|
|
|
const value = "value" in props ? props.value : undefined;
|
|
const setValue = "setValue" in props ? props.setValue : undefined;
|
|
const defaultValue = "defaultValue" in props ? props.defaultValue : undefined;
|
|
|
|
const [actualEditorSize] = useState<EditorSize>(editorSize ?? ("sm" as EditorSize));
|
|
// Fall back to a generated id so the visible <label> always associates with the
|
|
// input even when no id/name is supplied (e.g. read-only display fields).
|
|
// Without this the label has htmlFor={undefined} and axe flags a missing label.
|
|
const generatedId = useId();
|
|
const inputId = id ?? name ?? generatedId;
|
|
const resolvedAriaInvalid = ariaInvalid ?? (isError ? true : undefined);
|
|
|
|
useImperativeHandle(ref, () => ({ input, textArea }));
|
|
const input = useRef<HTMLInputElement>(null);
|
|
const textArea = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const [actualValue, setActualValue] = useState<string>(value ?? defaultValue ?? "");
|
|
|
|
useEffect(() => {
|
|
setActualValue(value || defaultValue || "");
|
|
}, [value, defaultValue]);
|
|
|
|
useEffect(() => {
|
|
if (setValue && actualValue !== value) {
|
|
setValue(actualValue);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [actualValue]);
|
|
|
|
function onChange(value: string) {
|
|
setActualValue(value);
|
|
if (setValue) {
|
|
setValue(value);
|
|
}
|
|
}
|
|
|
|
const showLabel = Boolean(withLabel && resolvedTitle);
|
|
|
|
// Rich label content rendered into Subframe's label slot (inline-safe — spans only, so it
|
|
// is valid both inside a native <label> and inside Subframe's <span> label wrapper).
|
|
const labelContent: ReactNode = showLabel ? (
|
|
<span className="flex w-full items-center justify-between gap-2">
|
|
<span className="flex shrink-0 items-center space-x-1 truncate">
|
|
<span className="flex space-x-1 truncate">
|
|
<span className="truncate">{resolvedTitle}</span>
|
|
{required && resolvedTitle && <span className="ml-1 text-red-600">*</span>}
|
|
</span>
|
|
{help && <HintTooltip text={help} />}
|
|
</span>
|
|
{withTranslation && value?.includes(".") && (
|
|
<span
|
|
className="text-muted-foreground truncate font-light italic"
|
|
title={i18n.exists(value) ? String(translationParams.length > 0 ? t(value, { 0: translationParams }) : translateLabel(t, i18n, value)) : t("shared.invalid")}
|
|
>
|
|
{t("admin.pricing.i18n")}:{" "}
|
|
{i18n.exists(value) ? (
|
|
<span className="text-muted-foreground">{translationParams.length > 0 ? t(value, { 0: translationParams }) : translateLabel(t, i18n, value)}</span>
|
|
) : (
|
|
<span className="text-red-600">{t("shared.invalid")}</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
{hint}
|
|
</span>
|
|
) : null;
|
|
|
|
// Editor branches (monaco / wysiwyg) keep their original rendering. They are not driven by
|
|
// any current InputText call site, but remain for API completeness.
|
|
if (editor === "monaco" && editorLanguage) {
|
|
return (
|
|
<div className={clsx(className, !darkMode && "")}>
|
|
{showLabel && (
|
|
<label htmlFor={inputId} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
|
|
{labelContent}
|
|
</label>
|
|
)}
|
|
<div className={clsx("relative flex w-full rounded-md")}>
|
|
<textarea hidden readOnly name={name} value={actualValue} />
|
|
{typeof window !== "undefined" && (
|
|
<MonacoEditor
|
|
theme={editorTheme}
|
|
className={clsx(
|
|
actualEditorSize === "sm" && "h-32",
|
|
actualEditorSize === "md" && "h-64",
|
|
actualEditorSize === "lg" && "h-96",
|
|
actualEditorSize === "auto" && "h-auto",
|
|
actualEditorSize === "full" && "h-full",
|
|
actualEditorSize === "screen" && "h-screen",
|
|
className,
|
|
classNameBg,
|
|
borderless && "border-transparent"
|
|
)}
|
|
defaultLanguage={editorLanguage}
|
|
language={editorLanguage as any}
|
|
hideLineNumbers={editorHideLineNumbers}
|
|
fontSize={editorFontSize}
|
|
readOnly={disabled || readOnly}
|
|
options={editorOptions}
|
|
value={value ?? ""}
|
|
defaultValue={defaultValue}
|
|
onChange={(e) => onChange(e)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (editor === "wysiwyg") {
|
|
return (
|
|
<div className={clsx(className, !darkMode && "")}>
|
|
{showLabel && (
|
|
<label htmlFor={inputId} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
|
|
{labelContent}
|
|
</label>
|
|
)}
|
|
<div className={clsx("relative flex w-full rounded-md")}>
|
|
<textarea hidden readOnly name={name} value={actualValue} />
|
|
<Suspense fallback={null}>
|
|
<NovelEditor
|
|
autoFocus={autoFocus}
|
|
key={name ?? "novel-editor"}
|
|
readOnly={readOnly || disabled}
|
|
disabled={disabled}
|
|
darkMode={darkMode}
|
|
className={clsx(
|
|
actualEditorSize === "sm" && "min-h-[100px]",
|
|
actualEditorSize === "md" && "min-h-[300px]",
|
|
actualEditorSize === "lg" && "min-h-[500px]",
|
|
actualEditorSize === "auto" && "h-auto",
|
|
actualEditorSize === "full" && "h-full",
|
|
actualEditorSize === "screen" && "h-screen",
|
|
borderless && "border-transparent"
|
|
)}
|
|
content={value || defaultValue}
|
|
onChange={(e) => onChange(e?.html ?? "")}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Textarea field — Subframe TextArea (label + box + textarea).
|
|
if (rows) {
|
|
return (
|
|
<div className={clsx(className, !darkMode && "")}>
|
|
<TextArea className="w-full max-w-none" showLabel={showLabel} label={labelContent} error={resolvedAriaInvalid === true} borderless={borderless}>
|
|
<TextArea.Input
|
|
ref={textArea}
|
|
rows={rows}
|
|
style={hideChars ? ({ WebkitTextSecurity: "disc" } as any) : undefined}
|
|
id={id ?? name}
|
|
name={name}
|
|
aria-describedby={ariaDescribedBy}
|
|
aria-invalid={resolvedAriaInvalid}
|
|
autoComplete={autoComplete}
|
|
required={required}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
defaultValue={defaultValue}
|
|
value={value}
|
|
onChange={(e) => onChange(e.currentTarget.value)}
|
|
onBlur={onBlur}
|
|
onFocus={onFocus}
|
|
disabled={disabled}
|
|
readOnly={readOnly}
|
|
placeholder={placeholder}
|
|
data-ui-id={dataUiId}
|
|
aria-label={!showLabel ? resolvedTitle ?? placeholder : undefined}
|
|
autoFocus={autoFocus}
|
|
/>
|
|
</TextArea>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default text field — Subframe TextInput (label + box + input).
|
|
return (
|
|
<div className={clsx(className, !darkMode && "")}>
|
|
<TextInput
|
|
className="w-full max-w-none"
|
|
showLabel={showLabel}
|
|
label={labelContent}
|
|
error={resolvedAriaInvalid === true}
|
|
disabled={disabled}
|
|
type={toSubframeType(type)}
|
|
borderless={borderless}
|
|
icon={icon ? <EntityIcon className="text-muted-foreground h-5 w-5" icon={icon} /> : undefined}
|
|
>
|
|
<TextInput.Input
|
|
ref={input}
|
|
type={toSubframeType(type)}
|
|
style={hideChars ? ({ WebkitTextSecurity: "disc" } as any) : undefined}
|
|
id={inputId}
|
|
name={name}
|
|
aria-describedby={ariaDescribedBy}
|
|
aria-invalid={resolvedAriaInvalid}
|
|
autoComplete={autoComplete}
|
|
required={required}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
defaultValue={defaultValue}
|
|
value={value}
|
|
onChange={(e) => onChange(e.currentTarget.value)}
|
|
onBlur={onBlur}
|
|
onFocus={onFocus}
|
|
disabled={disabled}
|
|
readOnly={readOnly}
|
|
placeholder={placeholder}
|
|
data-ui-id={dataUiId}
|
|
aria-label={!showLabel ? resolvedTitle ?? placeholder : undefined}
|
|
pattern={pattern !== "" && pattern !== undefined ? pattern : undefined}
|
|
autoFocus={autoFocus}
|
|
onPaste={onPaste}
|
|
/>
|
|
</TextInput>
|
|
{button}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default forwardRef(InputText);
|