marmar-context / web / context / src / clinician / kit / Layout.tsx
Layout.tsx
Raw
import type { HTMLAttributes, KeyboardEvent, ReactNode } from "react";
import { useEffect, useRef } from "react";
import { Link } from "../router";
import { Count, Dot, type Tone } from "./Badge";
import { cn } from "./cn";
import { Icon, type IconName } from "./Icon";

/** Section title with its count and an optional "See all". */
export function SectionHeader({ title, count, action, to, className }: { title: ReactNode; count?: number; action?: ReactNode; to?: string; className?: string }) {
  return (
    <div className={cn("flex items-center justify-between gap-3", className)}>
      <h2 className="m-0 flex items-center gap-2 text-[15px] leading-5 font-semibold tracking-[-0.01em] text-ink">
        {title}
        {count !== undefined && <Count>{count}</Count>}
      </h2>
      {action ??
        (to && (
          <Link to={to} className="text-[13px] font-medium text-muted no-underline hover:text-ink">
            See all
          </Link>
        ))}
    </div>
  );
}

/** Label over value, no box. A tail ("of 84", ".5%") stays quiet. */
export function Stat({ label, value, tail, icon, tone, className }: { label: ReactNode; value: ReactNode; tail?: ReactNode; icon?: IconName; tone?: string; className?: string }) {
  return (
    <div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
      <span className="flex items-center gap-1.5 text-[12.5px] text-muted">
        {icon && <Icon name={icon} size={14} className={tone ?? "text-subtle"} />}
        {label}
      </span>
      <span className="truncate text-[15px] leading-5 font-medium text-ink tabular-nums">
        {value}
        {tail && <span className="font-normal text-subtle"> {tail}</span>}
      </span>
    </div>
  );
}

export type TabItem = { id: string; label: string; to: string; count?: number; countLabel?: string; dot?: { tone: Tone; label: string } };

/**
 * Underline tabs, as links so each tab has its own address. A count can carry words for a screen reader
 * ("3 unread notes") and a dot can mark something new. On a phone they scroll sideways and keep the open one in view.
 */
export function UnderlineTabs({ items, active, label, className }: { items: TabItem[]; active: string; label: string; className?: string }) {
  const ref = useRef<HTMLElement>(null);
  useEffect(() => {
    const nav = ref.current;
    const open = nav?.querySelector<HTMLElement>('[aria-current="page"]');
    if (!nav || !open) return;
    const [n, o] = [nav.getBoundingClientRect(), open.getBoundingClientRect()];
    if (o.right > n.right || o.left < n.left) nav.scrollLeft += o.left - n.left - 16;
  }, [active]);
  return (
    <nav
      ref={ref}
      aria-label={label}
      className={cn("flex gap-[22px] overflow-x-auto shadow-[inset_0_-1px_0_var(--color-hairline)] [scrollbar-width:none] max-sm:gap-[18px]", className)}
    >
      {items.map((t) => {
        const on = t.id === active;
        return (
          <Link
            key={t.id}
            to={t.to}
            aria-current={on ? "page" : undefined}
            className={cn(
              "relative flex h-10 shrink-0 items-center gap-1.5 rounded-sm px-0.5 text-[14px] font-medium whitespace-nowrap no-underline transition-colors duration-150 outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--color-brand)]",
              on ? "text-ink after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full after:bg-ink" : "text-muted hover:text-ink",
            )}
          >
            {t.label}
            {t.count !== undefined && t.count > 0 && (
              <>
                <Count aria-hidden={t.countLabel ? true : undefined} className={on ? "bg-ink text-white" : undefined}>
                  {t.count}
                </Count>
                {t.countLabel && <span className="sr-only">, {t.countLabel}</span>}
              </>
            )}
            {t.dot && <Dot tone={t.dot.tone} role="img" aria-label={t.dot.label} />}
          </Link>
        );
      })}
    </nav>
  );
}

