Primitives.tsx 8.18 KB
// Bespoke primitives that mirror prototype/src/primitives.jsx exactly so
// screens like UserDetail keep pixel-level fidelity to the prototype rather
// than picking up AntD's default chrome.

import React from "react";

const fieldStyles = {
  label: {
    width: 88,
    flex: "none" as const,
    textAlign: "right" as const,
    paddingRight: 8,
    color: "var(--text)",
    fontSize: 12,
    lineHeight: "26px",
    whiteSpace: "nowrap" as const,
    overflow: "hidden" as const,
    textOverflow: "ellipsis" as const,
  },
  control: {
    flex: 1,
    minWidth: 0,
    height: "var(--input-h)",
    border: "1px solid var(--border-input)",
    background: "var(--bg-input)",
    padding: "0 6px",
    fontSize: 12,
    color: "var(--text)",
    borderRadius: 0,
    outline: "none",
    fontFamily: "inherit",
  },
  controlDisabled: { background: "var(--bg-disabled)", color: "var(--text-muted)" },
  controlReq: { background: "#d4e8f7" }, // light cyan tint matching prototype screenshots
};

interface FieldProps {
  label: string;
  required?: boolean;
  children: React.ReactNode;
  labelWidth?: number;
}

export function Field({ label, required, children, labelWidth }: FieldProps) {
  return (
    <div style={{ display: "flex", alignItems: "center", minWidth: 0 }}>
      <div
        style={{
          ...fieldStyles.label,
          width: labelWidth ?? fieldStyles.label.width,
          ...(required ? { color: "var(--required)" } : {}),
        }}
        title={label}
      >
        {required ? <span style={{ marginRight: 2 }}>*</span> : null}
        {label}:
      </div>
      {children}
    </div>
  );
}

interface InputProps {
  value: string;
  onChange?: (v: string) => void;
  disabled?: boolean;
  required?: boolean;
  placeholder?: string;
  mono?: boolean;
}

export function PrimInput({ value, onChange, disabled, required, placeholder, mono }: InputProps) {
  return (
    <input
      type="text"
      value={value ?? ""}
      onChange={onChange ? (e) => onChange(e.target.value) : undefined}
      disabled={disabled}
      placeholder={placeholder}
      style={{
        ...fieldStyles.control,
        ...(disabled ? fieldStyles.controlDisabled : {}),
        ...(required && !disabled ? fieldStyles.controlReq : {}),
        ...(mono
          ? { fontFamily: '"JetBrains Mono", Menlo, Consolas, monospace', fontSize: 11 }
          : {}),
      }}
    />
  );
}

interface SelectOption {
  value: string;
  label: string;
}

interface SelectProps {
  value: string;
  onChange?: (v: string) => void;
  disabled?: boolean;
  required?: boolean;
  options: (string | SelectOption)[];
  allowEmpty?: boolean;
}

export function PrimSelect({
  value,
  onChange,
  disabled,
  required,
  options,
  allowEmpty = true,
}: SelectProps) {
  return (
    <div style={{ position: "relative", flex: 1, minWidth: 0 }}>
      <select
        value={value ?? ""}
        onChange={onChange ? (e) => onChange(e.target.value) : undefined}
        disabled={disabled}
        style={{
          ...fieldStyles.control,
          ...(disabled ? fieldStyles.controlDisabled : {}),
          ...(required && !disabled ? fieldStyles.controlReq : {}),
          appearance: "none",
          paddingRight: 22,
          width: "100%",
        }}
      >
        {allowEmpty ? <option value="" /> : null}
        {options.map((o) => {
          const v = typeof o === "string" ? o : o.value;
          const l = typeof o === "string" ? o : o.label;
          return (
            <option key={v} value={v}>
              {l}
            </option>
          );
        })}
      </select>
      <div
        style={{
          position: "absolute",
          right: 6,
          top: "50%",
          transform: "translateY(-50%)",
          color: "var(--text-faint)",
          pointerEvents: "none",
          fontSize: 9,
        }}
      >

      </div>
    </div>
  );
}

interface CheckboxProps {
  checked: boolean;
  onChange?: (v: boolean) => void;
  disabled?: boolean;
  label?: string;
  size?: number;
}

