Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { Switch } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import { Checkbox } from "~/ui/components/Checkbox";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
value?: boolean | undefined;
|
||||
setValue?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
asToggle?: boolean;
|
||||
readOnly?: boolean;
|
||||
hint?: ReactNode;
|
||||
help?: string;
|
||||
icon?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export default function InputCheckbox({
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
value,
|
||||
setValue,
|
||||
className,
|
||||
required,
|
||||
disabled,
|
||||
asToggle,
|
||||
readOnly,
|
||||
hint,
|
||||
help,
|
||||
icon,
|
||||
autoFocus,
|
||||
}: Props) {
|
||||
// useImperativeHandle(ref, () => ({ input }));
|
||||
// const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Subframe's Checkbox is always controlled (no uncontrolled/defaultChecked path), so the
|
||||
// shim owns the checked state and mirrors it back through setValue — preserving the
|
||||
// original controlled+uncontrolled public API.
|
||||
const [actualValue, setActualValue] = useState(value ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setActualValue(value ?? false);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setValue && actualValue !== value) {
|
||||
setValue(actualValue);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actualValue]);
|
||||
|
||||
const accessibleName = title || name || undefined;
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "")}>
|
||||
{withLabel && title && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className=" flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
<div className="">{help && <HintTooltip text={help} />}</div>
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
<div className={clsx("relative flex w-full rounded-md")}>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="text-muted-foreground h-5 w-5" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
<input type="hidden" name={name} value={actualValue === true ? "true" : "false"} hidden readOnly />
|
||||
{asToggle ? (
|
||||
// asToggle renders a switch (not a checkbox) — left on the legacy headless-ui Switch;
|
||||
// switch migration is tracked separately (Subframe Switch).
|
||||
<Switch
|
||||
checked={actualValue}
|
||||
onChange={setActualValue}
|
||||
disabled={disabled || readOnly}
|
||||
autoFocus={autoFocus}
|
||||
aria-label={accessibleName}
|
||||
className={clsx(
|
||||
actualValue ? "bg-primary" : " bg-secondary ",
|
||||
"focus:ring-primary relative inline-flex h-5 w-8 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus:ring-2 focus:ring-offset-2",
|
||||
icon && "pl-10",
|
||||
(disabled || readOnly) && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
actualValue ? "translate-x-3" : "translate-x-0",
|
||||
"bg-background pointer-events-none inline-block h-4 w-4 transform rounded-full shadow-xs ring-0 transition duration-200 ease-in-out"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
) : (
|
||||
<Checkbox
|
||||
id={name}
|
||||
name={name}
|
||||
// The visible <label> above already labels this field; Subframe's own label slot is
|
||||
// hidden to avoid a duplicate. aria-label is the accessible name when no label shows.
|
||||
hideLabel={true}
|
||||
aria-label={!withLabel || !title ? accessibleName : undefined}
|
||||
onCheckedChange={(checked) => setActualValue(Boolean(checked))}
|
||||
disabled={disabled || readOnly}
|
||||
autoFocus={autoFocus}
|
||||
checked={actualValue}
|
||||
className={clsx((disabled || readOnly) && "cursor-not-allowed", "mt-1", icon && "pl-10")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import InputCheckboxCards from "./InputCheckboxCards";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("InputCheckboxCards", () => {
|
||||
it("does not mutate the incoming value array while comparing selections", () => {
|
||||
const value = ["beta", "alpha"];
|
||||
|
||||
render(
|
||||
<InputCheckboxCards
|
||||
options={[
|
||||
{ name: "Alpha", value: "alpha" },
|
||||
{ name: "Beta", value: "beta" },
|
||||
]}
|
||||
value={value}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(value).toEqual(["beta", "alpha"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import HintTooltip from "../tooltips/HintTooltip";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { CheckboxCard } from "~/ui/components/CheckboxCard";
|
||||
|
||||
type CheckboxCardValue = string | number;
|
||||
|
||||
function compareCheckboxCardValue(a: CheckboxCardValue, b: CheckboxCardValue) {
|
||||
const aKey = `${typeof a}:${String(a)}`;
|
||||
const bKey = `${typeof b}:${String(b)}`;
|
||||
|
||||
return aKey.localeCompare(bKey);
|
||||
}
|
||||
|
||||
function hasSameCheckboxCardValues(a: CheckboxCardValue[] | undefined, b: CheckboxCardValue[] | undefined) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!a || !b || a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sortedA = [...a].sort(compareCheckboxCardValue);
|
||||
const sortedB = [...b].sort(compareCheckboxCardValue);
|
||||
|
||||
return sortedA.every((value, index) => Object.is(value, sortedB[index]));
|
||||
}
|
||||
|
||||
export default function InputCheckboxCards({
|
||||
title,
|
||||
name,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
selectAndClearAll = false,
|
||||
disabled,
|
||||
required,
|
||||
help,
|
||||
hint,
|
||||
columns,
|
||||
display,
|
||||
}: {
|
||||
title?: string;
|
||||
name?: string;
|
||||
options: { name: string; value: CheckboxCardValue; color?: Colors; disabled?: boolean }[];
|
||||
value?: CheckboxCardValue[];
|
||||
onChange?: (value: CheckboxCardValue[]) => void;
|
||||
selectAndClearAll?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
help?: string;
|
||||
hint?: React.ReactNode;
|
||||
columns?: number;
|
||||
display?: "name" | "value" | "nameAndValue";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selected, setSelected] = useState<CheckboxCardValue[]>(() => (value ? [...value] : []));
|
||||
const [displayType, setDisplayType] = useState<"name" | "value" | "nameAndValue">("name");
|
||||
|
||||
useEffect(() => {
|
||||
if (display) {
|
||||
setDisplayType(display);
|
||||
} else {
|
||||
const hasNames = options.some((item) => item.name);
|
||||
if (!hasNames) {
|
||||
setDisplayType("value");
|
||||
} else {
|
||||
const namesAreDifferent = options.some((item) => item.name !== item.value.toString());
|
||||
if (namesAreDifferent) {
|
||||
setDisplayType("nameAndValue");
|
||||
} else {
|
||||
setDisplayType("name");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [display, options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value && !hasSameCheckboxCardValues(selected, value)) {
|
||||
setSelected([...value]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange && !hasSameCheckboxCardValues(selected, value)) {
|
||||
onChange(selected);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
function isChecked(value: CheckboxCardValue) {
|
||||
return selected.includes(value);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{/* selected: {JSON.stringify(selected)} */}
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div>
|
||||
{title && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
|
||||
{help && <HintTooltip text={help} />}
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectAndClearAll && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{selected.length !== options.length && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground text-xs font-medium"
|
||||
onClick={() => {
|
||||
setSelected(options.map((item) => item.value.toString()));
|
||||
}}
|
||||
>
|
||||
{t("shared.selectAll")}
|
||||
</button>
|
||||
)}
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground text-xs font-medium"
|
||||
onClick={() => {
|
||||
setSelected([]);
|
||||
}}
|
||||
>
|
||||
{t("shared.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ul
|
||||
className={clsx(
|
||||
"grid w-full grid-cols-1 gap-3",
|
||||
columns === undefined && "md:grid-cols-3",
|
||||
columns === 1 && "md:grid-cols-1",
|
||||
columns === 2 && "md:grid-cols-2",
|
||||
columns === 3 && "md:grid-cols-3",
|
||||
columns === 4 && "md:grid-cols-4",
|
||||
columns === 5 && "md:grid-cols-5",
|
||||
columns === 6 && "md:grid-cols-6",
|
||||
columns === 7 && "md:grid-cols-7",
|
||||
columns === 8 && "md:grid-cols-8",
|
||||
columns === 9 && "md:grid-cols-9",
|
||||
columns === 10 && "md:grid-cols-10",
|
||||
columns === 11 && "md:grid-cols-11",
|
||||
columns === 12 && "md:grid-cols-12"
|
||||
)}
|
||||
>
|
||||
{options.map((item) => {
|
||||
const itemDisabled = disabled || item.disabled;
|
||||
return (
|
||||
<li key={item.value}>
|
||||
<CheckboxCard
|
||||
id={`${item.name}-${item.value}`}
|
||||
name={name}
|
||||
value={String(item.value)}
|
||||
aria-label={item.name || String(item.value)}
|
||||
checked={isChecked(item.value)}
|
||||
disabled={itemDisabled}
|
||||
onCheckedChange={() => {
|
||||
if (selected.includes(item.value)) {
|
||||
setSelected(selected.filter((v) => v !== item.value));
|
||||
} else {
|
||||
setSelected([...selected, item.value]);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="block w-full min-w-0">
|
||||
<div className="flex w-full min-w-0 justify-between truncate">
|
||||
<div className="min-w-0 truncate">
|
||||
<div className={clsx("block w-full truncate text-sm", !itemDisabled ? "font-medium" : "")}>{item.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
{["nameAndValue", "value"].includes(displayType) && <div className="w-full truncate text-sm">{item.value}</div>}
|
||||
</div>
|
||||
</CheckboxCard>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import clsx from "clsx";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import { Checkbox } from "~/ui/components/Checkbox";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
title: string | ReactNode;
|
||||
value?: boolean;
|
||||
setValue?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
help?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
description?: ReactNode;
|
||||
readOnly?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export default function InputCheckboxInline({
|
||||
name,
|
||||
title,
|
||||
value,
|
||||
setValue,
|
||||
description,
|
||||
className,
|
||||
help,
|
||||
required,
|
||||
disabled = false,
|
||||
readOnly,
|
||||
autoFocus,
|
||||
}: Props) {
|
||||
// useImperativeHandle(ref, () => ({ input }));
|
||||
// const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Subframe's Checkbox is always controlled. The original field could be used uncontrolled
|
||||
// (value omitted, only setValue), so the shim holds the checked state internally and mirrors
|
||||
// both directions — keeps the public API (controlled + uncontrolled) intact.
|
||||
const [checked, setChecked] = useState(value ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== checked) {
|
||||
setChecked(value);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
// Rich label content rendered into Subframe's label slot (the original right-hand label).
|
||||
const labelContent: ReactNode = (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span>
|
||||
{title}
|
||||
{description}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</span>
|
||||
{help && <HintTooltip text={help} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "")}>
|
||||
<Checkbox
|
||||
id={name}
|
||||
name={name}
|
||||
label={labelContent}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
const isChecked = Boolean(next);
|
||||
setChecked(isChecked);
|
||||
setValue?.(isChecked);
|
||||
}}
|
||||
disabled={disabled || readOnly}
|
||||
autoFocus={autoFocus}
|
||||
className="sm:col-span-6"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import clsx from "clsx";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import HintTooltip from "../tooltips/HintTooltip";
|
||||
import { Checkbox } from "~/ui/components/Checkbox";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string | ReactNode;
|
||||
description: string | ReactNode;
|
||||
value?: boolean;
|
||||
setValue?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export default function InputCheckboxWithDescription({ name, title, value, setValue, description, className, help, disabled = false, autoFocus }: Props) {
|
||||
// Subframe's Checkbox is always controlled; keep the original internal state that also
|
||||
// supports the uncontrolled (setValue-only) usage and mirrors changes back.
|
||||
const [checked, setChecked] = useState(value ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== checked) {
|
||||
setChecked(value ?? false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setValue && value !== checked) {
|
||||
setValue(checked);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [checked]);
|
||||
|
||||
// Title + optional help tooltip rendered into Subframe's label slot.
|
||||
const labelContent: ReactNode = (
|
||||
<span className="flex items-center space-x-1">
|
||||
<span>{title}</span>
|
||||
{help && <HintTooltip text={help} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx("relative flex items-start pb-4 pt-2", className)}>
|
||||
<Checkbox
|
||||
id={name}
|
||||
name={name}
|
||||
label={labelContent}
|
||||
description={description}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => setChecked(Boolean(next))}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { RadioCardGroup } from "~/ui/components/RadioCardGroup";
|
||||
import { Label } from "../label";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
options: { name: string | React.ReactNode; value: string; description?: string }[];
|
||||
required?: boolean;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
disabled?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// Subframe migration (strangler shim):
|
||||
// Single-choice picker rendered with the Subframe design-system `RadioCardGroup` behind the
|
||||
// original `InputChoicebox` API. Public props are unchanged; call sites are not touched.
|
||||
// - `value`/`defaultValue`/`onChange` keep controlled + uncontrolled behaviour (Radix RadioGroup).
|
||||
// - Form submission still goes through the hidden <input name=...> (RadioGroup buttons are not
|
||||
// submitted natively), preserving the prior contract exactly.
|
||||
// - `columns` maps to a CSS grid on the group; default (no columns) keeps the legacy 2-up grid
|
||||
// when there are exactly two options, otherwise a single column.
|
||||
export default function InputChoicebox({ name, title, value, defaultValue, onChange, options, required, columns, disabled, style }: Props) {
|
||||
return (
|
||||
<div>
|
||||
{name && <input type="hidden" name={name} value={value} readOnly hidden />}
|
||||
{title && (
|
||||
<Label className="mb-1 block text-xs">
|
||||
{title} {required && <span className="text-red-600">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<RadioCardGroup
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onValueChange={onChange ? (value) => onChange(value) : undefined}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"grid w-full",
|
||||
columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "sm:grid-cols-3"
|
||||
: columns === 4
|
||||
? "sm:grid-cols-4"
|
||||
: options.length === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-1"
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<RadioCardGroup.RadioCard value={option.value} key={option.value} disabled={disabled} style={style} className="max-w-none">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="text-sm font-medium">{option.name}</span>
|
||||
{option.description && <span className="text-muted-foreground text-xs">{option.description}</span>}
|
||||
</div>
|
||||
</RadioCardGroup.RadioCard>
|
||||
))}
|
||||
</RadioCardGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { getColors } from "~/utils/shared/ColorUtils";
|
||||
import ColorBadge from "../badges/ColorBadge";
|
||||
import InputSelector from "./InputSelector";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
value?: string | number | undefined;
|
||||
setValue?: React.Dispatch<React.SetStateAction<string | number | undefined>>;
|
||||
className?: string;
|
||||
withColorNames?: boolean;
|
||||
withSearch?: boolean;
|
||||
selectPlaceholder?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
export default function InputColorSelector({
|
||||
name,
|
||||
title,
|
||||
value,
|
||||
setValue,
|
||||
className,
|
||||
withColorNames = true,
|
||||
withSearch = false,
|
||||
selectPlaceholder,
|
||||
required,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<InputSelector
|
||||
className={className}
|
||||
name={name ?? "color"}
|
||||
title={title ?? t("models.group.color")}
|
||||
withSearch={withSearch}
|
||||
selectPlaceholder={selectPlaceholder ?? t("models.group.color")}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
required={required}
|
||||
options={
|
||||
getColors(true).map((color) => {
|
||||
return {
|
||||
name: (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ColorBadge color={color} />
|
||||
{withColorNames && <div>{t("app.shared.colors." + Colors[color])}</div>}
|
||||
</div>
|
||||
),
|
||||
value: color,
|
||||
};
|
||||
}) ?? []
|
||||
}
|
||||
></InputSelector>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import InputCombobox from "./InputCombobox";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"shared.clear": "Clear",
|
||||
"shared.new": "New",
|
||||
"shared.noRecords": "No records",
|
||||
"shared.searchDot": "Search...",
|
||||
"shared.select": "Select",
|
||||
"shared.selectedCount": "selected",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const manyOptions = Array.from({ length: 12 }, (_, index) => ({
|
||||
value: `option-${index}`,
|
||||
name: `Option ${index}`,
|
||||
}));
|
||||
|
||||
function renderCombobox(ui: React.ReactElement) {
|
||||
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
}
|
||||
|
||||
describe("InputCombobox", () => {
|
||||
it("keeps the clear control reachable with a visible keyboard focus style", () => {
|
||||
const onClear = vi.fn();
|
||||
|
||||
renderCombobox(<InputCombobox title="Workspace" value={["option-1"]} options={[{ value: "option-1", name: "Acme workspace" }]} onClear={onClear} />);
|
||||
|
||||
const clearButton = screen.getByRole("button", { name: "Clear" });
|
||||
|
||||
expect(clearButton).toHaveClass("min-h-8", "shrink-0", "focus-visible:ring-2");
|
||||
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives the icon-only new button an accessible name and stable hit area", () => {
|
||||
const onNew = vi.fn();
|
||||
|
||||
renderCombobox(<InputCombobox title="Workspace" options={manyOptions} onNew={onNew} />);
|
||||
|
||||
const newButton = screen.getByRole("button", { name: "New" });
|
||||
|
||||
expect(newButton).toHaveClass("h-9", "w-10", "shrink-0", "focus-visible:ring-2");
|
||||
|
||||
fireEvent.click(newButton);
|
||||
|
||||
expect(onNew).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps route-based icon-only creation and the popover constrained on mobile", () => {
|
||||
renderCombobox(<InputCombobox title="Workspace" options={manyOptions} onNewRoute="/admin/workspaces/new" />);
|
||||
|
||||
const newLink = screen.getByRole("link", { name: "New" });
|
||||
|
||||
expect(newLink).toHaveAttribute("href", "/admin/workspaces/new");
|
||||
expect(newLink).toHaveClass("h-9", "w-10", "shrink-0", "focus-visible:ring-2");
|
||||
|
||||
fireEvent.click(screen.getByText("Select...").closest("button") as HTMLButtonElement);
|
||||
|
||||
expect(screen.getByRole("listbox").parentElement).toHaveClass("max-w-[calc(100vw-2rem)]", "overflow-hidden");
|
||||
expect(screen.getByRole("listbox")).toHaveClass("max-h-72", "overflow-auto");
|
||||
});
|
||||
|
||||
it("localizes collapsed multi-select counts instead of appending hard-coded English", () => {
|
||||
renderCombobox(<InputCombobox title="Workspace" value={["option-1", "option-2", "option-3"]} options={manyOptions} minDisplayCount={3} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /workspace selected/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Ref, forwardRef, useImperativeHandle, useRef, ReactNode, Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { Combobox, ComboboxButton, ComboboxOption, ComboboxOptions, Label, Transition } from "@headlessui/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 { Input } from "../input";
|
||||
import { CaretSortIcon, CheckIcon, SearchIcon, PlusIcon } from "~/components/ui/icons/vendor";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export interface RefInputCombobox {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
value?: (string | number)[];
|
||||
disabled?: boolean;
|
||||
options: { value: string | number | undefined; name?: string | ReactNode; color?: Colors; disabled?: boolean }[];
|
||||
onChange?: (value: (string | number)[]) => void;
|
||||
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;
|
||||
renderHiddenInputValue?: (item: string | number) => string;
|
||||
prefix?: string;
|
||||
onClear?: () => void;
|
||||
minDisplayCount?: number;
|
||||
}
|
||||
|
||||
function selectionKey(values: (string | number)[]) {
|
||||
return values
|
||||
.map((value) => `${typeof value}:${String(value)}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
const InputCombobox = (
|
||||
{
|
||||
name,
|
||||
title,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
onChange,
|
||||
className,
|
||||
withSearch = true,
|
||||
withLabel = true,
|
||||
withColors = false,
|
||||
selectPlaceholder,
|
||||
onNew,
|
||||
required,
|
||||
onNewRoute,
|
||||
help,
|
||||
hint,
|
||||
icon,
|
||||
borderless,
|
||||
darkMode,
|
||||
autoFocus,
|
||||
readOnly,
|
||||
renderHiddenInputValue,
|
||||
prefix,
|
||||
onClear,
|
||||
minDisplayCount = 3,
|
||||
}: Props,
|
||||
ref: Ref<RefInputCombobox>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const inputSearch = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [selected, setSelected] = useState<(string | number)[]>(value || []);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (value && selectionKey(selected) !== selectionKey(value)) {
|
||||
setSelected([...value]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange && selectionKey(selected) !== selectionKey(value ?? [])) {
|
||||
onChange(selected);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ focus }));
|
||||
function focus() {
|
||||
setTimeout(() => {
|
||||
button.current?.focus();
|
||||
button.current?.click();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!options) {
|
||||
return [];
|
||||
}
|
||||
const query = searchInput.toUpperCase();
|
||||
return options.filter((f) => f.name?.toString().toUpperCase().includes(query) || f.value?.toString().toUpperCase().includes(query));
|
||||
}, [options, searchInput]);
|
||||
|
||||
const selectedValues = useMemo(() => new Set(selected), [selected]);
|
||||
const selectedOptions = useMemo(() => options.filter((f) => f.value !== undefined && selectedValues.has(f.value)), [options, selectedValues]);
|
||||
const newLabel = String(t("shared.new"));
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-end gap-2">
|
||||
<Combobox as={Fragment} multiple value={selected} onChange={setSelected} disabled={disabled || readOnly}>
|
||||
{({ open }) => (
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* {renderHiddenInputValue && <>
|
||||
{selected?.map((item, idx) => {
|
||||
return <input key={idx} type="hidden" name={name + `[]`} value={JSON.stringify(item)} />;
|
||||
})}
|
||||
</>} */}
|
||||
|
||||
{withLabel && title && (
|
||||
<Label htmlFor={name} className="mb-1 flex min-w-0 justify-between gap-2 text-xs font-medium">
|
||||
<div className="flex min-w-0 items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
|
||||
{help && <HintTooltip text={help} />}
|
||||
</div>
|
||||
{hint}
|
||||
{onClear && selected.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="text-muted-foreground hover:text-foreground/80 focus-visible:ring-ring inline-flex min-h-8 shrink-0 items-center rounded-md px-2 text-xs focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
{t("shared.clear")}
|
||||
</button>
|
||||
) : null}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<ComboboxButton
|
||||
autoFocus={autoFocus}
|
||||
type="button"
|
||||
ref={button}
|
||||
// className={cn(
|
||||
// "focus:border-border focus:ring-ring relative w-full cursor-default rounded-md border border-border py-2 pl-3 pr-10 text-left shadow-2xs focus:outline-hidden focus:ring-1 sm:text-sm",
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "bg-background hover:bg-secondary focus:bg-secondary",
|
||||
// borderless && "border-transparent",
|
||||
// darkMode && "dark:border-border dark:bg-foreground/90"
|
||||
// )}
|
||||
className={cn(
|
||||
// Subframe Combobox field box layout: h-9, rounded-sm, border, px-3, gap-2.
|
||||
// Radix/headlessui-only kept: focus/ring, disabled, overflow, span clamp.
|
||||
"border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-sm border bg-transparent px-3 py-2 text-left text-sm whitespace-nowrap shadow-2xs focus:outline-hidden focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="h-5 w-5" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-flex min-w-0 flex-1 items-center space-x-2 truncate">
|
||||
{/* {withColors && selected && <ColorBadge color={selected?.color ?? Colors.UNDEFINED} />} */}
|
||||
<div className="truncate">
|
||||
{/* {JSON.stringify(selected)} */}
|
||||
{selected.length > 0 ? (
|
||||
<span className="truncate">
|
||||
{prefix ?? ""}
|
||||
{selectedOptions.length < minDisplayCount
|
||||
? selectedOptions.map((f) => f.name ?? f.value).join(", ")
|
||||
: t("shared.selectedCount", { count: selectedOptions.length, formattedCount: formatters.number(selectedOptions.length) })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">{selectPlaceholder ?? t("shared.select")}...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CaretSortIcon className="h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<Transition show={open} as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
|
||||
<div className="bg-background text-foreground border-border absolute z-10 mt-1 w-full max-w-[calc(100vw-2rem)] overflow-hidden rounded-sm border text-base shadow-md sm:text-sm">
|
||||
{withSearch && filteredItems.length > 10 && (
|
||||
<div className="flex min-w-0 gap-2 rounded-md p-2">
|
||||
<div className="relative flex min-w-0 grow items-stretch focus-within:z-10">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="text-muted-foreground h-5 w-5" />
|
||||
</div>
|
||||
<Input
|
||||
ref={inputSearch}
|
||||
id="search"
|
||||
autoComplete="off"
|
||||
placeholder={t("shared.searchDot")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComboboxOptions
|
||||
// onFocus={() => inputSearch.current?.focus()}
|
||||
// onBlur={() => setSearchInput("")}
|
||||
// Subframe Combobox dropdown inner list: px-1 py-1, gap-0.5 between items.
|
||||
className="flex max-h-72 flex-col gap-0.5 overflow-auto px-1 py-1 focus:outline-hidden"
|
||||
>
|
||||
{filteredItems.map((item, idx) => (
|
||||
<ComboboxOption
|
||||
key={idx}
|
||||
disabled={item.disabled}
|
||||
className={({ focus }) =>
|
||||
cn(
|
||||
// Subframe Combobox.Item layout: cursor-pointer, rounded-sm, px-2 py-2, gap-2.
|
||||
// Radix/headlessui-only kept: pr-9 (room for absolute check), relative, focus states.
|
||||
"text-foreground hover:bg-secondary hover:text-secondary-foreground relative cursor-pointer gap-2 rounded-sm py-2 pr-9 pl-2 select-none focus:outline-hidden",
|
||||
!item.disabled && focus && "bg-background text-foreground",
|
||||
!item.disabled && !focus && " ",
|
||||
item.disabled && "text-muted-foreground bg-secondary/90 cursor-not-allowed",
|
||||
darkMode && !item.disabled && focus && "dark:bg-accent-500 dark:text-black",
|
||||
darkMode && !item.disabled && !focus && "dark:text-gray-50",
|
||||
darkMode && item.disabled && "dark:text-muted-foreground cursor-not-allowed dark:bg-gray-900"
|
||||
// selected && "bg-secondary text-secondary-foreground"
|
||||
)
|
||||
}
|
||||
value={item.value}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<div className="flex min-w-0 items-center justify-between space-x-2">
|
||||
<div className="flex min-w-0 items-center space-x-2 truncate">
|
||||
{withColors && item.color !== undefined && <ColorBadge color={item.color} />}
|
||||
<div className={cn(selected ? "font-normal" : "font-normal", "truncate")}>{item.name || item.value}</div>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div className={cn(active ? "" : "", "absolute inset-y-0 right-0 flex items-center pr-4")}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{/* <svg xmlns="http://www.w3.org/2000/svg" className="text-foreground h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg> */}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
|
||||
{withSearch && filteredItems.length === 0 && <div className="text-muted-foreground px-3 py-2 text-sm">{t("shared.noRecords")}</div>}
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
{onNew && (
|
||||
<button
|
||||
aria-label={newLabel}
|
||||
title={newLabel}
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className="focus-visible:border-border focus-visible:ring-ring relative inline-flex h-9 w-10 shrink-0 items-center justify-center rounded-md 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="focus-visible:border-border focus-visible:ring-ring border-border bg-secondary hover:bg-secondary/90 text-foreground/80 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(InputCombobox);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import InputText from "./InputText";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const NovelEditor = lazy(() => import("~/modules/novel/ui/editor"));
|
||||
|
||||
interface Props {
|
||||
contentType: "wysiwyg" | "markdown";
|
||||
value: string;
|
||||
onChangeValue: (e: string) => void;
|
||||
onChangeContentType?: (e: "wysiwyg" | "markdown") => void;
|
||||
name?: string;
|
||||
className?: string;
|
||||
editorLanguage?: string;
|
||||
editorTheme?: "vs-dark" | "light";
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export default function InputContent({
|
||||
contentType,
|
||||
onChangeContentType,
|
||||
value,
|
||||
onChangeValue,
|
||||
name,
|
||||
className,
|
||||
editorLanguage = "markdown",
|
||||
editorTheme = "vs-dark",
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
{onChangeContentType && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<button type="button" onClick={() => onChangeContentType("wysiwyg")} className="text-text-neutral-tertiary text-xs hover:underline">
|
||||
<div className={clsx(contentType === "wysiwyg" ? "font-bold" : "")}>{t("components.editor.modes.wysiwyg")}</div>
|
||||
</button>
|
||||
<div>•</div>
|
||||
<button type="button" onClick={() => onChangeContentType("markdown")} className="text-text-neutral-tertiary text-xs hover:underline">
|
||||
<div className={clsx(contentType === "markdown" ? "font-bold" : "")}>{t("components.editor.modes.markdown")}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input name="contentType" value={contentType} readOnly hidden />
|
||||
{contentType === "wysiwyg" ? (
|
||||
<div className={clsx(className)}>
|
||||
{name && <input type="hidden" name={name} value={value} hidden readOnly />}
|
||||
<Suspense fallback={null}>
|
||||
<NovelEditor
|
||||
content={value}
|
||||
onChange={(e) => onChangeValue(e.html ?? "")}
|
||||
usingLocalStorage={false}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : contentType === "markdown" ? (
|
||||
<InputText
|
||||
className={clsx(className, "min-h-[500px]")}
|
||||
rows={6}
|
||||
editor="monaco"
|
||||
editorLanguage={editorLanguage}
|
||||
editorTheme={editorTheme}
|
||||
editorFontSize={14}
|
||||
name={name}
|
||||
value={value}
|
||||
setValue={(e) => onChangeValue(e.toString())}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, Fragment, ReactNode, Ref, RefObject, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import { Calendar as CalendarIcon } from "~/components/ui/icons/vendor";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
||||
import { Button } from "../button";
|
||||
import { Calendar } from "../calendar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "../input";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export interface RefInputDate {
|
||||
input: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
display?: "default" | "calendar";
|
||||
name?: string;
|
||||
title: string;
|
||||
defaultValue?: Date | undefined;
|
||||
value?: Date;
|
||||
onChange?: (date: Date) => void;
|
||||
className?: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
hint?: ReactNode;
|
||||
icon?: string;
|
||||
darkMode?: boolean;
|
||||
autoFocus?: boolean;
|
||||
classNameInput?: string;
|
||||
}
|
||||
const InputDate = (
|
||||
{
|
||||
display = "default",
|
||||
name,
|
||||
title,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
className,
|
||||
help,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
hint,
|
||||
icon,
|
||||
darkMode,
|
||||
autoFocus,
|
||||
classNameInput,
|
||||
}: Props,
|
||||
ref: Ref<RefInputDate>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
useImperativeHandle(ref, () => ({ input }));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [actualValue, setActualValue] = useState<Date | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue) {
|
||||
// setActualValue(defaultValue?.toISOString().split("T")[0]);
|
||||
setActualValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
if (date) {
|
||||
// setActualValue(date.toISOString().split("T")[0]);
|
||||
setActualValue(date);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue) {
|
||||
const date = new Date(defaultValue);
|
||||
if (date) {
|
||||
// setActualValue(date.toISOString().split("T")[0]);
|
||||
setActualValue(date);
|
||||
}
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange && actualValue) {
|
||||
let isDifferent = false;
|
||||
try {
|
||||
if (value && actualValue) {
|
||||
isDifferent = value.toISOString() !== actualValue.toISOString();
|
||||
}
|
||||
} catch {
|
||||
isDifferent = true;
|
||||
}
|
||||
if (isDifferent) {
|
||||
const date = new Date(actualValue);
|
||||
if (date) {
|
||||
onChange(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actualValue]);
|
||||
|
||||
return (
|
||||
<div className={clsx(className, !darkMode && "")}>
|
||||
<label htmlFor={name} className="flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className=" flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
|
||||
{help && <HintTooltip text={help} />}
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
<div className="relative mt-1 flex w-full rounded-md">
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="h-5 w-5" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
{display === "default" && (
|
||||
<Input
|
||||
ref={input}
|
||||
type="date"
|
||||
id={name}
|
||||
name={name}
|
||||
required={required}
|
||||
value={actualValue?.toISOString().split("T")[0]}
|
||||
onChange={(e) => {
|
||||
const date = new Date(e.target.value);
|
||||
if (date) {
|
||||
setActualValue(date);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || readOnly}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
className={cn((disabled || readOnly) && "bg-secondary/90 cursor-not-allowed", icon && "pl-10", classNameInput)}
|
||||
/>
|
||||
)}
|
||||
{display === "calendar" && (
|
||||
<Fragment>
|
||||
{name && (
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={actualValue?.toISOString().split("T")[0]}
|
||||
required={required}
|
||||
disabled={actualValue === undefined}
|
||||
hidden
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={clsx("w-full justify-start text-left font-normal", !actualValue && "text-muted-foreground")}
|
||||
disabled={disabled || readOnly}
|
||||
autoFocus={autoFocus}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{actualValue ? formatters.date(actualValue) : t("components.date.pick")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar mode="single" selected={actualValue} onSelect={(date) => setActualValue(date)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default forwardRef(InputDate);
|
||||
@@ -0,0 +1,283 @@
|
||||
import * as React from "react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { parse } from "date-fns";
|
||||
import { CalendarIcon, ChevronDownIcon, XIcon } from "~/components/ui/icons/vendor";
|
||||
import { Button } from "../button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Calendar } from "../calendar";
|
||||
import { Input } from "../input";
|
||||
import { Tabs, TabsContent } from "../tabs";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
type Props = {
|
||||
name?: string;
|
||||
date: Date | undefined | null;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
dirty?: boolean;
|
||||
canClear?: boolean;
|
||||
className?: string;
|
||||
closeOnSelect?: boolean;
|
||||
showInput?: boolean;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
};
|
||||
|
||||
export function InputDatePickerDeprecated({
|
||||
name,
|
||||
date,
|
||||
setDate,
|
||||
dirty,
|
||||
canClear = true,
|
||||
className,
|
||||
closeOnSelect = false,
|
||||
showInput = false,
|
||||
placeholder,
|
||||
required,
|
||||
disabled,
|
||||
autoFocus,
|
||||
style,
|
||||
fromYear,
|
||||
toYear,
|
||||
}: Props) {
|
||||
const formatters = useLocaleFormatters();
|
||||
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [activeTab, setActiveTab] = React.useState("calendar");
|
||||
const [month, setMonth] = React.useState<Date | undefined>(date || undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (date) {
|
||||
try {
|
||||
const parsedDate = new Date(date);
|
||||
if (!isNaN(parsedDate.getTime())) {
|
||||
setInputValue(parsedDate.toISOString().split("T")[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid date:", date);
|
||||
}
|
||||
} else {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [date]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setInputValue(value);
|
||||
if (value) {
|
||||
// Parse the date string in local timezone
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const newDate = new Date(year, month - 1, day);
|
||||
if (!isNaN(newDate.getTime())) {
|
||||
setDate(newDate);
|
||||
}
|
||||
} else {
|
||||
setDate(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const displayDate = React.useMemo(() => {
|
||||
if (!date) return null;
|
||||
try {
|
||||
const parsedDate = new Date(date);
|
||||
if (!isNaN(parsedDate.getTime())) {
|
||||
return formatters.date(parsedDate, { dateStyle: "long" });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid date:", date);
|
||||
}
|
||||
return null;
|
||||
}, [date, formatters]);
|
||||
|
||||
return (
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||||
{name && <input type="hidden" name={name} value={date ? new Date(date).toISOString() : ""} readOnly hidden />}
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative truncate">
|
||||
<Button
|
||||
type="button"
|
||||
variant={"outline"}
|
||||
className={cn("w-full justify-start truncate text-left font-normal", !date && "text-muted-foreground", className)}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
style={style}
|
||||
>
|
||||
<CalendarIcon className="size-3" />
|
||||
{displayDate || <span>{i18nT("components.date.pick")}</span>}
|
||||
</Button>
|
||||
{canClear && date && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={"ghost"}
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDate(undefined);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full gap-0">
|
||||
<TabsContent value="calendar" className="w-[250px] p-0">
|
||||
<div className="px-3 pt-3">
|
||||
<Input
|
||||
type="date"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
className="mb-2"
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsCalendarOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date ? new Date(date) : undefined}
|
||||
month={month || date || undefined}
|
||||
onMonthChange={(e) => {
|
||||
setMonth(e);
|
||||
}}
|
||||
onSelect={(e: Date | undefined) => {
|
||||
setDate(e);
|
||||
if (closeOnSelect) {
|
||||
setIsCalendarOpen(false);
|
||||
}
|
||||
}}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
fromYear={fromYear}
|
||||
toYear={toYear}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="w-[250px] p-3">
|
||||
<Input
|
||||
type="date"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
className="mb-2"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsCalendarOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InputDatePicker(props: Props) {
|
||||
const formatters = useLocaleFormatters();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [date, setDate] = React.useState<Date | undefined | null>(props.date ? new Date(props.date) : undefined);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id="date"
|
||||
className={cn("justify-between bg-transparent font-normal hover:bg-transparent", props.className)}
|
||||
dirty={props.dirty}
|
||||
disabled={props.disabled}
|
||||
style={props.style}
|
||||
>
|
||||
{date ? formatters.date(date, { dateStyle: "long" }) : props.placeholder || i18nT("components.date.select")}
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="bg-background w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date || undefined}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
setDate(date);
|
||||
setOpen(false);
|
||||
props.setDate(date);
|
||||
}}
|
||||
fromYear={props.fromYear}
|
||||
toYear={props.toYear}
|
||||
/>
|
||||
{/* <div className="px-2 pb-2">
|
||||
<ManualInputDatePicker date={date} onChange={setDate} />
|
||||
</div> */}
|
||||
<div className="flex items-center justify-center gap-2 px-3 pb-2">
|
||||
{/* Today button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDate(new Date());
|
||||
props.setDate(new Date());
|
||||
}}
|
||||
className="w-1/2"
|
||||
>
|
||||
{i18nT("shared.dateRange.today")}
|
||||
</Button>
|
||||
{/* Clear button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDate(null);
|
||||
props.setDate(undefined);
|
||||
}}
|
||||
disabled={!date}
|
||||
className="w-1/2"
|
||||
>
|
||||
{i18nT("shared.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// function ManualInputDatePicker({
|
||||
// date,
|
||||
// onChange,
|
||||
// inputFormat = "mm/dd/yyyy",
|
||||
// }: {
|
||||
// date: Date | undefined;
|
||||
// onChange: (date: Date | undefined) => void;
|
||||
// inputFormat?: string;
|
||||
// }) {
|
||||
// const [inputValue, setInputValue] = React.useState(date ? format(date, inputFormat) : "");
|
||||
|
||||
// function tryParseDate(value: string) {
|
||||
// setInputValue(value);
|
||||
// try {
|
||||
// const parsedDate = parse(value, inputFormat, new Date());
|
||||
// if (!isNaN(parsedDate.getTime())) {
|
||||
// onChange(parsedDate);
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.error("Invalid date:", value);
|
||||
// }
|
||||
// }
|
||||
|
||||
// return <Input value={inputValue} onChange={(e) => tryParseDate(e.target.value)} placeholder={inputFormat} />;
|
||||
// }
|
||||
@@ -0,0 +1,305 @@
|
||||
import { Transition } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { FormEvent, Fragment, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Form, useSearchParams } from "react-router";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { updateItemByIdx } from "~/utils/shared/ObjectUtils";
|
||||
import FilterEmptyIcon from "../icons/FilterEmptyIcon";
|
||||
import FilterFilledIcon from "../icons/FilterFilledIcon";
|
||||
import InputCheckboxInline from "./InputCheckboxInline";
|
||||
import InputSearch from "./InputSearch";
|
||||
import InputSelect from "./InputSelect";
|
||||
import { Input } from "../input";
|
||||
import { Button } from "../button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { PopoverClose } from "@radix-ui/react-popover";
|
||||
import { translateLabel } from "~/utils/i18n/translateLabel";
|
||||
// import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "../dropdown-menu";
|
||||
|
||||
export type FilterDto = {
|
||||
name: string;
|
||||
title: string;
|
||||
options?: { name: string; value: string; color?: Colors }[];
|
||||
hideSearch?: boolean;
|
||||
isBoolean?: boolean;
|
||||
fallbackValue?: string;
|
||||
};
|
||||
|
||||
export type FilterValueDto = FilterDto & {
|
||||
selected: boolean;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
filters: FilterDto[];
|
||||
withSearch?: boolean;
|
||||
withName?: boolean;
|
||||
size?: "xs" | "sm" | "default" | "lg";
|
||||
position?: "left" | "right";
|
||||
// saveEntityFilters?: {
|
||||
// entity: EntityWithDetails;
|
||||
// currentView: EntityView | null;
|
||||
// onSaveFilters: (filters: FilterValueDto[]) => void;
|
||||
// };
|
||||
}
|
||||
|
||||
export default function InputFilters({ filters, withSearch = true, size = "default", position = "right" }: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [items, setItems] = useState<FilterValueDto[]>([]);
|
||||
const [filteredItems, setFilteredItems] = useState<number>(0);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const items: FilterValueDto[] = filters.map((item) => {
|
||||
const value = searchParams.get(item.name) ?? undefined;
|
||||
return {
|
||||
...item,
|
||||
selected: value !== undefined || item.fallbackValue ? true : false,
|
||||
value,
|
||||
};
|
||||
});
|
||||
setItems(items);
|
||||
setSearchInput(searchParams.get("q") ?? "");
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
const appliedFilters: FilterValueDto[] = [];
|
||||
items.forEach((item) => {
|
||||
const value = searchParams.get(item.name) ?? item.fallbackValue;
|
||||
if (value) {
|
||||
appliedFilters.push(item);
|
||||
}
|
||||
});
|
||||
if (withSearch) {
|
||||
setFilteredItems(appliedFilters.length + (searchParams.get("q") ? 1 : 0));
|
||||
} else {
|
||||
setFilteredItems(appliedFilters.length);
|
||||
}
|
||||
}, [items, searchInput, searchParams, withSearch]);
|
||||
|
||||
// useEffect(() => {
|
||||
// const searchInput = searchParams.get("q");
|
||||
// setSearchInput(searchInput ?? "");
|
||||
|
||||
// const newItems = items;
|
||||
// items.forEach((item, idx) => {
|
||||
// const valueInParam = searchParams.get(item.name);
|
||||
// if (valueInParam) {
|
||||
// newItems[idx].selected = true;
|
||||
// newItems[idx].value = valueInParam.toString();
|
||||
// } else {
|
||||
// newItems[idx].selected = false;
|
||||
// newItems[idx].value = undefined;
|
||||
// }
|
||||
// });
|
||||
// setItems(newItems);
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [searchParams]);
|
||||
|
||||
function onClear() {
|
||||
setOpened(false);
|
||||
|
||||
items.forEach((item) => {
|
||||
searchParams.delete(item.name);
|
||||
item.selected = false;
|
||||
item.value = undefined;
|
||||
});
|
||||
setItems(items);
|
||||
|
||||
searchParams.delete("page");
|
||||
searchParams.delete("q");
|
||||
setSearchInput("");
|
||||
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
|
||||
function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
items.forEach((item) => {
|
||||
if (item.selected && item.value) {
|
||||
searchParams.set(item.name, item.value?.toString() ?? "");
|
||||
} else {
|
||||
searchParams.delete(item.name);
|
||||
}
|
||||
});
|
||||
if (searchInput) {
|
||||
searchParams.set("q", searchInput);
|
||||
} else {
|
||||
searchParams.delete("q");
|
||||
}
|
||||
searchParams.delete("page");
|
||||
setSearchParams(searchParams);
|
||||
setOpened(false);
|
||||
}
|
||||
|
||||
// const clickOutside = useOuterClick(() => setOpened(false));
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" aria-label={t("shared.filters")} size={size} onClick={() => setOpened(!opened)} variant="outline" className="relative z-0 inline-flex gap-2">
|
||||
<div className={clsx("relative inline-flex items-center gap-2", filteredItems === 0 ? "rounded-md" : "rounded-l-md")}>
|
||||
<div>
|
||||
{filteredItems === 0 && <FilterEmptyIcon className="h-3 w-3" />}
|
||||
{filteredItems > 0 && <FilterFilledIcon className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="hidden sm:block">{t("shared.filters")}</div>
|
||||
</div>
|
||||
{filteredItems > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
"border-border-default relative -ml-px inline-flex items-center rounded-full border px-1.5 text-xs font-medium",
|
||||
filteredItems > 0 ? "bg-surface-neutral-secondary text-text-neutral-primary" : "bg-surface-neutral-primary text-text-neutral-tertiary"
|
||||
)}
|
||||
>
|
||||
{filteredItems}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
method="get"
|
||||
className={cn(
|
||||
"bg-surface-neutral-primary divide-border-default ring-opacity-5 z-40 divide-y rounded-md shadow-2xl ring-1 ring-black focus:outline-none",
|
||||
position === "right" ? "right-0" : "left-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-2 text-sm">
|
||||
<Button type="button" variant="outline" onClick={onClear}>
|
||||
{t("shared.reset")}
|
||||
</Button>
|
||||
<div className="font-bold">{t("shared.filters")}</div>
|
||||
<Button type="submit" variant="outline">
|
||||
{t("shared.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-surface-neutral-primary divide-border-default divide-y rounded-b-md text-sm">
|
||||
{withSearch && (
|
||||
<div className="p-2">
|
||||
<InputSearch value={searchInput} setValue={setSearchInput} placeholder={t("shared.searchAll") + "..."} />
|
||||
</div>
|
||||
)}
|
||||
{items.map((filter, idx) => {
|
||||
return (
|
||||
<div key={filter.name} className="divide-border divide-y">
|
||||
<div className="divide-y divide-gray-300 px-2 py-2">
|
||||
<InputCheckboxInline
|
||||
name={"filter-" + filter.name}
|
||||
title={translateLabel(t, i18n, filter.title)}
|
||||
value={filter.selected}
|
||||
setValue={(e) => {
|
||||
updateItemByIdx(items, setItems, idx, {
|
||||
selected: Boolean(e),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{filter.selected && (
|
||||
<div className="bg-surface-neutral-secondary px-2 py-1">
|
||||
{filter.options && filter.options.length > 0 ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<InputSelect
|
||||
// withSearch={!filter.hideSearch}
|
||||
name={filter.name}
|
||||
title={""}
|
||||
// withColors={true}
|
||||
placeholder={t("shared.select") + "..."}
|
||||
options={filter.options.map((item) => {
|
||||
return {
|
||||
value: item.value,
|
||||
name: translateLabel(t, i18n, item.name),
|
||||
color: item.color,
|
||||
};
|
||||
})}
|
||||
value={filter.value ?? filter.fallbackValue}
|
||||
withLabel={false}
|
||||
setValue={(e) => {
|
||||
updateItemByIdx(items, setItems, idx, {
|
||||
value: e,
|
||||
});
|
||||
}}
|
||||
className="w-full pb-1"
|
||||
selectClassName="bg-surface-neutral-primary"
|
||||
/>
|
||||
</div>
|
||||
) : filter.isBoolean ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<InputSelect
|
||||
// withSearch={!filter.hideSearch}
|
||||
name={filter.name}
|
||||
title={""}
|
||||
placeholder={t("shared.select") + "..."}
|
||||
options={[
|
||||
{ name: t("shared.yes"), value: "true" },
|
||||
{ name: t("shared.no"), value: "false" },
|
||||
]}
|
||||
value={filter.value ?? filter.fallbackValue ?? ""}
|
||||
withLabel={false}
|
||||
setValue={(e) => {
|
||||
updateItemByIdx(items, setItems, idx, {
|
||||
value: e,
|
||||
});
|
||||
}}
|
||||
className="bg-surface-neutral-primary w-full pb-1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-text-neutral-tertiary flex-shrink-0 truncate">{t("entities.conditions.contains")}</div>
|
||||
<Input
|
||||
type="text"
|
||||
name={filter.name}
|
||||
autoComplete="off"
|
||||
className="focus:border-brand-brand-border-solid-default focus:shadow-focus bg-surface-neutral-primary border-border-strong block w-full min-w-0 flex-1 rounded-md p-1 text-sm"
|
||||
required
|
||||
value={filter.value ?? filter.fallbackValue ?? ""}
|
||||
onChange={(e) => {
|
||||
updateItemByIdx(items, setItems, idx, {
|
||||
value: e.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* {saveEntityFilters && (
|
||||
<div className="divide-y divide-border">
|
||||
<div className="flex justify-end space-x-2 px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
saveEntityFilters?.onSaveFilters(items.filter((f) => f.value));
|
||||
setOpened(false);
|
||||
}}
|
||||
className={clsx(
|
||||
"rounded-md border border-border px-2 py-0.5",
|
||||
false
|
||||
? " bg-secondary/90 text-muted-foreground"
|
||||
: "bg-background text-muted-foreground hover:bg-secondary hover:text-muted-foreground focus:z-10 focus:border-border focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
)}
|
||||
>
|
||||
{saveEntityFilters.currentView ? <div>{t("shared.updateView")}</div> : <div>{t("shared.createView")}</div>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</Form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { forwardRef, Ref, RefObject, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputText from "./InputText";
|
||||
import UploadDocument from "../uploaders/UploadDocument";
|
||||
import clsx from "clsx";
|
||||
import { sanitizeIconSvg } from "~/utils/html-sanitizer";
|
||||
import { XIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export interface RefInputImage {
|
||||
input: RefObject<HTMLInputElement> | RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
type WithDefaultValue = { defaultValue: string | undefined };
|
||||
type WithValueAndSetValue = { value: string | undefined; setValue: React.Dispatch<React.SetStateAction<string>> };
|
||||
|
||||
type Props = (WithDefaultValue | WithValueAndSetValue) & {
|
||||
name?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
size?: "small" | "medium" | "large";
|
||||
square?: boolean;
|
||||
required?: boolean;
|
||||
hint?: React.ReactNode;
|
||||
accept?: string;
|
||||
placeholder?: string;
|
||||
opened?: boolean;
|
||||
description?: string;
|
||||
className?: string;
|
||||
};
|
||||
const InputImage = (props: Props, ref: Ref<RefInputImage>) => {
|
||||
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 size = props.size ?? "large";
|
||||
const square = props.square ?? false;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [showUploadImage, setShowUploadImage] = useState((props.opened && (!value || defaultValue)) ?? false);
|
||||
const [image, setImage] = useState(value || defaultValue || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (setValue && value !== image) {
|
||||
setValue(image);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [image]);
|
||||
|
||||
const imageClassName = clsx(
|
||||
"mx-auto",
|
||||
square ? "object-cover" : "object-contain",
|
||||
size === "small" && "h-8",
|
||||
size === "medium" && "h-16",
|
||||
size === "large" && "h-32",
|
||||
square && clsx(size === "small" && "w-8", size === "medium" && "w-16", size === "large" && "w-32")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-1", props.className)}>
|
||||
<input name={props.name} type="hidden" value={image} readOnly hidden />
|
||||
|
||||
<label htmlFor={props.name} className="flex items-baseline justify-between space-x-2 truncate text-xs font-medium">
|
||||
<div className="flex shrink-0 items-center space-x-1 truncate">
|
||||
<div className="flex space-x-1 truncate">
|
||||
<div className="truncate">{props.title}</div>
|
||||
{props.required && props.title && <div className="ml-1 text-red-600">*</div>}
|
||||
</div>
|
||||
</div>
|
||||
{props.hint && <div>{props.hint}</div>}
|
||||
</label>
|
||||
|
||||
{/* {!showUploadImage && ( */}
|
||||
<InputText
|
||||
name={props.name}
|
||||
required={props.required}
|
||||
value={image}
|
||||
setValue={setImage}
|
||||
readOnly={image.startsWith("data:image")}
|
||||
placeholder={props.placeholder ?? t("components.input.image.urlPlaceholder")}
|
||||
withLabel={false}
|
||||
button={
|
||||
<div className="absolute inset-y-0 right-0 flex py-1.5 pr-1.5 ">
|
||||
<div className="border-border-default text-text-neutral-tertiary bg-surface-neutral-primary inline-flex items-center rounded border px-2 font-sans text-sm font-medium">
|
||||
{!image ? (
|
||||
<button type="button" onClick={() => setShowUploadImage(true)}>
|
||||
{t("shared.upload")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.disabled}
|
||||
onClick={() => {
|
||||
setImage("");
|
||||
setShowUploadImage(true);
|
||||
}}
|
||||
>
|
||||
{t("shared.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* )} */}
|
||||
{showUploadImage && (
|
||||
<UploadDocument
|
||||
name={props.name + "-file"}
|
||||
disabled={props.disabled}
|
||||
className="col-span-12"
|
||||
accept={props.accept || "image/png, image/jpg, image/jpeg"}
|
||||
// description={t("models.post.image")}
|
||||
description={props.description}
|
||||
onDropped={(e) => {
|
||||
setImage(e);
|
||||
setShowUploadImage(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!showUploadImage && image && (
|
||||
<div className="col-span-12">
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-surface-neutral-primary border-border-default relative overflow-hidden rounded-lg border",
|
||||
!square && "p-4",
|
||||
square && size === "small" && "w-8",
|
||||
square && size === "medium" && "w-16",
|
||||
square && size === "large" && "w-32"
|
||||
)}
|
||||
>
|
||||
{/* <button
|
||||
type="button"
|
||||
className="absolute inset-0 transition-colors duration-200"
|
||||
onClick={() => setShowUploadImage(true)}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center text-sm font-medium text-white">{t("shared.upload")}</div>
|
||||
</button> */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 bg-black/50 p-1 transition-colors duration-200 hover:bg-black/75"
|
||||
onClick={() => setImage("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
{image.startsWith("<svg") ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeIconSvg(image, imageClassName) }} />
|
||||
) : (
|
||||
<img className={imageClassName} src={image} alt={props.title} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default forwardRef(InputImage);
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { RowMedia } from "@toolctrl/db/prisma-browser";
|
||||
import clsx from "clsx";
|
||||
import { lazy, type ReactNode, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MediaDto } from "~/application/dtos/entities/MediaDto";
|
||||
import type { FileBase64 } from "~/application/dtos/shared/FileBase64";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import MediaItem from "~/components/ui/uploaders/MediaItem";
|
||||
import UploadDocuments from "~/components/ui/uploaders/UploadDocument";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import { updateItemByIdx } from "~/utils/shared/ObjectUtils";
|
||||
|
||||
const PreviewMediaModal = lazy(() => import("~/components/ui/media/PreviewMediaModal"));
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
title?: string;
|
||||
initialMedia?: RowMedia[] | MediaDto[] | undefined;
|
||||
disabled?: boolean;
|
||||
onSelected?: (item: MediaDto[]) => void;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
accept?: string;
|
||||
hint?: ReactNode;
|
||||
help?: string;
|
||||
icon?: string;
|
||||
maxSize?: number;
|
||||
uploadText?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export default function InputMedia({
|
||||
name,
|
||||
title,
|
||||
initialMedia,
|
||||
disabled,
|
||||
onSelected,
|
||||
className,
|
||||
readOnly,
|
||||
required,
|
||||
max,
|
||||
accept,
|
||||
hint,
|
||||
help,
|
||||
maxSize,
|
||||
uploadText,
|
||||
autoFocus,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [items, setItems] = useState<MediaDto[]>(initialMedia ?? []);
|
||||
const [selectedItem, setSelectedItem] = useState<MediaDto>();
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelected) {
|
||||
onSelected(items);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items]);
|
||||
|
||||
function deleteMedia(idx: number) {
|
||||
setItems(items.filter((_f, index) => index !== idx));
|
||||
}
|
||||
|
||||
function onDroppedFiles(e: FileBase64[]) {
|
||||
setError(undefined);
|
||||
if (max) {
|
||||
if (e.length + items.length > max) {
|
||||
setError(t("components.media.errors.maxFilesExceeded", { max }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (maxSize) {
|
||||
const bytesToMegaBytes = (bytes: number) => bytes / (1024 * 1024);
|
||||
const found = e.find((f) => bytesToMegaBytes(f.file.size) > maxSize);
|
||||
if (found) {
|
||||
setError(
|
||||
t("components.media.errors.maxSizeExceeded", {
|
||||
maxSize: formatters.number(maxSize, { maximumFractionDigits: 2 }),
|
||||
foundSize: formatters.number(bytesToMegaBytes(found.file.size), { maximumFractionDigits: 2 }),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (accept?.includes(".")) {
|
||||
const acceptedFileExtensions = accept.split(",");
|
||||
const invalidFiles = e.map((f) => {
|
||||
let foundExtension = "";
|
||||
acceptedFileExtensions
|
||||
.filter((f) => f)
|
||||
.forEach((element) => {
|
||||
if (f.file.name.toLowerCase().endsWith(element.toLowerCase())) {
|
||||
foundExtension = element;
|
||||
}
|
||||
});
|
||||
if (!foundExtension) {
|
||||
return f;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (invalidFiles.find((f) => f !== null)) {
|
||||
setError(t("components.media.errors.invalidFileType", { accept }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newAttachments: MediaDto[] = [...items];
|
||||
e.forEach(({ file, base64 }) => {
|
||||
newAttachments.push({
|
||||
title: file.name.split(".").slice(0, -1).join("."),
|
||||
file: base64,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
// _file: file,
|
||||
});
|
||||
});
|
||||
setItems(newAttachments);
|
||||
}
|
||||
|
||||
function preview(item: MediaDto) {
|
||||
setSelectedItem(item);
|
||||
}
|
||||
|
||||
function download(item: MediaDto) {
|
||||
const downloadLink = document.createElement("a");
|
||||
downloadLink.href = item.publicUrl ?? item.file;
|
||||
downloadLink.download = item.name;
|
||||
downloadLink.click();
|
||||
}
|
||||
|
||||
function isDisabled() {
|
||||
return disabled || readOnly;
|
||||
}
|
||||
return (
|
||||
<div className={clsx(className, "")}>
|
||||
<label htmlFor={name} className="flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className=" flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title && (
|
||||
<>
|
||||
{t(title)}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="">{help && <HintTooltip text={help} />}</div>
|
||||
</div>
|
||||
<div>{error && <span className="text-red-600">{error}</span>}</div>
|
||||
{hint}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
{/* <UploadDocuments onDropped={onDroppedFile} /> */}
|
||||
{!isDisabled() && items.length === 0 && (
|
||||
<UploadDocuments
|
||||
name={name}
|
||||
disabled={isDisabled() || (max !== undefined && max <= items.length)}
|
||||
multiple={true}
|
||||
onDroppedFiles={onDroppedFiles}
|
||||
accept={accept}
|
||||
uploadText={uploadText}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDisabled() && items.length === 0 && (
|
||||
<div>
|
||||
{/* Subframe FileUpload empty dropzone hull: rounded-lg, dashed border-strong, neutral-primary fill, px-6 py-8. */}
|
||||
<div className="border-border-strong bg-surface-neutral-primary flex w-full cursor-not-allowed items-center justify-center rounded-lg border border-dashed px-6 py-8">
|
||||
<div className="text-text-neutral-secondary text-xs">
|
||||
<p>{t("shared.files.noFilesUploaded")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
return <input key={idx} type="hidden" name={name + `[]`} value={JSON.stringify(item)} />;
|
||||
})}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, idx) => {
|
||||
return (
|
||||
<MediaItem
|
||||
key={idx}
|
||||
item={item}
|
||||
onChangeTitle={(title) =>
|
||||
updateItemByIdx(items, setItems, idx, {
|
||||
title,
|
||||
name: title + "." + item.name.split(".").slice(-1)[0],
|
||||
})
|
||||
}
|
||||
onDelete={() => deleteMedia(idx)}
|
||||
readOnly={isDisabled()}
|
||||
onDownload={() => download(item)}
|
||||
onPreview={item.type.includes("pdf") || item.type.includes("image") ? () => preview(item) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div></div>
|
||||
</div>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
{isDisabled() && items.filter((f) => f.type.includes("image")).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="grid gap-1 overflow-auto">
|
||||
{items
|
||||
.filter((f) => f.type.includes("image"))
|
||||
.map((item) => {
|
||||
return (
|
||||
<div key={item.name} className="space-y-1">
|
||||
<label className="text-text-neutral-tertiary text-xs">{item.title}</label>
|
||||
{/* Subframe-style preview hull: solid border-default, neutral-primary fill, rounded-md. */}
|
||||
<img
|
||||
className="border-border-default bg-surface-neutral-primary h-12 w-full rounded-md border border-solid object-contain p-1 shadow-2xs"
|
||||
src={item.publicUrl ?? item.file}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedItem && (
|
||||
<Suspense fallback={null}>
|
||||
<PreviewMediaModal item={selectedItem} onClose={() => setSelectedItem(undefined)} onDownload={() => download(selectedItem)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useRef, useEffect, KeyboardEvent, ReactNode, RefObject, Ref, forwardRef, useImperativeHandle } from "react";
|
||||
import clsx from "clsx";
|
||||
import HintTooltip from "../tooltips/HintTooltip";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RowValueMultipleDto } from "~/application/dtos/entities/RowValueMultipleDto";
|
||||
import { SeparatorFormatType } from "~/utils/shared/SeparatorUtils";
|
||||
import { Input } from "../input";
|
||||
|
||||
export interface RefInputMultiText {
|
||||
input: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
value?: RowValueMultipleDto[];
|
||||
className?: string;
|
||||
classNameBg?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
autoComplete?: string;
|
||||
translationParams?: string[];
|
||||
placeholder?: string;
|
||||
pattern?: string;
|
||||
rows?: number;
|
||||
button?: ReactNode;
|
||||
lowercase?: boolean;
|
||||
uppercase?: boolean;
|
||||
darkMode?: boolean;
|
||||
hint?: ReactNode;
|
||||
help?: string;
|
||||
icon?: string;
|
||||
onBlur?: () => void;
|
||||
borderless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
separator?: SeparatorFormatType;
|
||||
}
|
||||
|
||||
const InputMultiText = (
|
||||
{
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
value,
|
||||
className,
|
||||
classNameBg,
|
||||
help,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
minLength,
|
||||
maxLength,
|
||||
autoComplete,
|
||||
placeholder,
|
||||
pattern,
|
||||
hint,
|
||||
rows,
|
||||
button,
|
||||
lowercase,
|
||||
uppercase,
|
||||
darkMode,
|
||||
icon,
|
||||
onBlur,
|
||||
borderless,
|
||||
autoFocus,
|
||||
separator = ",",
|
||||
}: Props,
|
||||
ref: Ref<RefInputMultiText>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useImperativeHandle(ref, () => ({ input }));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const [actualValue, setActualValue] = useState<(string | number)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const selection = value?.map((f) => f.value) ?? [];
|
||||
if (selection.sort().join(",") !== actualValue.sort().join(",")) {
|
||||
setActualValue(selection);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (separator === e.key) {
|
||||
e.preventDefault();
|
||||
if (inputValue.trim() !== "") {
|
||||
setActualValue?.((prevTags) => [...prevTags, inputValue.trim()]);
|
||||
setInputValue("");
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" && inputValue === "") {
|
||||
setActualValue?.((prevTags) => prevTags.slice(0, prevTags.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
setActualValue?.((prevTags) => prevTags.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(className, !darkMode && "")}>
|
||||
{withLabel && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
<div className="flex space-x-1 truncate">
|
||||
<div className="truncate">{title}</div>
|
||||
{required && title && <div className="ml-1 text-red-600">*</div>}
|
||||
</div>
|
||||
<div className="">{help && <HintTooltip text={help} />}</div>
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{actualValue.map((item, idx) => {
|
||||
return (
|
||||
<input
|
||||
key={idx}
|
||||
type="hidden"
|
||||
name={name + `[]`}
|
||||
value={JSON.stringify({
|
||||
value: item,
|
||||
order: idx,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={clsx("relative flex w-full rounded-md")}>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="text-text-neutral-tertiary h-5 w-5" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-wrap items-center">
|
||||
{actualValue?.map((tag, index) => (
|
||||
<div key={index} className="border-border-default bg-surface-neutral-secondary m-0.5 flex items-center rounded border px-2 py-2 text-sm">
|
||||
<span>{tag}</span>
|
||||
{!disabled && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-text-neutral-tertiary focus:text-text-neutral-primary ml-2 shrink-0"
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!disabled && !readOnly && (
|
||||
<Input
|
||||
type="text"
|
||||
ref={input}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
if (lowercase) {
|
||||
setInputValue(e.target.value.toLowerCase());
|
||||
} else if (uppercase) {
|
||||
setInputValue(e.target.value.toUpperCase());
|
||||
} else {
|
||||
setInputValue(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("shared.typeAndPressTo", { 0: separator, 1: t("shared.add").toLowerCase() })}
|
||||
id={name}
|
||||
autoComplete={autoComplete}
|
||||
// required={required}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
// defaultValue={value}
|
||||
onBlur={() => {
|
||||
if (inputValue.trim() !== "") {
|
||||
setActualValue?.((prevTags) => [...prevTags, inputValue.trim()]);
|
||||
setInputValue("");
|
||||
}
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
pattern={pattern !== "" && pattern !== undefined ? pattern : undefined}
|
||||
autoFocus={autoFocus}
|
||||
className={clsx(
|
||||
"m-0.5 min-w-0 flex-1",
|
||||
icon && "rounded-md pl-10"
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// classNameBg,
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "hover:bg-secondary focus:bg-secondary",
|
||||
// borderless && "border-transparent"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(InputMultiText);
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useRef, useEffect, KeyboardEvent, ReactNode, RefObject, Ref, forwardRef, useImperativeHandle } from "react";
|
||||
import clsx from "clsx";
|
||||
import HintTooltip from "../tooltips/HintTooltip";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RowValueMultipleDto } from "~/application/dtos/entities/RowValueMultipleDto";
|
||||
import { SeparatorFormatType } from "~/utils/shared/SeparatorUtils";
|
||||
import { Input } from "../input";
|
||||
|
||||
export interface RefInputMultiText {
|
||||
input: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
className?: string;
|
||||
classNameBg?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
autoComplete?: string;
|
||||
translationParams?: string[];
|
||||
placeholder?: string;
|
||||
pattern?: string;
|
||||
rows?: number;
|
||||
button?: ReactNode;
|
||||
lowercase?: boolean;
|
||||
uppercase?: boolean;
|
||||
darkMode?: boolean;
|
||||
hint?: ReactNode;
|
||||
help?: string;
|
||||
icon?: string;
|
||||
onBlur?: () => void;
|
||||
borderless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
separator?: SeparatorFormatType;
|
||||
}
|
||||
|
||||
const InputMultipleText = (
|
||||
{
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
classNameBg,
|
||||
help,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
minLength,
|
||||
maxLength,
|
||||
autoComplete,
|
||||
placeholder,
|
||||
pattern,
|
||||
hint,
|
||||
rows,
|
||||
button,
|
||||
lowercase,
|
||||
uppercase,
|
||||
darkMode,
|
||||
icon,
|
||||
onBlur,
|
||||
borderless,
|
||||
autoFocus,
|
||||
separator = ",",
|
||||
}: Props,
|
||||
ref: Ref<RefInputMultiText>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useImperativeHandle(ref, () => ({ input }));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (separator === e.key || separator === ",") {
|
||||
e.preventDefault();
|
||||
if (inputValue.trim() !== "") {
|
||||
onChange([...value, inputValue.trim()]);
|
||||
setInputValue("");
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" && inputValue === "") {
|
||||
onChange(value.slice(0, value.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
onChange(value.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(className, !darkMode && "")}>
|
||||
{withLabel && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
<div className="flex space-x-1 truncate">
|
||||
<div className="truncate">{title}</div>
|
||||
{required && title && <div className="ml-1 text-red-600">*</div>}
|
||||
</div>
|
||||
<div className="">{help && <HintTooltip text={help} />}</div>
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{value.map((item, idx) => {
|
||||
return <input key={idx} type="hidden" name={name} value={item} />;
|
||||
})}
|
||||
|
||||
<div className={clsx("relative flex w-full rounded-md")}>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="text-text-neutral-tertiary h-5 w-5" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-wrap items-center">
|
||||
{value.map((tag, index) => (
|
||||
<div key={index} className="border-border-default bg-surface-neutral-secondary m-0.5 flex items-center rounded border px-2 py-2 text-sm">
|
||||
<span>{tag}</span>
|
||||
{!disabled && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-text-neutral-tertiary focus:text-text-neutral-primary ml-2 shrink-0"
|
||||
onClick={() => removeTag(index)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!disabled && !readOnly && (
|
||||
<Input
|
||||
type="text"
|
||||
ref={input}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
if (lowercase) {
|
||||
setInputValue(e.target.value.toLowerCase());
|
||||
} else if (uppercase) {
|
||||
setInputValue(e.target.value.toUpperCase());
|
||||
} else {
|
||||
setInputValue(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("shared.typeAndPressTo", { 0: separator, 1: t("shared.add").toLowerCase() })}
|
||||
id={name}
|
||||
autoComplete={autoComplete}
|
||||
// required={required}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
// defaultValue={value}
|
||||
onBlur={() => {
|
||||
if (inputValue.trim() !== "") {
|
||||
onChange([...value, inputValue.trim()]);
|
||||
setInputValue("");
|
||||
}
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
pattern={pattern !== "" && pattern !== undefined ? pattern : undefined}
|
||||
autoFocus={autoFocus}
|
||||
className={clsx(
|
||||
"m-0.5 min-w-0 flex-1",
|
||||
icon && "rounded-md pl-10"
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// classNameBg,
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "hover:bg-secondary focus:bg-secondary",
|
||||
// borderless && "border-transparent"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(InputMultipleText);
|
||||
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import clsx from "~/utils/shared/ClassesUtils";
|
||||
import { RadioGroup } from "~/ui/components/RadioGroup";
|
||||
|
||||
// Subframe migration (strangler shim):
|
||||
// Keeps the original InputRadioGroup API (options/value/setValue/...) but renders the Subframe
|
||||
// design-system `RadioGroup` (horizontal layout). The public props are unchanged.
|
||||
//
|
||||
// value/setValue keep the original `string | number | undefined` type. Subframe/Radix
|
||||
// RadioGroup speaks strings only, so values are stringified at the boundary and mapped back to
|
||||
// their original typed value (via the options lookup) before calling setValue.
|
||||
//
|
||||
// controlled + uncontrolled: Subframe's Root is internally controlled. The legacy field was
|
||||
// usually controlled via value/setValue, but setValue is optional, so the shim holds its own
|
||||
// state seeded from `value`. When the parent controls `value` it stays in sync; when it
|
||||
// doesn't, internal state keeps the selection from sticking.
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
options: { name: string | ReactNode; value: string | number | undefined; disabled?: boolean }[];
|
||||
value?: string | number | undefined;
|
||||
setValue?: React.Dispatch<React.SetStateAction<string | number | undefined>>;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const toKey = (value: string | number | undefined): string => (value === undefined ? "" : String(value));
|
||||
|
||||
export default function InputRadioGroup({ name, title, value, options, setValue, className, required, disabled }: Props) {
|
||||
const [actualValue, setActualValue] = useState<string | number | undefined>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setActualValue(value);
|
||||
}, [value]);
|
||||
|
||||
function onValueChange(nextKey: string) {
|
||||
// Map the stringified key back to the original typed option value.
|
||||
const matched = options.find((option) => toKey(option.value) === nextKey);
|
||||
const next = matched ? matched.value : nextKey;
|
||||
setActualValue(next);
|
||||
if (setValue) {
|
||||
setValue(next);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "w-full grow")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex justify-between space-x-2 truncate text-xs font-medium">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name={name} value={toKey(actualValue)} hidden readOnly />
|
||||
|
||||
<RadioGroup
|
||||
className="mt-1 w-full max-w-none"
|
||||
horizontal={true}
|
||||
disabled={disabled}
|
||||
value={toKey(actualValue)}
|
||||
onValueChange={onValueChange}
|
||||
aria-label={title}
|
||||
>
|
||||
{options.map((option, idx) => (
|
||||
<RadioGroup.Option key={idx} value={toKey(option.value)} disabled={option.disabled || disabled} label={option.name} className="flex-1" />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import clsx from "clsx";
|
||||
import HintTooltip from "../tooltips/HintTooltip";
|
||||
import { useState, useEffect, ReactNode } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { RadioCardGroup } from "~/ui/components/RadioCardGroup";
|
||||
|
||||
// Subframe migration (strangler shim):
|
||||
// Keeps the original InputRadioGroupCards API but renders the Subframe design-system
|
||||
// `RadioCardGroup`. The public props (title/options/value/onChange/columns/display/...) are
|
||||
// unchanged; call-sites are untouched.
|
||||
//
|
||||
// The Subframe Root provides only the radio behaviour + selected styling (no label, no grid),
|
||||
// so the surrounding label (title/required/help/hint) and the columns grid stay in this shim.
|
||||
// The grid classes are passed via `className` (applied last by Subframe's twClassNames) to
|
||||
// override the Root's default `flex` layout.
|
||||
//
|
||||
// controlled + uncontrolled: Subframe/Radix Root is internally controlled. The legacy field
|
||||
// already held its own `actualValue` state (seeded from value || defaultValue) and called
|
||||
// `onChange` on change — that behaviour is preserved, so selection works whether or not the
|
||||
// parent supplies value/onChange.
|
||||
|
||||
type ItemDto = {
|
||||
value: string | number;
|
||||
name: string;
|
||||
color?: Colors;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
renderName?: React.ReactNode;
|
||||
};
|
||||
export default function InputRadioGroupCards({
|
||||
title,
|
||||
name,
|
||||
options,
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
disabled,
|
||||
help,
|
||||
hint,
|
||||
columns,
|
||||
className,
|
||||
display,
|
||||
}: {
|
||||
title?: string;
|
||||
name?: string;
|
||||
options: ItemDto[];
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
help?: string;
|
||||
hint?: React.ReactNode;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
display?: "name" | "value" | "nameAndValue";
|
||||
}) {
|
||||
const [displayType, setDisplayType] = useState<"name" | "value" | "nameAndValue">("name");
|
||||
|
||||
const [actualValue, setActualValue] = useState<string | undefined>(value || defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (actualValue && onChange && value !== actualValue) {
|
||||
onChange(actualValue);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actualValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (display) {
|
||||
setDisplayType(display);
|
||||
} else {
|
||||
const hasNames = options.some((item) => item.name);
|
||||
if (!hasNames) {
|
||||
setDisplayType("value");
|
||||
} else {
|
||||
const namesAreDifferent = options.some((item) => item.name !== item.value.toString());
|
||||
if (namesAreDifferent) {
|
||||
setDisplayType("nameAndValue");
|
||||
} else {
|
||||
setDisplayType("name");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [display, options]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{title && (
|
||||
<div className="mb-1 flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
|
||||
{help && <HintTooltip text={help} />}
|
||||
</div>
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
<input type="hidden" name={name} value={actualValue} hidden readOnly />
|
||||
<RadioCardGroup
|
||||
value={actualValue}
|
||||
onValueChange={(e) => setActualValue(e)}
|
||||
aria-label={title}
|
||||
className={clsx(
|
||||
"grid w-full max-w-none grid-cols-1 gap-3",
|
||||
columns === undefined && "md:grid-cols-3",
|
||||
columns === 1 && "md:grid-cols-1",
|
||||
columns === 2 && "md:grid-cols-2",
|
||||
columns === 3 && "md:grid-cols-3",
|
||||
columns === 4 && "md:grid-cols-4",
|
||||
columns === 5 && "md:grid-cols-5",
|
||||
columns === 6 && "md:grid-cols-6",
|
||||
columns === 7 && "md:grid-cols-7",
|
||||
columns === 8 && "md:grid-cols-8",
|
||||
columns === 9 && "md:grid-cols-9",
|
||||
columns === 10 && "md:grid-cols-10",
|
||||
columns === 11 && "md:grid-cols-11",
|
||||
columns === 12 && "md:grid-cols-12"
|
||||
)}
|
||||
>
|
||||
{options.map((item) => (
|
||||
<RadioCardGroup.RadioCard
|
||||
key={item.value}
|
||||
value={item.value.toString()}
|
||||
disabled={disabled || item.disabled}
|
||||
hideRadio={Boolean(item.icon)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex w-full justify-center truncate">{item.icon}</div>
|
||||
) : (
|
||||
<span className="flex w-full flex-col select-none">
|
||||
<span className={clsx("block truncate text-sm", !disabled ? "font-medium" : "")}>
|
||||
{["name", "nameAndValue"].includes(displayType) ? <span>{item.renderName ? item.renderName : item.name}</span> : item.value}
|
||||
</span>
|
||||
{["nameAndValue", "value"].includes(displayType) && <span className="mt-1 flex items-center truncate text-sm">{item.value}</span>}
|
||||
</span>
|
||||
)}
|
||||
</RadioCardGroup.RadioCard>
|
||||
))}
|
||||
</RadioCardGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import InputRadioGroup from "./InputRadioGroup";
|
||||
import InputSelector from "./InputSelector";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
value?: 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;
|
||||
}
|
||||
export default function InputResponsiveSelector(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<InputSelector className="sm:hidden" {...props} />
|
||||
<InputRadioGroup className="hidden sm:block" {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor, EditorContent, Extensions, useEditor } from "@tiptap/react";
|
||||
import { Button } from "../button";
|
||||
import { Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote, Undo, Redo, Eraser, Link2, Underline, PilcrowIcon } from "~/components/ui/icons/vendor";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import { createTiptapEditorProps } from "~/modules/novel/ui/editor/props";
|
||||
import { createTiptapExtensions } from "~/modules/novel/ui/editor/extensions";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useRootData } from "~/utils/data/useRootData";
|
||||
|
||||
function MenuBar({ editor, disabled }: { editor: Editor | null; disabled?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const headingLevels: (1 | 2 | 3)[] = [1, 2, 3];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleBold().run()}
|
||||
className={editor?.isActive("bold") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.bold")}
|
||||
>
|
||||
<Bold size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleItalic().run()}
|
||||
className={editor?.isActive("italic") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.italic")}
|
||||
>
|
||||
<Italic size={16} />
|
||||
</Button>
|
||||
{/* Underline */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
title={t("components.richTextEditor.toolbar.underline")}
|
||||
className={editor?.isActive("underline") ? "bg-surface-neutral-secondary" : ""}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<Underline size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleStrike().run()}
|
||||
className={editor?.isActive("strike") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.strikethrough")}
|
||||
>
|
||||
<Strikethrough size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleCode().run()}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleCode().run()}
|
||||
className={editor?.isActive("code") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.code")}
|
||||
>
|
||||
<Code size={16} />
|
||||
</Button>
|
||||
|
||||
{headingLevels.map((level) => (
|
||||
<Button
|
||||
key={level}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level }).run()}
|
||||
title={t("components.richTextEditor.toolbar.heading", { level })}
|
||||
className={editor?.isActive("heading", { level }) ? "bg-surface-neutral-secondary" : ""}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleHeading({ level }).run()}
|
||||
>
|
||||
H{level}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().setParagraph().run()}
|
||||
className={editor?.isActive("paragraph") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.paragraph")}
|
||||
disabled={disabled || !editor?.can().chain().focus().setParagraph().run()}
|
||||
>
|
||||
<PilcrowIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Link Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const previousUrl = editor?.getAttributes("link").href;
|
||||
const url = window.prompt(t("components.richTextEditor.toolbar.enterUrl"), previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor?.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
}}
|
||||
className={editor?.isActive("link") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.link")}
|
||||
disabled={disabled || !editor?.can().chain().focus().extendMarkRange("link").run()}
|
||||
>
|
||||
<Link2 size={16} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
className={editor?.isActive("bulletList") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.bulletList")}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
className={editor?.isActive("orderedList") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.orderedList")}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
className={editor?.isActive("blockquote") ? "bg-surface-neutral-secondary" : ""}
|
||||
title={t("components.richTextEditor.toolbar.blockquote")}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleCodeBlock().run()}
|
||||
>
|
||||
<Quote size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().unsetAllMarks().run()}
|
||||
title={t("components.richTextEditor.toolbar.clearFormatting")}
|
||||
disabled={disabled || !editor?.can().chain().focus().unsetAllMarks().run()}
|
||||
>
|
||||
<Eraser size={16} />
|
||||
</Button>
|
||||
{/* Highlight */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleHighlight().run()}
|
||||
title={t("components.richTextEditor.toolbar.highlight")}
|
||||
className={editor?.isActive("highlight") ? "bg-surface-neutral-secondary" : ""}
|
||||
disabled={disabled || !editor?.can().chain().focus().toggleHighlight().run()}
|
||||
>
|
||||
<span className="bg-yellow-200 px-1 text-yellow-800">H</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={disabled || !editor?.can().chain().focus().undo().run()}
|
||||
title={t("components.richTextEditor.toolbar.undo")}
|
||||
>
|
||||
<Undo size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={disabled || !editor?.can().chain().focus().redo().run()}
|
||||
title={t("components.richTextEditor.toolbar.redo")}
|
||||
>
|
||||
<Redo size={16} />
|
||||
</Button>
|
||||
{/* Color Picker Button */}
|
||||
<div className="relative">
|
||||
{/* Hidden native color input */}
|
||||
<input
|
||||
type="color"
|
||||
id="tiptap-color-picker"
|
||||
className="absolute top-0 left-0 h-0 w-0 opacity-0"
|
||||
onChange={(e) => editor?.chain().focus().setColor(e.target.value).run()}
|
||||
/>
|
||||
|
||||
{/* Button that triggers the hidden color input */}
|
||||
<Button variant="ghost" size="xs" type="button" title={t("components.richTextEditor.toolbar.textColor")} onClick={() => document.getElementById("tiptap-color-picker")?.click()}>
|
||||
🎨
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ValueDto = {
|
||||
html?: string;
|
||||
json?: any;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export interface InputRichTextEditorClientProps {
|
||||
content: string | any | undefined;
|
||||
onChange: (value: ValueDto) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
dirty?: boolean;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function InputRichTextEditorClient({ content, onChange, disabled, className, dirty, placeholder, required, style }: InputRichTextEditorClientProps) {
|
||||
const rootData = useRootData();
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const imageUploadOptions = useMemo(() => ({ apiBaseUrl: rootData.apiBaseUrl, tenantId: rootData.currentTenant?.id ?? null }), [rootData.apiBaseUrl, rootData.currentTenant?.id]);
|
||||
const extensions: Extensions = useMemo(
|
||||
() => [
|
||||
...createTiptapExtensions(imageUploadOptions),
|
||||
Color.configure({ types: ["textStyle"] }), // enables color
|
||||
],
|
||||
[imageUploadOptions]
|
||||
);
|
||||
const editorProps = useMemo(() => createTiptapEditorProps(imageUploadOptions), [imageUploadOptions]);
|
||||
|
||||
const debouncedUpdates = useCallback(
|
||||
({ editor }: { editor: Editor }) => {
|
||||
const json = editor.getJSON();
|
||||
const html = editor.getHTML();
|
||||
const text = editor.getText();
|
||||
// onChange(json);
|
||||
onChange({
|
||||
json,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions,
|
||||
editorProps,
|
||||
content,
|
||||
onUpdate: (e) => {
|
||||
debouncedUpdates(e);
|
||||
// const json = editor?.getJSON();
|
||||
// const html = editor?.getHTML();
|
||||
// onChange({ json, html });
|
||||
},
|
||||
});
|
||||
|
||||
// Hydrate the editor with the content from localStorage.
|
||||
useEffect(() => {
|
||||
if (editor && content && !hydrated) {
|
||||
editor.commands.setContent(content);
|
||||
setHydrated(true);
|
||||
}
|
||||
}, [editor, content, hydrated]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border-default relative w-full rounded border shadow-sm",
|
||||
disabled ? "bg-surface-neutral-secondary opacity-80" : "bg-surface-neutral-primary",
|
||||
dirty && "bg-yellow-50 dark:bg-yellow-100 dark:text-yellow-900"
|
||||
)}
|
||||
>
|
||||
<div className="border-b p-1">
|
||||
<MenuBar editor={editor} disabled={disabled} />
|
||||
</div>
|
||||
{/*
|
||||
Mouse-only hit-area enhancement: clicking the editor padding focuses the
|
||||
TipTap editor. The editing surface (EditorContent) is itself keyboard
|
||||
focusable, so this wrapper must NOT be a focusable/role-bearing element —
|
||||
that would add a redundant, confusing tab stop ahead of the real editor.
|
||||
*/}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
||||
<div
|
||||
className={cn("tiptap cursor-text p-4", className)}
|
||||
style={style}
|
||||
onClick={() => {
|
||||
editor?.chain().focus().run();
|
||||
}}
|
||||
>
|
||||
{disabled ? (
|
||||
<div className="cursor-not-allowed">
|
||||
<div dangerouslySetInnerHTML={{ __html: editor?.getHTML() || "" }} />
|
||||
</div>
|
||||
) : (
|
||||
<EditorContent editor={editor} disabled={disabled} required={required} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import type { InputRichTextEditorClientProps } from "./InputRichTextEditor.client";
|
||||
|
||||
const InputRichTextEditorClient = lazy(() => import("./InputRichTextEditor.client"));
|
||||
|
||||
export default function InputRichTextEditor(props: InputRichTextEditorClientProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className={props.className} style={props.style} />}>
|
||||
<InputRichTextEditorClient {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonPrimary from "../buttons/ButtonPrimary";
|
||||
import { TextInput } from "~/ui/components/TextInput";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
onNew?: () => void;
|
||||
onNewRoute?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
size?: "xs" | "sm" | "md";
|
||||
}
|
||||
|
||||
// Subframe migration (strangler shim): renders Subframe TextInput with type="search"
|
||||
// (built-in search icon) behind the original InputSearch API. Subframe's own SearchInput
|
||||
// is purely presentational (hardcoded value="", no children/onChange/value binding) and
|
||||
// cannot back a controlled field, so TextInput is the correct controlled target — mirrors
|
||||
// the InputText shim. See docs/subframe/COMPONENT-MAPPING.md.
|
||||
export default function InputSearch({ value, setValue, onNew, onNewRoute, placeholder, className, disabled, size = "md" }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedPlaceholder = placeholder ?? t("shared.searchDot");
|
||||
return (
|
||||
<div className={clsx("flex justify-between space-x-2", className)}>
|
||||
<TextInput
|
||||
className="w-full max-w-none"
|
||||
type="search"
|
||||
disabled={disabled}
|
||||
size={size === "md" ? "medium" : "small"}
|
||||
>
|
||||
<TextInput.Input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder={resolvedPlaceholder}
|
||||
aria-label={resolvedPlaceholder}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TextInput>
|
||||
{onNew && <ButtonPrimary onClick={onNew}>{t("shared.new")}</ButtonPrimary>}
|
||||
{onNewRoute && <ButtonPrimary to={onNewRoute}>{t("shared.new")}</ButtonPrimary>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import clsx from "clsx";
|
||||
import { FeatherSearch } from "@subframe/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "../button";
|
||||
|
||||
interface Props {
|
||||
size?: "xs" | "sm" | "md";
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// Subframe migration (strangler shim): this is NOT an editable field — it is a button
|
||||
// styled like a search box that triggers `onClick` (e.g. opens a search modal/palette).
|
||||
// It therefore maps to the already-migrated Subframe `Button` shim (variant="outline")
|
||||
// rather than Subframe's TextInput/SearchInput. The inline SVG is replaced by the
|
||||
// design-system FeatherSearch icon. Public API (size/onClick/className/placeholder) is
|
||||
// preserved exactly. See docs/subframe/COMPONENT-MAPPING.md.
|
||||
export default function InputSearchButton({ size = "md", onClick, className, placeholder }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={clsx("flex justify-between space-x-2")}>
|
||||
<div className="relative flex w-full flex-auto items-center justify-center">
|
||||
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 flex items-center justify-center focus-within:z-10 lg:left-0 lg:pl-3">
|
||||
<FeatherSearch className={cn(size === "xs" && "size-2", size === "sm" && "size-3.5", size === "md" && "size-4")} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-muted-foreground w-full justify-start text-left font-normal",
|
||||
className,
|
||||
size === "xs" && "h-7 text-xs lg:pl-8",
|
||||
size === "sm" && "h-8 text-sm lg:pl-8",
|
||||
size === "md" && "lg:pl-9"
|
||||
)}
|
||||
>
|
||||
<span className="hidden text-xs lg:flex">{placeholder ?? t("shared.searchDot")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router";
|
||||
import ButtonPrimary from "../buttons/ButtonPrimary";
|
||||
import clsx from "clsx";
|
||||
import { TextInput } from "~/ui/components/TextInput";
|
||||
|
||||
interface Props {
|
||||
onNew?: () => void;
|
||||
onNewRoute?: string;
|
||||
placeholder?: string;
|
||||
size?: "xs" | "sm" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Subframe migration (strangler shim): renders Subframe TextInput with type="search"
|
||||
// (built-in search icon) behind the original API. The debounced URL-sync logic is
|
||||
// unchanged. Subframe's own SearchInput is presentational only and cannot back a
|
||||
// controlled field. See docs/subframe/COMPONENT-MAPPING.md.
|
||||
export default function InputSearchWithURL({ onNew, onNewRoute, placeholder, size = "lg", className }: Props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [search, setSearch] = useState(searchParams.get("q")?.toString() ?? "");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(searchParams.get("q")?.toString() ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchParams.get("q") ?? "";
|
||||
if (query !== debouncedSearch) {
|
||||
searchParams.set("q", debouncedSearch);
|
||||
if (searchParams.get("page")) {
|
||||
searchParams.set("page", "1");
|
||||
}
|
||||
if (searchParams.get("pageSize")) {
|
||||
// searchParams.delete("pageSize");
|
||||
}
|
||||
setSearchParams(searchParams, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
}, [debouncedSearch, searchParams, setSearchParams]);
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t("shared.searchDot");
|
||||
|
||||
return (
|
||||
<div className={clsx("flex justify-between space-x-2", className)}>
|
||||
<TextInput className="w-full max-w-none" type="search" size={size === "lg" ? "medium" : "small"}>
|
||||
<TextInput.Input
|
||||
type="search"
|
||||
name="q"
|
||||
aria-label={resolvedPlaceholder}
|
||||
placeholder={resolvedPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</TextInput>
|
||||
|
||||
{onNew && <ButtonPrimary onClick={onNew}>{t("shared.new")}</ButtonPrimary>}
|
||||
{onNewRoute && <ButtonPrimary to={onNewRoute}>{t("shared.new")}</ButtonPrimary>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import clsx from "~/utils/shared/ClassesUtils";
|
||||
import HintTooltip from "../tooltips/HintTooltip";
|
||||
import ColorBadge from "../badges/ColorBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "../input";
|
||||
import { Select } from "~/ui/components/Select";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
options: {
|
||||
name: string | ReactNode;
|
||||
value: string | number | undefined;
|
||||
disabled?: boolean;
|
||||
component?: ReactNode;
|
||||
color?: number;
|
||||
}[];
|
||||
value?: string | number | undefined;
|
||||
setValue?: React.Dispatch<React.SetStateAction<string | number | undefined>>;
|
||||
defaultValue?: string | number | undefined;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
help?: string;
|
||||
hint?: ReactNode;
|
||||
borderless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
placeholder?: string;
|
||||
withColors?: boolean;
|
||||
withSearch?: boolean;
|
||||
selectClassName?: string;
|
||||
canClear?: boolean;
|
||||
dirty?: boolean;
|
||||
size?: "sm" | "default";
|
||||
style?: React.CSSProperties;
|
||||
hideIcon?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
export default function InputSelect({
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
setValue,
|
||||
className,
|
||||
required,
|
||||
disabled,
|
||||
help,
|
||||
hint,
|
||||
borderless,
|
||||
// autoFocus,
|
||||
placeholder,
|
||||
withColors,
|
||||
withSearch = false,
|
||||
selectClassName,
|
||||
canClear = true,
|
||||
size = "default",
|
||||
style,
|
||||
icon,
|
||||
hideIcon = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const refSearch = useRef<HTMLInputElement>(null);
|
||||
const [inputSearch, setInputSearch] = useState("");
|
||||
const filteredOptions = () => {
|
||||
let filteredItems = options.filter((f) => f.value);
|
||||
if (inputSearch.trim()) {
|
||||
filteredItems = options.filter((item) => {
|
||||
return (
|
||||
item.name?.toString().toLowerCase().includes(inputSearch.toLowerCase()) || item.value?.toString().toLowerCase().includes(inputSearch.toLowerCase())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (canClear) {
|
||||
return [
|
||||
{
|
||||
name: getPlaceholder(),
|
||||
value: "{null}",
|
||||
disabled: false,
|
||||
},
|
||||
...filteredItems,
|
||||
];
|
||||
}
|
||||
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
return withLabel ? placeholder || `${t("shared.select")}...` : title || `${t("shared.select")}...`;
|
||||
};
|
||||
|
||||
const showLabel = Boolean(withLabel && title);
|
||||
|
||||
// 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="flex space-x-1 truncate">
|
||||
<span className="truncate">{title}</span>
|
||||
{required && title && <span className="ml-1 text-red-600">*</span>}
|
||||
</span>
|
||||
{help && <HintTooltip text={help} />}
|
||||
</span>
|
||||
{hint}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "")} style={style}>
|
||||
<Select
|
||||
className={clsx("w-full max-w-none", selectClassName)}
|
||||
name={name}
|
||||
value={value !== undefined ? value.toString() : undefined}
|
||||
defaultValue={defaultValue !== undefined ? defaultValue.toString() : undefined}
|
||||
onValueChange={(e) => {
|
||||
if (e === "{null}") {
|
||||
setValue?.("");
|
||||
} else {
|
||||
setValue?.(e);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
borderless={borderless}
|
||||
size={size === "sm" ? "small" : "medium"}
|
||||
showLabel={showLabel}
|
||||
label={labelContent}
|
||||
icon={!hideIcon ? icon : undefined}
|
||||
placeholder={getPlaceholder()}
|
||||
>
|
||||
{withSearch && options.length > 30 && (
|
||||
<Input
|
||||
ref={refSearch}
|
||||
autoFocus
|
||||
type="text"
|
||||
className="mb-1 px-2 py-1.5"
|
||||
placeholder={t("shared.search")}
|
||||
value={inputSearch}
|
||||
onChange={(e) => setInputSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDownCapture={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{filteredOptions().length === 0 && (
|
||||
<Select.Item disabled value={"{empty}"}>
|
||||
<div className="text-muted-foreground w-full text-center">{t("shared.noRecords")}</div>
|
||||
</Select.Item>
|
||||
)}
|
||||
{filteredOptions().map((item, idx) => {
|
||||
return (
|
||||
<Select.Item key={idx} disabled={item.disabled} value={item.value?.toString() ?? ""}>
|
||||
{("component" in item && item.component) || (
|
||||
<div className="flex items-center space-x-2">
|
||||
{withColors && "color" in item && <ColorBadge color={item.color} />}
|
||||
<div>{item.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</Select.Item>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import InputText from "./InputText";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { exists: () => false },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("InputText accessibility attributes", () => {
|
||||
it("links text inputs to their error descriptions", () => {
|
||||
const { container } = render(
|
||||
<InputText
|
||||
id="email"
|
||||
name="email"
|
||||
title="Email"
|
||||
type="email"
|
||||
defaultValue="not-an-email"
|
||||
ariaDescribedBy="email-error"
|
||||
ariaInvalid
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = container.querySelector("input");
|
||||
// Subframe's TextInput associates its label implicitly by wrapping the input in the
|
||||
// <label> element (a valid WCAG name source), rather than via an explicit for/id pair.
|
||||
const label = input?.closest("label");
|
||||
|
||||
expect(label).not.toBeNull();
|
||||
expect(label?.textContent).toContain("Email");
|
||||
expect(input?.getAttribute("aria-describedby")).toBe("email-error");
|
||||
expect(input?.getAttribute("aria-invalid")).toBe("true");
|
||||
});
|
||||
|
||||
it("links textareas to their error descriptions", () => {
|
||||
const { container } = render(<InputText id="message" name="message" title="Message" rows={4} defaultValue="" ariaDescribedBy="message-error" isError />);
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
|
||||
expect(textarea?.getAttribute("aria-describedby")).toBe("message-error");
|
||||
expect(textarea?.getAttribute("aria-invalid")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
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);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
describe("Lazy rich text editor boundary", () => {
|
||||
it("keeps TipTap behind a lazy client import and renders a fallback", () => {
|
||||
const boundarySource = readRepoFile("apps/web/app/components/ui/input/InputRichTextEditor.tsx");
|
||||
|
||||
expect(boundarySource).toContain('lazy(() => import("./InputRichTextEditor.client"))');
|
||||
expect(boundarySource).toContain("<Suspense fallback=");
|
||||
expect(boundarySource).not.toContain("@tiptap/");
|
||||
});
|
||||
|
||||
it("keeps TipTap package imports isolated to the client editor", () => {
|
||||
const clientSource = readRepoFile("apps/web/app/components/ui/input/InputRichTextEditor.client.tsx");
|
||||
|
||||
expect(clientSource).toContain("@tiptap/react");
|
||||
expect(clientSource).toContain("@tiptap/extension-color");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputCheckbox from "./InputCheckbox";
|
||||
import InputDate from "./InputDate";
|
||||
import InputNumber from "./InputNumber";
|
||||
import InputRadioGroup from "./InputRadioGroup";
|
||||
import InputSelect from "./InputSelect";
|
||||
import InputSelector from "./InputSelector";
|
||||
import InputText from "./InputText";
|
||||
|
||||
export default function PreviewInputs() {
|
||||
const { t } = useTranslation();
|
||||
const [selectedOption, setSelectedOption] = useState<string | number | undefined>("");
|
||||
const sampleOptions = [
|
||||
{ value: "1", name: t("components.previewInputs.options.option1") },
|
||||
{ value: "2", name: t("components.previewInputs.options.option2") },
|
||||
];
|
||||
return (
|
||||
<div id="inputs" className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("components.previewInputs.sections.text")}</h3>
|
||||
<div id="text" className=" border-border space-y-3 border border-dashed px-10 py-12 lg:px-64">
|
||||
<InputText name="input-text" title={t("components.previewInputs.examples.inputTextSimple")} defaultValue={undefined} />
|
||||
<InputText name="input-text" title={t("components.previewInputs.examples.inputTextWithHint")} hint={<div>{t("components.previewInputs.hint")}</div>} defaultValue={undefined} />
|
||||
<InputText name="input-text" title={t("components.previewInputs.examples.inputTextWithHelp")} help={t("components.previewInputs.help")} defaultValue={undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("components.previewInputs.sections.number")}</h3>
|
||||
<div id="number" className=" border-border space-y-3 border border-dashed px-10 py-12 lg:px-64">
|
||||
<InputNumber name="input-number" title={t("components.previewInputs.examples.inputNumberSimple")} defaultValue={undefined} />
|
||||
<InputNumber name="input-number-with-help" title={t("components.previewInputs.examples.inputNumberWithHint")} hint={<div>{t("components.previewInputs.hint")}</div>} defaultValue={undefined} />
|
||||
<InputNumber name="input-number-with-help" title={t("components.previewInputs.examples.inputNumberWithHelp")} help={t("components.previewInputs.help")} defaultValue={undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("components.previewInputs.sections.select")}</h3>
|
||||
<div id="select" className=" border-border space-y-3 border border-dashed px-10 py-12 lg:px-64">
|
||||
<InputSelect name="input-select" title={t("components.previewInputs.examples.simple")} options={sampleOptions} value={2} />
|
||||
|
||||
<InputSelect name="input-select" title={t("components.previewInputs.examples.simple")} options={sampleOptions} value={2} />
|
||||
|
||||
<div>
|
||||
<InputSelector name="input-selector" title={t("components.previewInputs.examples.custom")} options={sampleOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("components.previewInputs.sections.radio")}</h3>
|
||||
<div id="radio" className=" border-border border border-dashed px-10 py-12 lg:px-64">
|
||||
<InputRadioGroup
|
||||
name="input-radio-group"
|
||||
title={t("components.previewInputs.examples.inputRadioGroup")}
|
||||
value={selectedOption}
|
||||
setValue={setSelectedOption}
|
||||
options={[
|
||||
...sampleOptions,
|
||||
{ value: "3", name: t("components.previewInputs.options.option3"), disabled: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("components.previewInputs.sections.checkbox")}</h3>
|
||||
<div id="checkbox" className=" border-border space-y-3 border border-dashed px-10 py-12 lg:px-64">
|
||||
<InputCheckbox name="input-check" title={t("components.previewInputs.examples.inputCheckboxSimple")} />
|
||||
<InputCheckbox name="input-check-as-toggle" title={t("components.previewInputs.examples.inputCheckboxAsToggle")} asToggle={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("components.previewInputs.sections.date")}</h3>
|
||||
<div id="date" className=" border-border border border-dashed px-10 py-12 lg:px-64">
|
||||
<InputDate name="input-date" title={t("components.previewInputs.examples.inputDate")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { Switch } from "~/ui/components/Switch";
|
||||
|
||||
// Subframe migration (strangler shim):
|
||||
// Keeps the original single-toggle API (`value`/`onChange`/`disabled`) but renders the
|
||||
// Subframe design-system `Switch`. A single boolean toggle maps to `Switch` (not
|
||||
// `ToggleGroup`, which is for multi-option segmented controls).
|
||||
//
|
||||
// The legacy field was fully controlled (`value: boolean`, required `onChange`), so the shim
|
||||
// stays controlled too — `checked={value}` + `onCheckedChange={onChange}`. No internal state
|
||||
// is needed because the caller always owns the value.
|
||||
interface Props {
|
||||
value: boolean;
|
||||
onChange(checked: boolean): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export default function Toggle({ value, onChange, disabled }: Props) {
|
||||
return (
|
||||
<Switch checked={value} disabled={disabled} onCheckedChange={onChange} aria-label={i18nT("components.toggle.useSetting")} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { KeyboardEvent, Ref, forwardRef, useImperativeHandle, useRef, ReactNode, Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import clsx from "clsx";
|
||||
import HintTooltip from "../../tooltips/HintTooltip";
|
||||
import ColorBadge from "../../badges/ColorBadge";
|
||||
import { ChevronsUpDown, SearchIcon, PlusIcon, CheckIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export interface RefInputSelectorDarkMode {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
type GroupDto = { name: string; items: ItemDto[] };
|
||||
type ItemDto = {
|
||||
group?: string;
|
||||
name: string | ReactNode;
|
||||
value: string | number | undefined;
|
||||
color?: Colors;
|
||||
disabled?: boolean;
|
||||
link?: string;
|
||||
img?: ReactNode;
|
||||
};
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
value?: string | number | undefined;
|
||||
disabled?: boolean;
|
||||
options: ItemDto[];
|
||||
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;
|
||||
autoFocus?: boolean;
|
||||
readOnly?: boolean;
|
||||
renderSelected?: (item: ItemDto) => ReactNode;
|
||||
}
|
||||
const InputSelectorDarkMode = (
|
||||
{
|
||||
name,
|
||||
title,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
setValue,
|
||||
className,
|
||||
withSearch = true,
|
||||
withLabel = true,
|
||||
withColors = false,
|
||||
selectPlaceholder,
|
||||
onNew,
|
||||
required,
|
||||
onNewRoute,
|
||||
help,
|
||||
hint,
|
||||
icon,
|
||||
borderless,
|
||||
autoFocus,
|
||||
readOnly,
|
||||
renderSelected,
|
||||
}: Props,
|
||||
ref: Ref<RefInputSelectorDarkMode>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const inputSearch = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [selected, setSelected] = useState<{ name: string | ReactNode; value: string | number | undefined; color?: Colors }>();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const normalizedSearch = searchInput.trim().toUpperCase();
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!normalizedSearch) {
|
||||
return options;
|
||||
}
|
||||
return options.filter((item) => {
|
||||
const name = item.name?.toString().toUpperCase() ?? "";
|
||||
const optionValue = item.value?.toString().toUpperCase() ?? "";
|
||||
return name.includes(normalizedSearch) || optionValue.includes(normalizedSearch);
|
||||
});
|
||||
}, [normalizedSearch, options]);
|
||||
const itemsByGroup = useMemo(() => {
|
||||
const groups = new Map<string, GroupDto>();
|
||||
for (const item of filteredItems) {
|
||||
const groupName = item.group ?? "";
|
||||
const group = groups.get(groupName);
|
||||
if (group) {
|
||||
group.items.push(item);
|
||||
} else {
|
||||
groups.set(groupName, { name: groupName, items: [item] });
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}, [filteredItems]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = options.find((f) => f.value === value);
|
||||
setSelected(selected);
|
||||
}, [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(() => {
|
||||
button.current?.focus();
|
||||
button.current?.click();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
function trySelect(event: KeyboardEvent) {
|
||||
if (event.code === "Enter") {
|
||||
if (filteredItems.length > 0) {
|
||||
setSelected(filteredItems[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Listbox value={selected} onChange={setSelected} disabled={disabled || readOnly}>
|
||||
{({ open }) => (
|
||||
<div className={clsx(className, "text-slate-50")}>
|
||||
{withLabel && title && (
|
||||
<Listbox.Label htmlFor={name} className="text-muted-foreground mb-1 flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
|
||||
{help && <HintTooltip text={help} />}
|
||||
</div>
|
||||
{hint}
|
||||
</Listbox.Label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
autoFocus={autoFocus}
|
||||
type="button"
|
||||
ref={button}
|
||||
className={clsx(
|
||||
"focus:border-border focus:ring-accent-700 relative w-full cursor-default rounded-sm border border-slate-800 bg-slate-800 py-2 pr-10 pl-3 text-left text-slate-50 shadow-2xs hover:bg-slate-700 focus:ring-1 focus:outline-hidden sm:text-sm",
|
||||
disabled || readOnly ? "cursor-not-allowed bg-slate-700" : "bg-slate-800 hover:bg-slate-700 focus:bg-slate-900",
|
||||
borderless && "border-transparent"
|
||||
)}
|
||||
>
|
||||
<input type="hidden" name={name} value={selected?.value ?? ""} hidden readOnly />
|
||||
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="h-5 w-5 text-slate-400" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="inline-flex w-full items-center space-x-2 truncate">
|
||||
{withColors && selected && <ColorBadge color={selected?.color ?? Colors.UNDEFINED} />}
|
||||
<div className="truncate">
|
||||
{selected ? (
|
||||
<span>{renderSelected ? renderSelected(selected) : selected.name}</span>
|
||||
) : (
|
||||
<span className="text-sm text-slate-500">{selectPlaceholder ?? t("shared.select")}...</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronsUpDown className="h-5 w-5 text-slate-400" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition show={open} as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
|
||||
<Listbox.Options
|
||||
// onFocus={() => inputSearch.current?.focus()}
|
||||
onBlur={() => setSearchInput("")}
|
||||
className={clsx(
|
||||
"ring-opacity-5 absolute z-10 mt-1 max-h-72 w-full overflow-auto rounded-sm bg-slate-800 py-1 text-base text-white shadow-lg ring-1 ring-black focus:outline-hidden sm:text-sm"
|
||||
)}
|
||||
>
|
||||
{(withSearch || onNew || onNewRoute) && (
|
||||
<div className="flex rounded-sm p-2">
|
||||
<div className="relative flex grow items-stretch focus-within:z-10">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
ref={inputSearch}
|
||||
id="search"
|
||||
autoComplete="off"
|
||||
onKeyDown={trySelect}
|
||||
placeholder={t("shared.searchDot")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="focus:border-accent-300 bg-background border-border block w-full rounded-none rounded-l-md border px-3 py-2 pl-10 text-sm focus:ring-slate-300 focus:outline-hidden sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{onNew && (
|
||||
<button
|
||||
title={t("shared.new")}
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className="focus:border-border focus:ring-ring bg-secondary border-border relative -ml-px inline-flex items-center space-x-2 rounded-r-md border px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 focus:ring-1 focus:outline-hidden"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onNewRoute && (
|
||||
<Link
|
||||
to={onNewRoute}
|
||||
className="focus:border-border focus:ring-ring bg-secondary border-border relative -ml-px inline-flex items-center space-x-2 rounded-r-md border px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 focus:ring-1 focus:outline-hidden"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemsByGroup.map((group, idx) => (
|
||||
<Fragment key={idx}>
|
||||
{group.name && (
|
||||
<Listbox.Option
|
||||
disabled
|
||||
className={({ active }) =>
|
||||
clsx("relative cursor-default py-1 pr-9 pl-3 select-none", active ? "bg-accent-500 text-black" : "text-slate-50")
|
||||
}
|
||||
value={group}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<Fragment>
|
||||
<span className={clsx("text-muted-foreground text-xs font-medium", selected ? "font-semibold" : "font-normal")}>{group.name}</span>
|
||||
</Fragment>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
)}
|
||||
|
||||
{group.items.map((item, idx) => (
|
||||
<Listbox.Option
|
||||
key={idx}
|
||||
disabled={item.disabled}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
"relative cursor-default py-2 pr-9 pl-3 text-gray-200 select-none hover:text-white",
|
||||
!item.disabled && active && "bg-accent-500",
|
||||
!item.disabled && !active && "",
|
||||
item.disabled && "cursor-not-allowed"
|
||||
)
|
||||
}
|
||||
value={item}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<Fragment>
|
||||
{item.link ? (
|
||||
<Link to={item.link}>
|
||||
<Item item={item} selected={selected} active={active} withColors={withColors} />
|
||||
</Link>
|
||||
) : (
|
||||
<Item item={item} selected={selected} active={active} withColors={withColors} />
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{withSearch && filteredItems.length === 0 && <div className="px-3 py-2 text-sm text-slate-400">{t("shared.noRecords")}</div>}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
);
|
||||
};
|
||||
|
||||
function Item({ item, selected, active, withColors = true }: { item: ItemDto; selected: boolean; active: boolean; withColors?: boolean }) {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="flex items-center space-x-2 truncate pr-3">
|
||||
{item.img && item.img}
|
||||
<div className="flex items-center space-x-2 truncate">
|
||||
{withColors && item.color !== undefined && <ColorBadge color={item.color} />}
|
||||
<div className={clsx(selected ? "font-medium" : "font-normal", "truncate")}>{item.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<span className={clsx(active ? "text-white" : "text-accent-400", "absolute inset-y-0 right-0 flex items-center pr-4")}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</span>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(InputSelectorDarkMode);
|
||||
@@ -0,0 +1,84 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const inputRoot = path.resolve(process.cwd(), "apps/web/app/components/ui/input");
|
||||
|
||||
function readInput(relativePath: string) {
|
||||
return fs.readFileSync(path.join(inputRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("input component i18n", () => {
|
||||
it("keeps preview demo copy behind semantic translation keys", () => {
|
||||
const source = readInput("PreviewInputs.tsx");
|
||||
const optionPreviewSources = [
|
||||
"previews/checkbox/PreviewInputCheckboxCards.tsx",
|
||||
"previews/radio/PreviewInputRadioGroup.tsx",
|
||||
"previews/radio/PreviewInputRadioGroupCards.tsx",
|
||||
"previews/radio/PreviewInputRadioGroupWithState.tsx",
|
||||
"previews/selector/PreviewInputCombobox.tsx",
|
||||
"previews/selector/PreviewInputSelect.tsx",
|
||||
"previews/selector/PreviewInputSelectWithState.tsx",
|
||||
"previews/selector/PreviewInputSelector.tsx",
|
||||
"previews/selector/PreviewInputSelectorWithState.tsx",
|
||||
].map(readInput);
|
||||
const titleOnlyPreviewSources = [
|
||||
"previews/checkbox/PreviewInputCheckbox.tsx",
|
||||
"previews/checkbox/PreviewInputCheckboxWithState.tsx",
|
||||
"previews/date/PreviewInputDate.tsx",
|
||||
"previews/date/PreviewInputDateWithState.tsx",
|
||||
"previews/number/PreviewInputNumber.tsx",
|
||||
"previews/number/PreviewInputNumberWithAllOptions.tsx",
|
||||
"previews/PreviewInputText.tsx",
|
||||
"previews/text/PreviewInputMultiText.tsx",
|
||||
"previews/text/PreviewInputText.tsx",
|
||||
"previews/text/PreviewInputTextWithAllOptions.tsx",
|
||||
"previews/text/PreviewInputTextWithHintAndHelp.tsx",
|
||||
].map(readInput);
|
||||
|
||||
expect(source).toContain("components.previewInputs.sections.text");
|
||||
expect(source).toContain("components.previewInputs.examples.inputTextSimple");
|
||||
expect(source).toContain("components.previewInputs.options.option1");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
expect(source).not.toContain('title="Simple"');
|
||||
expect(source).not.toContain(">Hint<");
|
||||
expect(source).not.toContain('help="Sample help text"');
|
||||
expect(source).not.toContain('name: "Option 1"');
|
||||
for (const nestedSource of optionPreviewSources) {
|
||||
expect(nestedSource).toContain("components.input.preview.title");
|
||||
expect(nestedSource).toContain("components.previewInputs.options.option1");
|
||||
expect(nestedSource).not.toContain('title="Title"');
|
||||
expect(nestedSource).not.toContain('name: "Option 1"');
|
||||
}
|
||||
for (const nestedSource of titleOnlyPreviewSources) {
|
||||
expect(nestedSource).toContain("components.input.preview.title");
|
||||
expect(nestedSource).not.toContain('title="Title"');
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps rich text editor toolbar copy behind semantic translation keys", () => {
|
||||
const source = readInput("InputRichTextEditor.client.tsx");
|
||||
|
||||
expect(source).toContain("components.richTextEditor.toolbar.bold");
|
||||
expect(source).toContain("components.richTextEditor.toolbar.heading");
|
||||
expect(source).toContain("components.richTextEditor.toolbar.textColor");
|
||||
expect(source).toContain("components.richTextEditor.toolbar.enterUrl");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
expect(source).not.toContain("i18nT");
|
||||
expect(source).not.toContain('window.prompt("Enter URL"');
|
||||
expect(source).not.toContain('useState("Saved")');
|
||||
expect(source).not.toContain('setSaveStatus("Saving...")');
|
||||
});
|
||||
|
||||
it("keeps unlabeled checkboxes and toggles accessible by name", () => {
|
||||
const source = readInput("InputCheckbox.tsx");
|
||||
|
||||
expect(source).toContain("const accessibleName = title || name || undefined");
|
||||
expect(source).toContain("aria-label={accessibleName}");
|
||||
// Subframe-Checkbox-Migration (upstream): die Bedingung wurde praeziser gefasst — der
|
||||
// zugaengliche Name greift jetzt auch, wenn zwar withLabel gesetzt ist, aber kein title
|
||||
// vorliegt (also keine sichtbare <label> rendert). a11y-Garantie bleibt erhalten.
|
||||
expect(source).toContain("aria-label={!withLabel || !title ? accessibleName : undefined}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputText from "../InputText";
|
||||
|
||||
export default function PreviewInputText() {
|
||||
return (
|
||||
<div id="input-text">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputText name="name" title={i18nT("components.input.preview.title")} defaultValue={undefined} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputCheckbox from "~/components/ui/input/InputCheckbox";
|
||||
|
||||
export default function PreviewInputCheckbox() {
|
||||
return (
|
||||
<div id="input-checkbox">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputCheckbox name="name" title={i18nT("components.input.preview.title")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useState } from "react";
|
||||
import InputCheckboxCards from "../../InputCheckboxCards";
|
||||
|
||||
export default function PreviewInputCheckboxCards() {
|
||||
const [selected, setSelected] = useState<(string | number)[]>([]);
|
||||
return (
|
||||
<div id="input-checkbox">
|
||||
<div className="not-prose border-border bg-background border border-dashed p-6">
|
||||
<InputCheckboxCards
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option3"),
|
||||
value: 3,
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputCheckbox from "~/components/ui/input/InputCheckbox";
|
||||
|
||||
export default function PreviewInputCheckboxWithState() {
|
||||
const [value, setValue] = useState(true);
|
||||
return (
|
||||
<div id="input-checkbox-with-state">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputCheckbox name="name" title={i18nT("components.input.preview.title")} value={value} setValue={setValue} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputDate from "~/components/ui/input/InputDate";
|
||||
|
||||
export default function PreviewInputDate() {
|
||||
return (
|
||||
<div id="input-date">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputDate name="name" title={i18nT("components.input.preview.title")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputDate from "~/components/ui/input/InputDate";
|
||||
|
||||
export default function PreviewInputDateWithState() {
|
||||
const [value, setValue] = useState(new Date("1990-01-02"));
|
||||
return (
|
||||
<div id="input-date-with-state">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputDate name="name" title={i18nT("components.input.preview.title")} value={value} onChange={(e) => setValue(e)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputNumber from "~/components/ui/input/InputNumber";
|
||||
|
||||
export default function PreviewInputNumber() {
|
||||
return (
|
||||
<div id="input-number">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputNumber name="name" title={i18nT("components.input.preview.title")} defaultValue={undefined} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputNumber from "~/components/ui/input/InputNumber";
|
||||
|
||||
export default function PreviewInputNumberWithAllOptions() {
|
||||
const [value, setValue] = useState<number | undefined>(undefined);
|
||||
return (
|
||||
<div id="input-number-with-all-options">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputNumber name="name" title={i18nT("components.input.preview.title")} value={value} setValue={setValue} min={0} max={10} required step="0.01" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import InputNumber from "~/components/ui/input/InputNumber";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export default function PreviewInputNumberWithHintAndHelp() {
|
||||
return (
|
||||
<div id="input-number-with-hint-and-help">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputNumber
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
hint={<span className="text-red-600">{i18nT("components.input.preview.hintText")}</span>}
|
||||
help={i18nT("components.input.preview.helpText")}
|
||||
defaultValue={undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputRadioGroup from "~/components/ui/input/InputRadioGroup";
|
||||
|
||||
export default function PreviewInputRadioGroup() {
|
||||
return (
|
||||
<div id="input-radio-group">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputRadioGroup
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option3"),
|
||||
value: 3,
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputRadioGroupCards from "../../InputRadioGroupCards";
|
||||
|
||||
export default function PreviewInputRadioGroupCards() {
|
||||
return (
|
||||
<div id="input-radio-group">
|
||||
<div className="not-prose border-border bg-background border border-dashed p-6">
|
||||
<InputRadioGroupCards
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option3"),
|
||||
value: 3,
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useState } from "react";
|
||||
import InputRadioGroup from "~/components/ui/input/InputRadioGroup";
|
||||
|
||||
type SelectType = string | number | undefined;
|
||||
export default function PreviewInputRadioGroupWithState() {
|
||||
const [value, setValue] = useState<SelectType>(2);
|
||||
return (
|
||||
<div id="input-radio-group-with-state">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputRadioGroup
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option3"),
|
||||
value: 3,
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputCombobox from "../../InputCombobox";
|
||||
|
||||
export default function PreviewInputCombobox() {
|
||||
return (
|
||||
<div id="input-selector">
|
||||
<div className="not-prose border-border bg-background border border-dashed p-6">
|
||||
<InputCombobox
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: "2",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputSelect from "~/components/ui/input/InputSelect";
|
||||
|
||||
export default function PreviewInputSelect() {
|
||||
return (
|
||||
<div id="input-select">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputSelect
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: "2",
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option3"),
|
||||
value: "3",
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useState } from "react";
|
||||
import InputSelect from "~/components/ui/input/InputSelect";
|
||||
|
||||
type SelectType = string | number | undefined;
|
||||
export default function PreviewInputSelectWithState() {
|
||||
const [value, setValue] = useState<SelectType>("2");
|
||||
return (
|
||||
<div id="input-select-with-state">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputSelect
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: "2",
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputSelector from "~/components/ui/input/InputSelector";
|
||||
|
||||
export default function PreviewInputSelector() {
|
||||
return (
|
||||
<div id="input-selector">
|
||||
<div className="not-prose border-border bg-background border border-dashed p-6">
|
||||
<InputSelector
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: "2",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useState } from "react";
|
||||
import InputSelector from "~/components/ui/input/InputSelector";
|
||||
|
||||
type SelectType = string | number | undefined;
|
||||
export default function PreviewInputSelectorWithState() {
|
||||
const [value, setValue] = useState<SelectType>("2");
|
||||
return (
|
||||
<div id="input-selector">
|
||||
<div className="not-prose border-border bg-background border border-dashed p-6">
|
||||
<InputSelector
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
options={[
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option1"),
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
name: i18nT("components.previewInputs.options.option2"),
|
||||
value: "2",
|
||||
},
|
||||
]}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import InputMultiText from "../../InputMultiText";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export default function PreviewInputMultiText() {
|
||||
return (
|
||||
<div id="input-text">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputMultiText name="name" title={i18nT("components.input.preview.title")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
|
||||
export default function PreviewInputText() {
|
||||
return (
|
||||
<div id="input-text">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputText name="name" title={i18nT("components.input.preview.title")} defaultValue="" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
|
||||
export default function PreviewInputTextWithAllOptions() {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<div id="input-text-with-all-options">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputText name="name" title={i18nT("components.input.preview.title")} value={value} setValue={setValue} minLength={0} maxLength={10} required />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export default function PreviewInputTextWithHint() {
|
||||
return (
|
||||
<div id="input-text-with-hint-and-help">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<InputText
|
||||
name="name"
|
||||
title={i18nT("components.input.preview.title")}
|
||||
hint={<span className="text-red-600">{i18nT("components.input.preview.hintText")}</span>}
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import { renderSanitizedMarkdown } from "~/utils/html-sanitizer";
|
||||
|
||||
export default function PreviewInputTextWithMonacoEditor() {
|
||||
const { t } = useTranslation();
|
||||
const [markdownValue, setMarkdownValue] = useState(
|
||||
() => `
|
||||
# ${t("components.previewInputs.markdownEditor")}
|
||||
|
||||
## ${t("components.previewInputs.title1")}
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
|
||||
_Image_:
|
||||
|
||||
)})
|
||||
`
|
||||
);
|
||||
return (
|
||||
<div id="input-text">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<div className="sm:grid sm:grid-cols-2 sm:gap-2">
|
||||
<InputText
|
||||
className="h-64"
|
||||
name="editor"
|
||||
title={t("components.previewInputs.editor")}
|
||||
editor="monaco"
|
||||
editorLanguage="markdown"
|
||||
editorHideLineNumbers={true}
|
||||
value={markdownValue}
|
||||
setValue={setMarkdownValue}
|
||||
/>
|
||||
|
||||
<div className="h-64 space-y-1 overflow-auto">
|
||||
{/* Caption for a read-only rendered-markdown region, not a form control. */}
|
||||
<div className="text-muted-foreground text-xs font-medium">{t("shared.preview")}</div>
|
||||
<div className="prose border-border rounded-md border-2 border-dashed p-6">
|
||||
<div dangerouslySetInnerHTML={{ __html: renderSanitizedMarkdown(markdownValue) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
|
||||
export default function PreviewInputTextWithTranslation() {
|
||||
const [valid, setValid] = useState("shared.hi");
|
||||
const [invalid, setInvalid] = useState("shared.invalid.i18n.key");
|
||||
return (
|
||||
<div id="input-text-with-translations">
|
||||
<div className="border-border bg-background border border-dashed p-6">
|
||||
<div className="space-y-2">
|
||||
<InputText name="name" title={i18nT("components.input.preview.validI18nKey")} value={valid} setValue={setValid} withTranslation />
|
||||
<InputText name="name" title={i18nT("components.input.preview.invalidI18nKey")} value={invalid} setValue={setInvalid} withTranslation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import clsx from "clsx";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import { Input } from "../../input";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
title: string;
|
||||
valueMin?: Date | null;
|
||||
defaultValueMin?: Date | null;
|
||||
onChangeMin?: (date: Date) => void;
|
||||
valueMax?: Date | null;
|
||||
defaultValueMax?: Date | null;
|
||||
onChangeMax?: (date: Date) => void;
|
||||
className?: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
hint?: ReactNode;
|
||||
icon?: string;
|
||||
darkMode?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export default function InputDateRangeDate({
|
||||
name,
|
||||
title,
|
||||
valueMin,
|
||||
defaultValueMin,
|
||||
valueMax,
|
||||
defaultValueMax,
|
||||
onChangeMin,
|
||||
onChangeMax,
|
||||
className,
|
||||
help,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
hint,
|
||||
icon,
|
||||
darkMode,
|
||||
autoFocus,
|
||||
}: Props) {
|
||||
// useImperativeHandle(ref, () => ({ inputMin, inputMax }));
|
||||
// const inputMin = useRef<HTMLInputElement>(null);
|
||||
// const inputMax = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [actualMin, setActualMin] = useState<string>("");
|
||||
const [actualMax, setActualMax] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const value = valueMin || defaultValueMin;
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
if (date) {
|
||||
setActualMin(date.toISOString().split("T")[0]);
|
||||
}
|
||||
}
|
||||
}, [defaultValueMin, valueMin]);
|
||||
|
||||
useEffect(() => {
|
||||
const value = valueMax || defaultValueMax;
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
if (date) {
|
||||
setActualMax(date.toISOString().split("T")[0]);
|
||||
}
|
||||
}
|
||||
}, [defaultValueMax, valueMax]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChangeMin && actualMin) {
|
||||
const date = new Date(actualMin);
|
||||
if (date) {
|
||||
onChangeMin(date);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actualMin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChangeMax && actualMax) {
|
||||
const date = new Date(actualMax);
|
||||
if (date) {
|
||||
onChangeMax(date);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actualMax]);
|
||||
|
||||
return (
|
||||
<div className={clsx(className, !darkMode && "")}>
|
||||
<label htmlFor={name} className="flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className=" flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
|
||||
{help && <HintTooltip text={help} />}
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
<div className="relative mt-1 flex w-full rounded-md">
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="text-muted-foreground h-5 w-5" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<Input
|
||||
type="date"
|
||||
id={name}
|
||||
name={`${name}-min`}
|
||||
required={required}
|
||||
value={actualMin}
|
||||
onChange={(e) => setActualMin(e.target.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
className={clsx(
|
||||
icon && "pl-10"
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// (disabled || readOnly) && "cursor-not-allowed bg-secondary/90",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<Input
|
||||
type="date"
|
||||
id={name}
|
||||
name={`${name}-max`}
|
||||
required={required}
|
||||
value={actualMax}
|
||||
onChange={(e) => setActualMax(e.target.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
className={clsx(
|
||||
icon && "pl-10"
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// (disabled || readOnly) && "cursor-not-allowed bg-secondary/90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EntityIcon from "~/components/layouts/icons/EntityIcon";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import { Input } from "../../input";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
valueMin?: number | null;
|
||||
defaultValueMin?: number | undefined;
|
||||
onChangeMin?: (value: number) => void;
|
||||
valueMax?: number | null;
|
||||
defaultValueMax?: number | undefined;
|
||||
onChangeMax?: (value: number) => void;
|
||||
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;
|
||||
}
|
||||
export default function InputRangeNumber({
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
valueMin,
|
||||
defaultValueMin,
|
||||
onChangeMin,
|
||||
valueMax,
|
||||
defaultValueMax,
|
||||
onChangeMax,
|
||||
className,
|
||||
hint,
|
||||
help,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
min = 0,
|
||||
max,
|
||||
step,
|
||||
placeholder,
|
||||
icon,
|
||||
borderless,
|
||||
autoFocus,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// useImperativeHandle(ref, () => ({ inputMin, inputMax }));
|
||||
// const inputMin = useRef<HTMLInputElement>(null);
|
||||
// const inputMax = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "")}>
|
||||
{withLabel && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 text-xs font-medium">
|
||||
<div className=" flex items-center space-x-1">
|
||||
<div className="truncate">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-red-600">*</span>}
|
||||
</div>
|
||||
|
||||
{help && <HintTooltip text={help} />}
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
<div className={clsx("relative flex w-full rounded-md")}>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EntityIcon className="text-muted-foreground h-5 w-5" icon={icon} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
id={`${name}-min`}
|
||||
name={`${name}-min`}
|
||||
required={required}
|
||||
min={min}
|
||||
max={max}
|
||||
value={valueMin || undefined}
|
||||
defaultValue={defaultValueMin}
|
||||
onChange={(e) => (onChangeMin ? onChangeMin(Number(e.currentTarget.value)) : null)}
|
||||
step={step}
|
||||
placeholder={placeholder ?? t("shared.from") + "..."}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
className={clsx(
|
||||
icon && "pl-10"
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "hover:bg-secondary focus:bg-secondary",
|
||||
// icon && "pl-10",
|
||||
// borderless && "border-transparent"
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<Input
|
||||
type="number"
|
||||
id={`${name}-max`}
|
||||
name={`${name}-max`}
|
||||
required={required}
|
||||
min={max}
|
||||
max={max}
|
||||
value={valueMax || undefined}
|
||||
defaultValue={defaultValueMax}
|
||||
onChange={(e) => (onChangeMax ? onChangeMax(Number(e.currentTarget.value)) : null)}
|
||||
step={step}
|
||||
placeholder={placeholder ?? t("shared.to") + "..."}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
className={clsx(
|
||||
icon && "pl-10"
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "hover:bg-secondary focus:bg-secondary",
|
||||
// borderless && "border-transparent"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, ReactNode, Ref, RefObject, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import CheckIcon from "../../icons/CheckIcon";
|
||||
import ExclamationTriangleIcon from "../../icons/ExclamationTriangleIcon";
|
||||
import { Input } from "../../input";
|
||||
import { AtSignIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export interface RefInputEmail {
|
||||
input: RefObject<HTMLInputElement | null> | RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
defaultValue?: string | undefined;
|
||||
value?: string | undefined;
|
||||
setValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
className?: string;
|
||||
classNameBg?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
pattern?: string;
|
||||
button?: ReactNode;
|
||||
lowercase?: boolean;
|
||||
uppercase?: boolean;
|
||||
type?: string;
|
||||
darkMode?: boolean;
|
||||
hint?: ReactNode;
|
||||
help?: string;
|
||||
onBlur?: () => void;
|
||||
borderless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
const InputEmail = (
|
||||
{
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
defaultValue,
|
||||
value,
|
||||
setValue,
|
||||
className,
|
||||
classNameBg,
|
||||
help,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
minLength,
|
||||
maxLength,
|
||||
placeholder,
|
||||
pattern,
|
||||
hint,
|
||||
button,
|
||||
lowercase,
|
||||
uppercase,
|
||||
type = "email",
|
||||
darkMode,
|
||||
onBlur,
|
||||
borderless,
|
||||
autoFocus,
|
||||
}: Props,
|
||||
ref: Ref<RefInputEmail>
|
||||
) => {
|
||||
useImperativeHandle(ref, () => ({ input }));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isValid, setIsValid] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isValid = value?.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i);
|
||||
setIsValid(isValid ? true : false);
|
||||
}, [value]);
|
||||
|
||||
function onChange(value: string) {
|
||||
if (setValue) {
|
||||
if (lowercase) {
|
||||
setValue(value.toLowerCase());
|
||||
} else if (uppercase) {
|
||||
setValue(value.toUpperCase());
|
||||
} else {
|
||||
setValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(className, !darkMode && "")}>
|
||||
{withLabel && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
<div className="flex space-x-1 truncate">
|
||||
<div className="truncate">{title}</div>
|
||||
{required && title && <div className="ml-1 text-red-600">*</div>}
|
||||
</div>
|
||||
<div className="">{help && <HintTooltip text={help} />}</div>
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
<div className={clsx("relative flex w-full rounded-md")}>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<AtSignIcon className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
ref={input}
|
||||
type={type}
|
||||
id={name}
|
||||
name={name}
|
||||
autoComplete={"off"}
|
||||
required={required}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
pattern={pattern !== "" && pattern !== undefined ? pattern : undefined}
|
||||
autoFocus={autoFocus}
|
||||
className="pl-10"
|
||||
// className={clsx(
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// classNameBg,
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "hover:bg-secondary focus:bg-secondary",
|
||||
// "px-10",
|
||||
// borderless && "border-transparent",
|
||||
// !(disabled || readOnly) &&
|
||||
// clsx(
|
||||
// isValid
|
||||
// ? "border-teal-500 focus:border-teal-500 focus:ring-teal-500"
|
||||
// : value
|
||||
// ? "focus:border-border focus:ring-ring border-red-300"
|
||||
// : "focus:border-border focus:ring-ring border-border"
|
||||
// )
|
||||
// )}
|
||||
/>
|
||||
{!(disabled || readOnly) && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
{isValid ? <CheckIcon className="h-4 w-4 text-teal-500" /> : value ? <ExclamationTriangleIcon className="h-4 w-4 text-red-600" /> : null}
|
||||
</div>
|
||||
)}
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default forwardRef(InputEmail);
|
||||
@@ -0,0 +1,157 @@
|
||||
import clsx from "clsx";
|
||||
import { ReactNode, RefObject, useEffect, useRef, useState } from "react";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import CheckIcon from "../../icons/CheckIcon";
|
||||
import ExclamationTriangleIcon from "../../icons/ExclamationTriangleIcon";
|
||||
import { Input } from "../../input";
|
||||
import { PhoneIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export interface RefInputPhone {
|
||||
input: RefObject<HTMLInputElement> | RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
defaultValue?: string | undefined;
|
||||
value?: string | undefined;
|
||||
setValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
className?: string;
|
||||
classNameBg?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
pattern?: string;
|
||||
button?: ReactNode;
|
||||
lowercase?: boolean;
|
||||
uppercase?: boolean;
|
||||
type?: string;
|
||||
darkMode?: boolean;
|
||||
hint?: ReactNode;
|
||||
help?: string;
|
||||
onBlur?: () => void;
|
||||
borderless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export default function InputPhone({
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
defaultValue,
|
||||
value,
|
||||
setValue,
|
||||
className,
|
||||
classNameBg,
|
||||
help,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
minLength,
|
||||
maxLength,
|
||||
placeholder,
|
||||
pattern,
|
||||
hint,
|
||||
button,
|
||||
lowercase,
|
||||
uppercase,
|
||||
type = "text",
|
||||
darkMode,
|
||||
onBlur,
|
||||
borderless,
|
||||
autoFocus,
|
||||
}: Props) {
|
||||
// useImperativeHandle(ref, () => ({ input }));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isValid, setIsValid] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// simple phone number validation, no letters or special characters except for +, -, (, and )
|
||||
const regex = /^[0-9-+()]*$/;
|
||||
if (regex.test(value ?? "")) {
|
||||
setIsValid(true);
|
||||
} else {
|
||||
setIsValid(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function onChange(value: string) {
|
||||
if (setValue) {
|
||||
if (lowercase) {
|
||||
setValue(value.toLowerCase());
|
||||
} else if (uppercase) {
|
||||
setValue(value.toUpperCase());
|
||||
} else {
|
||||
setValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(className, !darkMode && "")}>
|
||||
{withLabel && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
<div className="flex space-x-1 truncate">
|
||||
<div className="truncate">{title}</div>
|
||||
{required && title && <div className="ml-1 text-red-600">*</div>}
|
||||
</div>
|
||||
<div className="">{help && <HintTooltip text={help} />}</div>
|
||||
</div>
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
<div className={clsx("relative flex w-full rounded-md")}>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<PhoneIcon className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
ref={input}
|
||||
type={type}
|
||||
id={name}
|
||||
name={name}
|
||||
autoComplete={"off"}
|
||||
required={required}
|
||||
minLength={10}
|
||||
maxLength={maxLength}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder ?? "+1 (555) 555-5555"}
|
||||
pattern={pattern !== "" && pattern !== undefined ? pattern : undefined}
|
||||
autoFocus={autoFocus}
|
||||
className="pl-10"
|
||||
// className={clsx(
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// classNameBg,
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "hover:bg-secondary focus:bg-secondary",
|
||||
// "px-10",
|
||||
// borderless && "border-transparent",
|
||||
// !(disabled || readOnly) &&
|
||||
// clsx(
|
||||
// isValid
|
||||
// ? "border-teal-500 focus:border-teal-500 focus:ring-teal-500"
|
||||
// : value
|
||||
// ? "focus:border-border focus:ring-ring border-red-300"
|
||||
// : "focus:border-border focus:ring-ring border-border"
|
||||
// )
|
||||
// )}
|
||||
/>
|
||||
{!(disabled || readOnly) && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
{isValid ? <CheckIcon className="h-4 w-4 text-teal-500" /> : value ? <ExclamationTriangleIcon className="h-4 w-4 text-red-600" /> : null}
|
||||
</div>
|
||||
)}
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
defaultValue?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
export default function InputPhoneWithMask({
|
||||
name = "phone",
|
||||
defaultValue,
|
||||
className = "",
|
||||
placeholder = "",
|
||||
required = false,
|
||||
style,
|
||||
readOnly = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [phone, setPhone] = useState(defaultValue);
|
||||
|
||||
// Formats input as (123) 456-7890
|
||||
const formatPhone = (value: string) => {
|
||||
// Remove all non-digit characters
|
||||
const digits = value.replace(/\D/g, "");
|
||||
|
||||
// Limit to 10 digits
|
||||
const trimmed = digits.substring(0, 10);
|
||||
|
||||
// Format based on number of digits entered
|
||||
if (trimmed.length < 4) {
|
||||
return trimmed;
|
||||
} else if (trimmed.length < 7) {
|
||||
return `(${trimmed.substring(0, 3)}) ${trimmed.substring(3)}`;
|
||||
} else {
|
||||
return `(${trimmed.substring(0, 3)}) ${trimmed.substring(3, 6)}-${trimmed.substring(6)}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="tel"
|
||||
name={name}
|
||||
title={t("models.location.phone")}
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
const formatted = formatPhone(e.target.value);
|
||||
setPhone(formatted);
|
||||
}}
|
||||
className={cn("bg-background", className)}
|
||||
required={required}
|
||||
pattern="\(\d{3}\) \d{3}-\d{4}"
|
||||
placeholder={placeholder || "(123) 456-7890"}
|
||||
style={style}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputPhoneWithMaskControlled({
|
||||
name = "phone",
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
placeholder = "",
|
||||
required = false,
|
||||
dirty = false,
|
||||
style,
|
||||
}: Props & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
dirty?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Formats input as (123) 456-7890
|
||||
const formatPhone = (value: string) => {
|
||||
// Remove all non-digit characters
|
||||
const digits = value.replace(/\D/g, "");
|
||||
|
||||
// Limit to 10 digits
|
||||
const trimmed = digits.substring(0, 10);
|
||||
|
||||
// Format based on number of digits entered
|
||||
if (trimmed.length < 4) {
|
||||
return trimmed;
|
||||
} else if (trimmed.length < 7) {
|
||||
return `(${trimmed.substring(0, 3)}) ${trimmed.substring(3)}`;
|
||||
} else {
|
||||
return `(${trimmed.substring(0, 3)}) ${trimmed.substring(3, 6)}-${trimmed.substring(6)}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="tel"
|
||||
name={name}
|
||||
title={t("models.location.phone")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const formatted = formatPhone(e.target.value);
|
||||
onChange(formatted);
|
||||
}}
|
||||
className={cn(className)}
|
||||
required={required}
|
||||
pattern="\(\d{3}\) \d{3}-\d{4}"
|
||||
placeholder="(123) 456-7890"
|
||||
dirty={dirty}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Fragment } from "react";
|
||||
import InputText from "../InputText";
|
||||
import InputEmail from "./InputEmail";
|
||||
import InputPhone from "./InputPhone";
|
||||
import InputUrl from "./InputUrl";
|
||||
|
||||
type InputTextSubtypeProps = {
|
||||
subtype: "singleLine" | "email" | "phone" | "url" | null;
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
// value: string | undefined;
|
||||
// setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
className?: string;
|
||||
classNameBg?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
autoComplete?: string;
|
||||
translationParams?: string[];
|
||||
placeholder?: string;
|
||||
pattern?: string;
|
||||
rows?: number;
|
||||
button?: React.ReactNode;
|
||||
lowercase?: boolean;
|
||||
uppercase?: boolean;
|
||||
password?: boolean;
|
||||
type?: string;
|
||||
darkMode?: boolean;
|
||||
hint?: React.ReactNode;
|
||||
help?: string;
|
||||
icon?: string;
|
||||
editor?: string; // monaco
|
||||
editorLanguage?: string; // "javascript" | "typescript" | "html" | "css" | "json";
|
||||
editorHideLineNumbers?: boolean;
|
||||
editorTheme?: "vs-dark" | "light";
|
||||
editorFontSize?: number;
|
||||
onBlur?: () => void;
|
||||
borderless?: boolean;
|
||||
editorSize?: "sm" | "md" | "lg" | "auto" | "full" | "screen";
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
type WithDefaultValue = { defaultValue: string | undefined };
|
||||
type WithValueAndSetValue = { value: string | undefined; setValue: React.Dispatch<React.SetStateAction<string>> };
|
||||
|
||||
export default function InputTextSubtype(props: InputTextSubtypeProps & (WithDefaultValue | WithValueAndSetValue)) {
|
||||
return (
|
||||
<>
|
||||
{!props.subtype || props.subtype === "singleLine" ? (
|
||||
<Fragment>
|
||||
<InputText {...props} type={props.password ? "password" : undefined} autoComplete={props.password ? "off" : undefined} />
|
||||
</Fragment>
|
||||
) : props.subtype === "email" ? (
|
||||
<InputEmail {...props} />
|
||||
) : props.subtype === "url" ? (
|
||||
<InputUrl {...props} />
|
||||
) : props.subtype === "phone" ? (
|
||||
<InputPhone {...props} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// // "use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { inputVariants } from "../../input";
|
||||
|
||||
interface InputTimeProps {
|
||||
name?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
label?: string;
|
||||
description?: string;
|
||||
format?: "HH:MM" | "HH:MM:SS";
|
||||
}
|
||||
|
||||
// Custom slot component with placeholder
|
||||
function TimeSlot({
|
||||
index,
|
||||
placeholder,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
// inputVariants({ variant: "outline" }), //
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input bg-background relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char || <span className="text-muted-foreground/40 text-xs">{placeholder}</span>}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputTime({ name, value = "", onChange, required = false, disabled = false, style, label, description, format = "HH:MM:SS" }: InputTimeProps) {
|
||||
const maxLength = format === "HH:MM" ? 4 : 6;
|
||||
|
||||
// Convert value to OTP format (remove colons)
|
||||
const otpValue = value ? value.replace(/:/g, "") : "";
|
||||
|
||||
// Handle OTP change and format back to time
|
||||
const handleChange = (newValue: string) => {
|
||||
// Add validation for time values
|
||||
const digits = newValue.split("");
|
||||
let isValid = true;
|
||||
|
||||
// Validate hours (first 2 digits)
|
||||
if (digits.length >= 1) {
|
||||
const firstHourDigit = parseInt(digits[0]);
|
||||
if (firstHourDigit > 2) isValid = false;
|
||||
}
|
||||
if (digits.length >= 2) {
|
||||
const hours = parseInt(digits[0] + digits[1]);
|
||||
if (hours > 23) isValid = false;
|
||||
}
|
||||
|
||||
// Validate minutes (next 2 digits)
|
||||
if (digits.length >= 3) {
|
||||
const firstMinuteDigit = parseInt(digits[2]);
|
||||
if (firstMinuteDigit > 5) isValid = false;
|
||||
}
|
||||
if (digits.length >= 4) {
|
||||
const minutes = parseInt(digits[2] + digits[3]);
|
||||
if (minutes > 59) isValid = false;
|
||||
}
|
||||
|
||||
// Validate seconds (last 2 digits) if applicable
|
||||
if (format === "HH:MM:SS") {
|
||||
if (digits.length >= 5) {
|
||||
const firstSecondDigit = parseInt(digits[4]);
|
||||
if (firstSecondDigit > 5) isValid = false;
|
||||
}
|
||||
if (digits.length >= 6) {
|
||||
const seconds = parseInt(digits[4] + digits[5]);
|
||||
if (seconds > 59) isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
// Format the value with colons
|
||||
let formattedValue = "";
|
||||
if (newValue.length >= 2) {
|
||||
formattedValue = newValue.slice(0, 2);
|
||||
if (newValue.length >= 3) {
|
||||
formattedValue += ":" + newValue.slice(2, 4);
|
||||
if (format === "HH:MM:SS" && newValue.length >= 5) {
|
||||
formattedValue += ":" + newValue.slice(4, 6);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
formattedValue = newValue;
|
||||
}
|
||||
|
||||
onChange?.(formattedValue);
|
||||
};
|
||||
|
||||
// Determine placeholder characters for each slot
|
||||
const getSlotPlaceholder = (index: number): string => {
|
||||
if (index < 2) return "H";
|
||||
if (index < 4) return "M";
|
||||
return "S";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<Label className="gap-1 truncate text-xs font-medium">
|
||||
{label}
|
||||
{required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName="flex items-center gap-2 has-disabled:opacity-50"
|
||||
className="disabled:cursor-not-allowed"
|
||||
value={otpValue}
|
||||
onChange={handleChange}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
pattern="^[0-9]+$"
|
||||
inputMode="numeric"
|
||||
style={style}
|
||||
>
|
||||
<div data-slot="input-otp-group" className="flex items-center">
|
||||
<TimeSlot index={0} placeholder={getSlotPlaceholder(0)} />
|
||||
<TimeSlot index={1} placeholder={getSlotPlaceholder(1)} />
|
||||
</div>
|
||||
<div data-slot="input-otp-separator" role="separator" className="text-sm font-medium">
|
||||
:
|
||||
</div>
|
||||
<div data-slot="input-otp-group" className="flex items-center">
|
||||
<TimeSlot index={2} placeholder={getSlotPlaceholder(2)} />
|
||||
<TimeSlot index={3} placeholder={getSlotPlaceholder(3)} />
|
||||
</div>
|
||||
{format === "HH:MM:SS" && (
|
||||
<>
|
||||
<div data-slot="input-otp-separator" role="separator" className="text-sm font-medium">
|
||||
:
|
||||
</div>
|
||||
<div data-slot="input-otp-group" className="flex items-center">
|
||||
<TimeSlot index={4} placeholder={getSlotPlaceholder(4)} />
|
||||
<TimeSlot index={5} placeholder={getSlotPlaceholder(5)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</OTPInput>
|
||||
</div>
|
||||
{description && <p className="text-muted-foreground text-xs">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, ReactNode, Ref, RefObject, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import HintTooltip from "~/components/ui/tooltips/HintTooltip";
|
||||
import { translateLabel } from "~/utils/i18n/translateLabel";
|
||||
import CheckIcon from "../../icons/CheckIcon";
|
||||
import ExclamationTriangleIcon from "../../icons/ExclamationTriangleIcon";
|
||||
import { Input } from "../../input";
|
||||
import { LinkIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export interface RefInputUrl {
|
||||
input: RefObject<HTMLInputElement | HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
title?: string;
|
||||
withLabel?: boolean;
|
||||
defaultValue?: string | undefined;
|
||||
value?: string | undefined;
|
||||
setValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
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;
|
||||
onBlur?: () => void;
|
||||
borderless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
const InputUrl = (
|
||||
{
|
||||
name,
|
||||
title,
|
||||
withLabel = true,
|
||||
defaultValue,
|
||||
value,
|
||||
setValue,
|
||||
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,
|
||||
onBlur,
|
||||
borderless,
|
||||
autoFocus,
|
||||
}: Props,
|
||||
ref: Ref<RefInputUrl>
|
||||
) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useImperativeHandle(ref, () => ({ input }));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isValid, setIsValid] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isValid = value?.match(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
|
||||
setIsValid(isValid ? true : false);
|
||||
}, [value]);
|
||||
|
||||
function onChange(value: string) {
|
||||
if (setValue) {
|
||||
if (lowercase) {
|
||||
setValue(value.toLowerCase());
|
||||
} else if (uppercase) {
|
||||
setValue(value.toUpperCase());
|
||||
} else {
|
||||
setValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(className, !darkMode && "")}>
|
||||
{withLabel && (
|
||||
<label htmlFor={name} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
<div className="flex space-x-1 truncate">
|
||||
<div className="truncate">{title}</div>
|
||||
{required && title && <div className="ml-1 text-red-600">*</div>}
|
||||
</div>
|
||||
<div className="">{help && <HintTooltip text={help} />}</div>
|
||||
</div>
|
||||
{withTranslation && value?.includes(".") && (
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hint}
|
||||
</label>
|
||||
)}
|
||||
<div className={clsx("relative flex w-full rounded-md")}>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<LinkIcon className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
ref={input}
|
||||
type={type}
|
||||
id={name}
|
||||
name={name}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
pattern={pattern !== "" && pattern !== undefined ? pattern : undefined}
|
||||
autoFocus={autoFocus}
|
||||
className="pl-10"
|
||||
// className={clsx(
|
||||
// "focus:border-border focus:ring-ring block w-full min-w-0 flex-1 rounded-md border-border sm:text-sm",
|
||||
// className,
|
||||
// classNameBg,
|
||||
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "hover:bg-secondary focus:bg-secondary",
|
||||
// "px-10",
|
||||
// borderless && "border-transparent",
|
||||
// !(disabled || readOnly) &&
|
||||
// clsx(
|
||||
// isValid
|
||||
// ? "border-teal-500 focus:border-teal-500 focus:ring-teal-500"
|
||||
// : value
|
||||
// ? "focus:border-border focus:ring-ring border-red-300"
|
||||
// : "focus:border-border focus:ring-ring border-border"
|
||||
// )
|
||||
// )}
|
||||
/>
|
||||
{!(disabled || readOnly) && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
{isValid ? <CheckIcon className="h-4 w-4 text-teal-500" /> : value ? <ExclamationTriangleIcon className="h-4 w-4 text-red-600" /> : null}
|
||||
</div>
|
||||
)}
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default forwardRef(InputUrl);
|
||||
Reference in new issue
Block a user