172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
import { Ref, forwardRef, useImperativeHandle, useRef, ReactNode, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router";
|
|
import { Colors } from "~/application/enums/shared/Colors";
|
|
import ColorBadge from "../badges/ColorBadge";
|
|
import HintTooltip from "../tooltips/HintTooltip";
|
|
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
|
import { Select } from "~/ui/components/Select";
|
|
import { PlusIcon } from "~/components/ui/icons/vendor";
|
|
|
|
export interface RefInputSelector {
|
|
focus: () => void;
|
|
}
|
|
|
|
interface Props {
|
|
name?: string;
|
|
title?: string;
|
|
value?: string | number | undefined;
|
|
defaultValue?: string | number | undefined;
|
|
disabled?: boolean;
|
|
options: { name: string | ReactNode; value: string | number | undefined; color?: Colors; disabled?: boolean }[];
|
|
setValue?: React.Dispatch<React.SetStateAction<string | number | undefined>>;
|
|
className?: string;
|
|
withSearch?: boolean;
|
|
withLabel?: boolean;
|
|
withColors?: boolean;
|
|
selectPlaceholder?: string;
|
|
onNew?: () => void;
|
|
onNewRoute?: string;
|
|
required?: boolean;
|
|
help?: string;
|
|
hint?: ReactNode;
|
|
icon?: string;
|
|
borderless?: boolean;
|
|
darkMode?: boolean;
|
|
autoFocus?: boolean;
|
|
readOnly?: boolean;
|
|
isLoading?: boolean;
|
|
}
|
|
const InputSelector = (
|
|
{
|
|
name,
|
|
title,
|
|
value,
|
|
defaultValue,
|
|
options,
|
|
disabled,
|
|
setValue,
|
|
className,
|
|
// withSearch — Subframe Select has built-in typeahead; legacy in-list search dropped.
|
|
withLabel = true,
|
|
withColors = false,
|
|
selectPlaceholder,
|
|
onNew,
|
|
required,
|
|
onNewRoute,
|
|
help,
|
|
hint,
|
|
icon,
|
|
borderless,
|
|
// darkMode — handled by the global Subframe theme.
|
|
autoFocus,
|
|
readOnly,
|
|
isLoading,
|
|
}: Props,
|
|
ref: Ref<RefInputSelector>
|
|
) => {
|
|
const { t } = useTranslation();
|
|
|
|
const container = useRef<HTMLDivElement>(null);
|
|
|
|
const [selected, setSelected] = useState<{ name: string | ReactNode; value: string | number | undefined; color?: Colors }>();
|
|
|
|
useEffect(() => {
|
|
const found = options?.find((f) => f.value === value);
|
|
setSelected(found);
|
|
}, [options, value]);
|
|
|
|
useEffect(() => {
|
|
if (selected && setValue && value !== selected?.value) {
|
|
setValue(selected?.value);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selected]);
|
|
|
|
useImperativeHandle(ref, () => ({ focus }));
|
|
function focus() {
|
|
setTimeout(() => {
|
|
const button = container.current?.querySelector("button");
|
|
button?.focus();
|
|
button?.click();
|
|
}, 1);
|
|
}
|
|
|
|
const showLabel = Boolean(withLabel && title);
|
|
const newLabel = String(t("shared.new"));
|
|
|
|
// Rich label content rendered into Subframe's label slot (spans only → inline-safe).
|
|
const labelContent: ReactNode = showLabel ? (
|
|
<span className="flex w-full items-center justify-between gap-2">
|
|
<span className="flex items-center space-x-1 truncate">
|
|
<span className="truncate">
|
|
{title}
|
|
{required && <span className="ml-1 text-red-600">*</span>}
|
|
</span>
|
|
{help && <HintTooltip text={help} />}
|
|
</span>
|
|
{hint}
|
|
</span>
|
|
) : null;
|
|
|
|
const placeholderText = isLoading ? `${t("shared.loading")}...` : `${selectPlaceholder ?? t("shared.select")}...`;
|
|
|
|
return (
|
|
<div className={className}>
|
|
<div className="flex items-end gap-2">
|
|
<Select
|
|
ref={container}
|
|
className="w-full max-w-none"
|
|
name={name}
|
|
value={value !== undefined ? value.toString() : undefined}
|
|
defaultValue={defaultValue !== undefined ? defaultValue.toString() : undefined}
|
|
onValueChange={(e) => {
|
|
const found = options?.find((f) => f.value?.toString() === e);
|
|
setSelected(found);
|
|
}}
|
|
disabled={disabled || readOnly}
|
|
required={required}
|
|
borderless={borderless}
|
|
showLabel={showLabel}
|
|
label={labelContent}
|
|
placeholder={placeholderText}
|
|
icon={icon ? <EntityIcon className="text-muted-foreground h-5 w-5" icon={icon} /> : undefined}
|
|
>
|
|
{options.map((item, idx) => (
|
|
<Select.Item key={idx} disabled={item.disabled} value={item.value?.toString() ?? ""}>
|
|
<div className="flex items-center space-x-2">
|
|
{withColors && item.color !== undefined && <ColorBadge color={item.color} />}
|
|
<div className="truncate">{item.name}</div>
|
|
</div>
|
|
</Select.Item>
|
|
))}
|
|
</Select>
|
|
{onNew && (
|
|
<button
|
|
aria-label={newLabel}
|
|
title={newLabel}
|
|
type="button"
|
|
onClick={onNew}
|
|
autoFocus={autoFocus}
|
|
className="border-border bg-secondary hover:bg-secondary/90 text-foreground/80 focus-visible:border-border focus-visible:ring-ring relative inline-flex h-9 w-10 shrink-0 items-center justify-center rounded-md border text-sm font-medium focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
>
|
|
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
{onNewRoute && (
|
|
<Link
|
|
to={onNewRoute}
|
|
aria-label={newLabel}
|
|
title={newLabel}
|
|
className="border-border bg-secondary hover:bg-secondary/90 text-foreground/80 focus-visible:border-border focus-visible:ring-ring relative inline-flex h-9 w-10 shrink-0 items-center justify-center rounded-md border text-sm font-medium focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
>
|
|
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default forwardRef(InputSelector);
|