marmar-context / web / context / src / clinician / kit / Field.tsx
Field.tsx
Raw
import type { ComponentProps, ReactNode } from "react";
import { cn } from "./cn";
import { Icon } from "./Icon";

/** Text inputs, selects and text areas: a hairline ring, a green ring on focus, a red one when invalid. */
export const fieldClass =
  "h-[38px] min-h-0 w-full rounded-[10px] border-0 bg-page px-3 py-0 font-sans text-[14px] text-ink shadow-[inset_0_0_0_1px_var(--color-line)] outline-none transition-shadow duration-150 placeholder:text-subtle hover:shadow-[inset_0_0_0_1px_var(--color-line-strong)] focus-visible:shadow-[inset_0_0_0_1.5px_var(--color-brand),0_0_0_4px_rgba(44,194,149,0.15)] aria-[invalid=true]:shadow-[inset_0_0_0_1.5px_#f0938c,0_0_0_4px_rgba(229,56,59,0.1)] disabled:cursor-not-allowed disabled:bg-tile disabled:text-subtle disabled:shadow-[inset_0_0_0_1px_var(--color-hairline)]";

/** A text area keeps the field ring but grows with its rows. */
export const areaClass = cn(fieldClass, "h-auto py-2.5 leading-5");

/** A label over its field. */
export const labelClass = "flex min-w-0 flex-col gap-1.5 text-[12.5px] font-medium text-cell";

/** Checkboxes: a quiet box that turns green when chosen. */
export const checkClass =
  "mt-0.5 size-4 shrink-0 cursor-pointer rounded-[4px] border-line checked:border-action checked:bg-action checked:bg-none focus-visible:shadow-[0_0_0_2px_#fff,0_0_0_4px_var(--color-brand)] disabled:cursor-not-allowed";

/** A plain-text button that reads as a link. */
export const linkButtonClass =
  "inline-flex cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent p-0 font-sans font-medium text-ink underline decoration-line underline-offset-4 outline-none hover:decoration-ink focus-visible:shadow-[0_0_0_2px_var(--color-brand)] disabled:cursor-not-allowed disabled:opacity-40";

/** The quiet line under a field, and the red one that replaces it when the field is wrong. */
export const Hint = ({ id, children, className }: { id?: string; children: ReactNode; className?: string }) => (
  <small id={id} className={cn("block text-[12.5px] leading-[18px] font-normal text-muted", className)}>
    {children}
  </small>
);
export const FieldError = ({ id, children, className }: { id?: string; children: ReactNode; className?: string }) => (
  <small id={id} role="alert" className={cn("flex items-start gap-1 text-[12.5px] leading-[18px] font-normal text-red-text", className)}>
    <Icon name="error-warning-fill" size={14} className="mt-0.5 text-red-icon" />
    {children}
  </small>
);

/** A label over its control, with a hint or an error under it. The control names `${id}-hint` or `${id}-error`. */
export function Field({ id, label, hint, error, children, className }: { id: string; label: ReactNode; hint?: ReactNode; error?: string; children: ReactNode; className?: string }) {
  return (
    <div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
      <label htmlFor={id} className="text-[12.5px] font-medium text-cell">
        {label}
      </label>
      {children}
      {hint && !error && <Hint id={`${id}-hint`}>{hint}</Hint>}
      {error && <FieldError id={`${id}-error`}>{error}</FieldError>}
    </div>
  );
}

export const Input = ({ className, ...props }: ComponentProps<"input">) => <input className={cn(fieldClass, className)} {...props} />;
export const Textarea = ({ className, ...props }: ComponentProps<"textarea">) => <textarea className={cn(areaClass, className)} {...props} />;

/** A native select with the field ring and a Remix chevron. */
export function Select({ className, children, ...props }: ComponentProps<"select">) {
  return (
    <span className={cn("relative flex w-full min-w-0", className)}>
      <select className={cn(fieldClass, "cursor-pointer appearance-none bg-none pr-9")} {...props}>
        {children}
      </select>
      <Icon name="arrow-down-s-line" size={16} className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-muted" />
    </span>
  );
}

