114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
import clsx from "clsx";
|
|
import { forwardRef, ReactNode, Ref, RefObject, useId, useImperativeHandle, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
|
import { NumberInput } from "~/ui/components/NumberInput";
|
|
|
|
export interface RefInputNumber {
|
|
input: RefObject<HTMLInputElement | null>;
|
|
}
|
|
// type WithoutValue = {};
|
|
type WithDefaultValue = { defaultValue: number | undefined };
|
|
type WithValueAndSetValue = { value: number | undefined; setValue: React.Dispatch<React.SetStateAction<number | undefined>> };
|
|
|
|
type Props = (WithDefaultValue | WithValueAndSetValue) & {
|
|
name?: string;
|
|
title?: string;
|
|
withLabel?: boolean;
|
|
className?: string;
|
|
help?: string;
|
|
min?: number;
|
|
max?: number;
|
|
disabled?: boolean;
|
|
readOnly?: boolean;
|
|
required?: boolean;
|
|
hint?: ReactNode;
|
|
step?: string;
|
|
placeholder?: string;
|
|
icon?: string;
|
|
borderless?: boolean;
|
|
autoFocus?: boolean;
|
|
canUnset?: boolean;
|
|
};
|
|
const InputNumber = (props: Props, ref: Ref<RefInputNumber>) => {
|
|
const { t } = useTranslation();
|
|
|
|
const {
|
|
name,
|
|
title,
|
|
withLabel = true,
|
|
className,
|
|
hint,
|
|
help,
|
|
disabled = false,
|
|
readOnly = false,
|
|
required = false,
|
|
min = 0,
|
|
max,
|
|
step,
|
|
placeholder,
|
|
// icon — Subframe NumberInput has no left-icon slot; see note below.
|
|
borderless,
|
|
autoFocus,
|
|
canUnset,
|
|
} = props;
|
|
|
|
const value = "value" in props ? props.value : undefined;
|
|
const setValue = "setValue" in props ? props.setValue : undefined;
|
|
const defaultValue = "defaultValue" in props ? props.defaultValue : undefined;
|
|
|
|
useImperativeHandle(ref, () => ({ input }));
|
|
const input = useRef<HTMLInputElement>(null);
|
|
|
|
// Fall back to a generated id so the visible <label> always associates with the
|
|
// input even when no name is supplied. Mirrors the InputText shim.
|
|
const generatedId = useId();
|
|
const inputId = name ?? generatedId;
|
|
|
|
const showLabel = Boolean(withLabel && title);
|
|
|
|
// Rich label content rendered into Subframe's label slot (inline-safe — spans only).
|
|
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">
|
|
<span className="truncate">
|
|
{title}
|
|
{required && <span className="ml-1 text-red-600">*</span>}
|
|
</span>
|
|
{help && <HintTooltip text={help} />}
|
|
</span>
|
|
{canUnset && !required && !disabled && !readOnly && value !== undefined && setValue && (
|
|
<button type="button" onClick={() => (setValue ? setValue(undefined) : null)} className="text-muted-foreground text-xs">
|
|
{t("shared.clear")}
|
|
</button>
|
|
)}
|
|
{hint}
|
|
</span>
|
|
) : null;
|
|
|
|
return (
|
|
<div className={clsx(className, "")}>
|
|
<NumberInput className="w-full max-w-none" showLabel={showLabel} label={labelContent} disabled={disabled} borderless={borderless}>
|
|
<NumberInput.Input
|
|
ref={input}
|
|
id={inputId}
|
|
name={name}
|
|
required={required}
|
|
min={min}
|
|
max={max}
|
|
value={value}
|
|
defaultValue={defaultValue}
|
|
onChange={(e) => (setValue ? setValue(Number(e.currentTarget.value)) : null)}
|
|
step={step}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
readOnly={readOnly}
|
|
autoFocus={autoFocus}
|
|
aria-label={!showLabel ? title ?? placeholder : undefined}
|
|
/>
|
|
</NumberInput>
|
|
</div>
|
|
);
|
|
};
|
|
export default forwardRef(InputNumber);
|