export function PrimCheckbox({ checked, onChange, disabled, label, size = 13 }: CheckboxProps) {
  return (
    <label
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 4,
        cursor: disabled ? "not-allowed" : "pointer",
        userSelect: "none",
        color: "var(--text)",
        fontSize: 12,
      }}
    >
      <span
        onClick={disabled ? undefined : () => onChange?.(!checked)}
        style={{
          width: size,
          height: size,
          border: "1px solid var(--border-strong)",
          background: checked ? "var(--accent)" : "#fff",
          borderColor: checked ? "var(--accent)" : "var(--border-strong)",
          display: "inline-flex",
          alignItems: "center",
          justifyContent: "center",
          cursor: disabled ? "not-allowed" : "pointer",
          flex: "none",
          opacity: disabled ? 0.6 : 1,
        }}
      >
        {checked ? (
          <svg width={size - 4} height={size - 4} viewBox="0 0 10 10" fill="none">
            <path
              d="M2 5l2 2 4-4"
              stroke="#fff"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        ) : null}
      </span>
      {label != null ? <span>{label}</span> : null}
    </label>
  );
}

interface ToolbarBtnDarkProps {
  children: React.ReactNode;
  icon?: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  danger?: boolean;
}

export function ToolbarBtnDark({
  children,
  icon,
  onClick,
  disabled,
  danger,
}: ToolbarBtnDarkProps) {
  const [hover, setHover] = React.useState(false);
  return (
    <button
      onClick={disabled ? undefined : onClick}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      disabled={disabled}
      style={{
        height: 26,
        padding: "0 10px",
        display: "inline-flex",
        alignItems: "center",
        gap: 4,
        background: hover && !disabled ? "rgba(255,255,255,0.08)" : "transparent",
        border: "1px solid transparent",
        color: disabled
          ? "var(--text-on-dark-muted)"
          : danger && hover
            ? "#ff8b8b"
            : "var(--text-on-dark)",
        cursor: disabled ? "not-allowed" : "pointer",
        fontSize: 12,
        borderRadius: 2,
        whiteSpace: "nowrap",
        fontFamily: "inherit",
      }}
    >
      {icon ? <span style={{ display: "inline-flex" }}>{icon}</span> : null}
      {children}
    </button>
  );
}

interface ToolbarBtnLightProps {
  children: React.ReactNode;
  icon?: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  primary?: boolean;
  danger?: boolean;
  success?: boolean;
}

export function ToolbarBtnLight({
  children,
  icon,
  onClick,
  disabled,
  primary,
  danger,
  success,
}: ToolbarBtnLightProps) {
  const [hover, setHover] = React.useState(false);
  let bg = "#fff",
    border = "var(--border-input)",
    color = "var(--text)";
  if (primary) {
    bg = hover ? "#3a9ae8" : "#5cabe8";
    border = "#3a8ad6";
    color = "#fff";
  } else if (danger) {
    bg = hover ? "#e85a5a" : "#f07070";
    border = "#d04141";
    color = "#fff";
  } else if (success) {
    bg = hover ? "#38b777" : "#4cc488";
    border = "#27a567";
    color = "#fff";
  } else if (hover && !disabled) {
    bg = "var(--accent-soft)";
    border = "var(--accent)";
  }
  return (
    <button
      onClick={disabled ? undefined : onClick}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      disabled={disabled}
      style={{
        height: "var(--tb-h)",
        padding: "0 10px",
        display: "inline-flex",
        alignItems: "center",
        gap: 4,
        background: disabled ? "var(--bg-disabled)" : bg,
        border: `1px solid ${disabled ? "var(--border)" : border}`,
        color: disabled ? "var(--text-faint)" : color,
        cursor: disabled ? "not-allowed" : "pointer",
        fontSize: 12,
        fontWeight: primary || danger || success ? 500 : 400,
        borderRadius: 2,
        whiteSpace: "nowrap",
        fontFamily: "inherit",
      }}
    >
      {icon ? <span style={{ display: "inline-flex" }}>{icon}</span> : null}
      {children}
    </button>
  );
}