Vectrum
Sign In
147 lines · 5.1 KB · TypeScript
"use client";

import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { Field, FormField, IconButton, Radio, RadioGroup, Textarea, Tooltip, useToast } from "@unlocalhosted/metalui";
import { MorphIcon } from "@unlocalhosted/metalui/icons";

/*
 * MetalUI's form anatomy for the settings, auth and new-repository forms: the label above, 6 apart,
 * the control, then its description in meta ink3 (FormField), around MetalUI's regular Field and
 * Textarea. Every control keeps its `name`, so the forms still post to their server actions.
 */

type Words = { label: ReactNode; description?: ReactNode; optional?: boolean; className?: string };

function FieldLabel({ label, optional }: { label: ReactNode; optional?: boolean }) {
  return (
    <FormField.Label>
      {label}
      {optional && <span className="text-ink3"> · optional</span>}
    </FormField.Label>
  );
}

/** A one-line text field: MetalUI's Field at the form size (32, radius 11) with its words. */
export function TextField({
  label,
  description,
  optional,
  className,
  icon,
  mono,
  ...input
}: Words & { icon?: ReactNode; mono?: boolean } & Omit<ComponentProps<"input">, "ref" | "className">) {
  return (
    <FormField className={className} disabled={input.disabled}>
      <FieldLabel label={label} optional={optional} />
      <Field size="regular" disabled={input.disabled}>
        {icon && <Field.Icon>{icon}</Field.Icon>}
        <Field.Input {...input} className={mono ? "font-mono!" : undefined} />
      </Field>
      {description && <FormField.Description>{description}</FormField.Description>}
    </FormField>
  );
}

/** Several lines: MetalUI's Textarea (it grows with what is written) with its words. */
export function TextareaField({
  label,
  description,
  optional,
  className,
  mono,
  ...textarea
}: Words & { mono?: boolean } & Omit<ComponentProps<typeof Textarea>, "ref" | "className">) {
  return (
    <FormField className={className}>
      <FieldLabel label={label} optional={optional} />
      <Textarea
        {...textarea}
        className={
          mono
            ? "[&_.mu-textarea-input]:font-mono [&_.mu-textarea-input]:text-[12px] [&_.mu-textarea-input]:leading-[18px] [&_.mu-textarea-mirror]:font-mono [&_.mu-textarea-mirror]:text-[12px] [&_.mu-textarea-mirror]:leading-[18px] [&_.mu-textarea-mirror]:break-all [&_.mu-textarea-input]:break-all"
            : undefined
        }
      />
      {description && <FormField.Description>{description}</FormField.Description>}
    </FormField>
  );
}

export type Choice<V extends string> = { value: V; title: string; description?: ReactNode; icon?: ReactNode };

/**
 * One of a few choices that need explaining (visibility, a token's permission): MetalUI's RadioGroup,
 * each option a pane on a sunk track; the chosen one rides the raised thumb, as on a switcher.
 */
export function ChoiceCards<V extends string>({
  name,
  choices,
  value,
  defaultValue,
  onValueChange,
  columns = 2,
  ...aria
}: {
  name: string;
  choices: Choice<V>[];
  value?: V;
  defaultValue?: V;
  onValueChange?: (value: V) => void;
  columns?: 1 | 2;
  "aria-label"?: string;
  "aria-labelledby"?: string;
}) {
  return (
    <RadioGroup
      name={name}
      value={value}
      defaultValue={defaultValue}
      onValueChange={(v) => onValueChange?.(v as V)}
      className={`grid gap-1 rounded-plate recipe-well-track p-1 ${columns === 2 ? "sm:grid-cols-2" : ""}`}
      {...aria}
    >
      {choices.map((c) => (
        <Radio
          key={c.value}
          value={c.value}
          className="w-full items-start! rounded-[14px] px-3.5 py-3 transition-[background,box-shadow] duration-150 has-data-checked:material-thumb [&>.mu-radio-well]:mt-px"
        >
          <span className="flex min-w-0 gap-3">
            {c.icon && <span className="mt-px shrink-0 text-ink2 [&_svg]:size-4">{c.icon}</span>}
            <span className="flex min-w-0 flex-col gap-0.5">
              <span className="type-title text-ink">{c.title}</span>
              {c.description && <span className="type-body text-ink2">{c.description}</span>}
            </span>
          </span>
        </Radio>
      ))}
    </RadioGroup>
  );
}

/**
 * Copies a value: MetalUI's ghost IconButton whose glyph morphs duplicate → check while it is copied,
 * named by its tooltip. With `toast`, the copy is also confirmed in the toast deck.
 */
export function CopyIconButton({ text, label = "Copy", toast }: { text: string; label?: string; toast?: string }) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const toasts = useToast();
  useEffect(() => () => clearTimeout(timer.current), []);
  return (
    <Tooltip label={copied ? "Copied" : label}>
      <IconButton
        label={label}
        icon={<MorphIcon name={copied ? "check" : "duplicate"} />}
        onClick={async () => {
          await navigator.clipboard.writeText(text);
          setCopied(true);
          if (toast) toasts.show({ title: toast, tone: "success" });
          clearTimeout(timer.current);
          timer.current = setTimeout(() => setCopied(false), 1500);
        }}
      />
    </Tooltip>
  );
}