Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/ui/input/InputText.tsx
T

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);