import { useId, useState, type ChangeEvent, type InputHTMLAttributes, type ReactNode } from "react";
import { MagnifyingGlass, WarningCircle, X } from "@phosphor-icons/react";
import { cn } from "../../lib/cn";
export type FieldMeta = {
error?: string;
hint?: string;
label?: string;
prefixIcon?: ReactNode;
suffixIcon?: ReactNode;
suffixInteractive?: boolean;
};
export type FormFieldProps = FieldMeta & {
children: ReactNode;
className?: string;
};
export function fieldClassName(hasError?: boolean) {
return cn(
"h-[var(--uhuru-control-height)] rounded-[var(--uhuru-radius-control)] border border-[var(--uhuru-border-default)] bg-[var(--uhuru-surface-default)] px-[var(--uhuru-control-padding-x)] text-sm text-[var(--uhuru-text-primary)] outline-none transition placeholder:text-[var(--uhuru-text-tertiary)] focus:border-[var(--uhuru-border-accent)] focus:ring-1 focus:ring-[var(--uhuru-border-accent)]",
hasError ? "border-[var(--uhuru-error-border)] focus:ring-[var(--uhuru-error-border)]" : "",
);
}
export function fieldNote(error?: string, hint?: string, hintId?: string, errorId?: string, onDismiss?: () => void) {
if (error) {
return (
{error}
{onDismiss ? : null}
);
}
if (hint) {
return (
{hint}
);
}
return null;
}
type PrefixFieldShellProps = {
children: ReactNode;
className?: string;
prefixIcon?: ReactNode;
suffixIcon?: ReactNode;
suffixInteractive?: boolean;
};
export function PrefixFieldShell({
children,
className,
prefixIcon,
suffixIcon,
suffixInteractive = false,
}: PrefixFieldShellProps) {
if (!prefixIcon && !suffixIcon) {
return <>{children}>;
}
return (
{prefixIcon ? (
{prefixIcon}
) : null}
{children}
{suffixIcon ? (
{suffixIcon}
) : null}
);
}
export function SearchGlyph() {
return ;
}
export function SearchlessInput({
className,
error,
hint,
id,
label,
prefixIcon,
suffixIcon,
...props
}: InputHTMLAttributes & FieldMeta) {
const generatedId = useId();
const fieldId = id ?? generatedId;
const hintId = hint ? `${fieldId}-hint` : undefined;
const errorId = error ? `${fieldId}-error` : undefined;
return (
);
}
export function padTimeUnit(value: number) {
return String(value).padStart(2, "0");
}
export function parseDateValue(value?: string | number | readonly string[]) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const [year, month, day] = value.split("-").map(Number);
const date = new Date(year, month - 1, day);
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatDateValue(date: Date) {
return `${date.getFullYear()}-${padTimeUnit(date.getMonth() + 1)}-${padTimeUnit(date.getDate())}`;
}
export function formatDateLabel(value?: string | number | readonly string[], placeholder = "dd/mm/yyyy") {
const date = parseDateValue(value);
if (!date) {
return placeholder;
}
return `${padTimeUnit(date.getDate())}/${padTimeUnit(date.getMonth() + 1)}/${date.getFullYear()}`;
}
export function parseTimeValue(value?: string | number | readonly string[]) {
if (typeof value !== "string" || !/^\d{2}:\d{2}$/.test(value)) {
return null;
}
const [hours, minutes] = value.split(":").map(Number);
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return null;
}
return { hours, minutes };
}
export function formatTimeValue(hours: number, minutes: number) {
return `${padTimeUnit(hours)}:${padTimeUnit(minutes)}`;
}
export function formatTimeLabel(value?: string | number | readonly string[], placeholder = "--:--") {
const time = parseTimeValue(value);
return time ? formatTimeValue(time.hours, time.minutes) : placeholder;
}
export function startOfMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
export function shiftMonth(date: Date, amount: number) {
return new Date(date.getFullYear(), date.getMonth() + amount, 1);
}
export function sameDay(left: Date | null, right: Date | null) {
if (!left || !right) {
return false;
}
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
export function compareDates(left: Date, right: Date) {
const leftValue = new Date(left.getFullYear(), left.getMonth(), left.getDate()).getTime();
const rightValue = new Date(right.getFullYear(), right.getMonth(), right.getDate()).getTime();
return leftValue - rightValue;
}
export function buildCalendarDays(month: Date) {
const firstDay = startOfMonth(month);
const mondayIndex = (firstDay.getDay() + 6) % 7;
const start = new Date(firstDay);
start.setDate(firstDay.getDate() - mondayIndex);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return date;
});
}
export function readStepMinutes(step?: string | number) {
const numericStep = typeof step === "number" ? step : Number(step);
if (!Number.isFinite(numericStep) || numericStep <= 0) {
return 1;
}
const minutes = numericStep >= 60 ? Math.round(numericStep / 60) : Math.round(numericStep);
return Math.min(60, Math.max(1, minutes));
}
export const MONTH_LABELS = Array.from({ length: 12 }, (_, index) =>
new Date(2026, index, 1).toLocaleString("en-US", { month: "short" }),
);
export const CALENDAR_WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"] as const;
export const PICKER_PANEL_RADIUS = "rounded-[min(var(--uhuru-radius-lg),1.1rem)]";
export const PICKER_ITEM_RADIUS = "rounded-[min(var(--uhuru-radius-control),0.9rem)]";
export const PICKER_TRACK_RADIUS = "rounded-[min(var(--uhuru-radius-lg),1.6rem)]";
export type PopupPlacement = {
horizontal: "left" | "right";
vertical: "bottom" | "top";
};
export function resolvePopupPlacement(anchorRect: DOMRect, panelRect: DOMRect): PopupPlacement {
const spaceBelow = window.innerHeight - anchorRect.bottom;
const spaceAbove = anchorRect.top;
const alignLeft = anchorRect.left + panelRect.width <= window.innerWidth;
return {
horizontal: alignLeft ? "left" : "right",
vertical: spaceBelow >= panelRect.height || spaceBelow >= spaceAbove ? "bottom" : "top",
};
}
export type UploadPreviewItem = {
error?: string;
file?: File;
name: string;
size: number;
type: string;
url?: string;
};
export function matchesAcceptedFileTypes(file: File, acceptedFileTypes?: string[]) {
if (!acceptedFileTypes || acceptedFileTypes.length === 0) {
return true;
}
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
return acceptedFileTypes.some((entry) => {
const normalized = entry.toLowerCase().trim();
if (!normalized) {
return false;
}
if (normalized.startsWith(".")) {
return extension === normalized.slice(1);
}
if (normalized.includes("/")) {
return file.type === normalized || (normalized.endsWith("/*") && file.type.startsWith(normalized.replace("/*", "/")));
}
return extension === normalized;
});
}
export function isPreviewableUpload(file: UploadPreviewItem) {
return Boolean(file.url) && (file.type.startsWith("image/") || file.type === "application/pdf");
}
export function UploadFileGlyph({ file }: { file: UploadPreviewItem }) {
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
if (file.type.startsWith("audio/") || ["mp3", "wav", "aac", "m4a"].includes(extension)) {
return AUD;
}
if (file.type.startsWith("video/") || ["mp4", "mov", "avi", "mkv"].includes(extension)) {
return VID;
}
if (["zip", "rar", "tar", "gz", "7z"].includes(extension)) {
return ZIP;
}
if (["ts", "tsx", "js", "jsx", "json", "dart", "css", "html", "md"].includes(extension)) {
return CODE;
}
if (file.type === "application/pdf" || extension === "pdf") {
return PDF;
}
if (file.type.includes("spreadsheet") || ["csv", "xls", "xlsx"].includes(extension)) {
return XLS;
}
if (file.type.includes("word") || ["doc", "docx"].includes(extension)) {
return DOC;
}
return (
);
}
export function formatUploadPreviewSize(size: number) {
if (!size) {
return null;
}
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
if (size >= 1024) {
return `${Math.round(size / 1024)} KB`;
}
return `${size} B`;
}
export function UploadPreviewChip({
file,
index,
isUploading,
onRemove,
}: {
file: UploadPreviewItem;
index: number;
isUploading?: boolean;
onRemove?: (index: number) => void;
}) {
const previewable = isPreviewableUpload(file);
const [previewOpen, setPreviewOpen] = useState(false);
return (
{
event.preventDefault();
event.stopPropagation();
}}
onFocus={() => previewable && setPreviewOpen(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPreviewOpen(false);
}
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onMouseEnter={() => previewable && setPreviewOpen(true)}
onMouseLeave={() => setPreviewOpen(false)}
>
{previewable ? (
{file.type.startsWith("image/") ? (
) : (
PDF
)}
) : (
)}
{file.name}
{formatUploadPreviewSize(file.size) ? (
{formatUploadPreviewSize(file.size)}
) : null}
{onRemove ? (
) : null}
{previewable ? (
{file.type.startsWith("image/") ? (
) : (
)}
{file.name}
) : null}
);
}
export function emitInputChange(
onChange: ((event: ChangeEvent) => void) | undefined,
nextValue: string,
name?: string,
) {
onChange?.({
target: { value: nextValue, name },
currentTarget: { value: nextValue, name },
} as ChangeEvent);
}