SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
3.1 KB · 97 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Plus, X } from "lucide-react";4import { Button } from "@/components/ui/button";5import { Input } from "@/components/ui/input";6import { newKv, type KV } from "./types";78/** Editable key/value rows (headers, query params, cookies). */9export function KvRows({10  rows,11  onChange,12  keyPlaceholder = "Name",13  valuePlaceholder = "Value",14  addLabel = "Add",15  label,16  secret,17}: {18  rows: KV[];19  onChange: (rows: KV[]) => void;20  keyPlaceholder?: string;21  valuePlaceholder?: string;22  addLabel?: string;23  /** Accessible label prefix for inputs. */24  label: string;25  /** Mask values (e.g. cookies). */26  secret?: boolean;27}) {28  const update = (id: string, patch: Partial<KV>) => onChange(rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));29  const remove = (id: string) => onChange(rows.filter((r) => r.id !== id));30  const add = () => onChange([...rows, newKv()]);31  const lastKeyRef = React.useRef<HTMLInputElement | null>(null);32  const [focusNew, setFocusNew] = React.useState(false);3334  React.useEffect(() => {35    if (focusNew && lastKeyRef.current) {36      lastKeyRef.current.focus();37      setFocusNew(false);38    }39  }, [focusNew, rows.length]);4041  return (42    <div className="grid gap-1.5">43      {rows.length === 0 ? <p className="text-xs text-fg-subtle">None.</p> : null}44      {rows.map((r, i) => (45        <div key={r.id} className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)_auto] items-center gap-1.5">46          <Input47            ref={i === rows.length - 1 ? lastKeyRef : undefined}48            value={r.key}49            onChange={(e) => update(r.id, { key: e.target.value })}50            placeholder={keyPlaceholder}51            aria-label={`${label} ${i + 1} name`}52            className="h-8 font-mono text-[12.5px]"53            autoCapitalize="off"54            autoCorrect="off"55            spellCheck={false}56          />57          <Input58            value={r.value}59            type={secret ? "password" : "text"}60            onChange={(e) => update(r.id, { value: e.target.value })}61            placeholder={valuePlaceholder}62            aria-label={`${label} ${i + 1} value`}63            className="h-8 font-mono text-[12.5px]"64            autoCapitalize="off"65            autoCorrect="off"66            spellCheck={false}67            onKeyDown={(e) => {68              if (e.key === "Enter" && i === rows.length - 1 && r.key.trim()) {69                e.preventDefault();70                add();71                setFocusNew(true);72              }73            }}74          />75          <Button type="button" variant="ghost" size="icon-sm" className="size-8 text-fg-subtle hover:text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${label.toLowerCase()} ${i + 1}`}>76            <X />77          </Button>78        </div>79      ))}80      <div>81        <Button82          type="button"83          variant="ghost"84          size="xs"85          className="-ml-1 text-fg-muted"86          onClick={() => {87            add();88            setFocusNew(true);89          }}90        >91          <Plus className="size-3.5" /> {addLabel}92        </Button>93      </div>94    </div>95  );96}97