/** A checkbox with its words beside it; the whole line is the target. */
export function Checkbox({ children, className, ...props }: Omit<ComponentProps<"input">, "type"> & { children: ReactNode }) {
  return (
    <label className={cn("flex cursor-pointer items-start gap-2.5 text-[13.5px] leading-5 text-body has-disabled:cursor-not-allowed has-disabled:text-subtle", className)}>
      <input type="checkbox" className={checkClass} {...props} />
      <span className="min-w-0">{children}</span>
    </label>
  );
}

/** A radio with its words beside it, for a short list of choices that each need a sentence. */
export function Radio({ children, className, ...props }: Omit<ComponentProps<"input">, "type"> & { children: ReactNode }) {
  return (
    <label className={cn("flex cursor-pointer items-start gap-2.5 text-[13.5px] leading-5 text-body has-disabled:cursor-not-allowed has-disabled:text-subtle", className)}>
      <input type="radio" className="mt-0.5 size-4 shrink-0 cursor-pointer accent-[var(--color-action)] focus-visible:shadow-[0_0_0_2px_#fff,0_0_0_4px_var(--color-brand)]" {...props} />
      <span className="min-w-0">{children}</span>
    </label>
  );
}

/** One choice of a few, as chips: a radio group with a visible legend. The chosen chip is ink. */
export function ChoiceChips<T extends string | number>({
  legend,
  name,
  options,
  value,
  onChange,
  disabled,
  describedBy,
  className,
}: {
  legend: ReactNode;
  name: string;
  options: { value: T; label: ReactNode }[];
  value: T | null | undefined;
  onChange: (v: T) => void;
  disabled?: boolean;
  describedBy?: string;
  className?: string;
}) {
  return (
    <fieldset disabled={disabled} aria-describedby={describedBy} className={cn("m-0 flex min-w-0 flex-col gap-2 border-0 p-0", className)}>
      <legend className="mb-2 p-0 text-[12.5px] font-medium text-cell">{legend}</legend>
      <div className="flex flex-wrap gap-1.5">
        {options.map((o) => (
          <label
            key={String(o.value)}
            className={cn(
              "inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full px-3.5 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-150 select-none has-focus-visible:shadow-[0_0_0_2px_#fff,0_0_0_4px_var(--color-brand)] has-disabled:cursor-not-allowed has-disabled:opacity-50",
              o.value === value ? "bg-ink text-white" : "bg-page text-body shadow-[inset_0_0_0_1px_var(--color-line)] hover:bg-tile hover:text-ink",
            )}
          >
            <input type="radio" name={name} className="sr-only" checked={o.value === value} onChange={() => onChange(o.value)} />
            {o.value === value && <Icon name="check-line" size={14} />}
            {o.label}
          </label>
        ))}
      </div>
    </fieldset>
  );
}

/** The search field from the States board: a hairline ring that turns brand green on focus. */
export function SearchField({ value, onChange, label, placeholder, className }: { value: string; onChange: (v: string) => void; label: string; placeholder: string; className?: string }) {
  return (
    <label
      className={cn(
        "flex h-[38px] items-center gap-2 rounded-[10px] bg-page px-3 text-subtle shadow-[inset_0_0_0_1px_var(--color-line)] transition-shadow focus-within:shadow-[inset_0_0_0_1.5px_var(--color-brand),0_0_0_4px_rgba(44,194,149,0.15)]",
        className,
      )}
    >
      <Icon name="search-line" size={16} />
      <span className="sr-only">{label}</span>
      <input
        type="search"
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        className="min-h-0 w-full min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 font-sans text-[13.5px] text-ink shadow-none outline-none placeholder:text-subtle hover:border-0 focus-visible:shadow-none"
      />
    </label>
  );
}