/** Pill segmented control for small view switches (Week / 30 days). Arrow keys move the choice, as in any radio group. */
export function Segmented<T extends string>({ options, value, onChange, label }: { options: { value: T; label: string }[]; value: T; onChange: (v: T) => void; label: string }) {
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
    if (!step) return;
    e.preventDefault();
    const next = options[(options.findIndex((o) => o.value === value) + step + options.length) % options.length];
    onChange(next.value);
    e.currentTarget.querySelector<HTMLElement>(`[data-value="${CSS.escape(next.value)}"]`)?.focus();
  };
  return (
    <div role="radiogroup" aria-label={label} onKeyDown={onKeyDown} className="inline-flex h-8 items-center gap-0.5 rounded-full bg-quiet p-[3px]">
      {options.map((o) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          data-value={o.value}
          aria-checked={o.value === value}
          tabIndex={o.value === value ? 0 : -1}
          onClick={() => onChange(o.value)}
          className={cn(
            "h-[26px] cursor-pointer rounded-full border-0 px-3 font-sans text-[12.5px] font-medium transition-[background-color,color,box-shadow] outline-none focus-visible:shadow-[0_0_0_2px_var(--color-brand)]",
            o.value === value ? "bg-page text-ink shadow-chip" : "bg-transparent text-muted hover:text-ink",
          )}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}

/** Empty state: a faint sketch of what will appear, one line, one link. */
export function EmptyState({ title, body, action, className }: { title: string; body?: ReactNode; action?: ReactNode; className?: string }) {
  return (
    <div className={cn("flex flex-col items-center gap-3 rounded-2xl bg-tile px-6 py-10 text-center", className)}>
      <div aria-hidden="true" className="flex w-44 flex-col gap-1.5">
        {[0.9, 0.65, 0.8].map((w, i) => (
          <span key={i} className="flex items-center gap-2 rounded-lg bg-page px-2.5 py-2 shadow-card">
            <span className="size-3 rounded-full bg-hairline" />
            <span className="h-1.5 rounded-full bg-hairline" style={{ width: `${w * 100}%` }} />
          </span>
        ))}
      </div>
      <p className="m-0 text-[14px] font-medium text-ink">{title}</p>
      {body && <p className="m-0 max-w-sm text-[13px] text-muted">{body}</p>}
      {action}
    </div>
  );
}

/** A white card on the page: hairline ring, no border. */
export function Card({
  children,
  className,
  as: As = "section",
  ...rest
}: { children: ReactNode; className?: string; as?: "section" | "div" | "article" } & Pick<HTMLAttributes<HTMLElement>, "id" | "aria-label" | "aria-labelledby" | "role">) {
  return (
    <As className={cn("rounded-2xl bg-page p-5 shadow-card", className)} {...rest}>
      {children}
    </As>
  );
}

/** A card with a title row: an optional coloured icon, a quiet note and actions on the right. Flush drops the body padding for rows. */
export function Panel({
  title,
  icon,
  iconClass = "text-muted",
  note,
  actions,
  flush,
  children,
  className,
  id,
  label,
}: {
  title: ReactNode;
  icon?: IconName | ReactNode;
  iconClass?: string;
  note?: ReactNode;
  actions?: ReactNode;
  flush?: boolean;
  children: ReactNode;
  className?: string;
  id?: string;
  label?: string;
}) {
  return (
    <section id={id} aria-label={label ?? (typeof title === "string" ? title : undefined)} className={cn("flex min-w-0 flex-col rounded-2xl bg-page shadow-card", className)}>
      <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-5 pt-4 pb-3">
        {typeof icon === "string" ? <Icon name={icon as IconName} size={18} className={iconClass} /> : icon}
        <h2 className="m-0 text-[15px] leading-5 font-semibold tracking-[-0.01em] text-ink">{title}</h2>
        {note && <span className="text-[12.5px] text-muted">{note}</span>}
        {actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
      </div>
      <div className={cn("flex min-w-0 flex-col", flush ? "pb-2" : "gap-4 px-5 pb-5")}>{children}</div>
    </section>
  );
}