Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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)}
>
&#10005;
</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)}
>
&#10005;
</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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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_:
![Example](https://placehold.co/1200x630?text=${encodeURIComponent(t("shared.preview"))})
`
);
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>
);
}
@@ -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);