{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flags-1",
  "title": "Flags 1: feature flags manager",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A feature flags manager: a switch per environment, a production rollout slider, flags fully on for months marked ready to remove, a confirmation before changing production, and failed saves undone with the reason.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/alert-dialog.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/slider.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/switch.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/flags-1/flags-1.tsx",
      "content": "// Ballmac UI: Flags 1. https://ui.ballmac.com/blocks/flags-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown, Flag, Loader2, Search, ShieldAlert, Sparkles } from \"lucide-react\"\n\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"@/components/ballmac/alert-dialog\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { Slider } from \"@/components/ballmac/slider\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { cn } from \"@/lib/utils\"\n\ntype Flags1Env = \"dev\" | \"staging\" | \"production\"\n\ntype Flags1Flag = {\n  id: string\n  /** The key your code reads, such as \"checkout-v2\". */\n  key: string\n  description: string\n  enabled: Record<Flags1Env, boolean>\n  /** Share of production users who get it when it is on, 0 to 100. */\n  rollout: number\n  /** Who gets it first, in words: \"Staff, and the Beta group\". */\n  rules?: string\n  owner: string\n  /** Days since it last changed. */\n  changedDays: number\n}\n\ntype Flags1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    flags?: Flags1Flag[]\n    title?: string\n    /** The flag whose rollout and details are open at first. */\n    defaultOpenId?: string\n    /** Called when a switch or rollout is saved. Return a promise; reject and the change is undone with a message. */\n    onChange?: (flag: Flags1Flag) => void | Promise<void>\n  }\n\nconst sample: Flags1Flag[] = [\n  { id: \"f1\", key: \"checkout-v2\", description: \"The new one-page checkout\", enabled: { dev: true, staging: true, production: true }, rollout: 25, rules: \"Staff, and anyone in the Beta group\", owner: \"Priya Shah\", changedDays: 3 },\n  { id: \"f2\", key: \"ai-summaries\", description: \"AI summaries at the top of long threads\", enabled: { dev: true, staging: true, production: false }, rollout: 0, rules: \"Nobody yet\", owner: \"Theo Nakamura\", changedDays: 1 },\n  { id: \"f3\", key: \"dark-mode-default\", description: \"Follow the device setting on first visit\", enabled: { dev: true, staging: true, production: true }, rollout: 100, owner: \"Noor Haddad\", changedDays: 142 },\n  { id: \"f4\", key: \"bulk-export\", description: \"Export more than 10,000 rows at once\", enabled: { dev: true, staging: false, production: false }, rollout: 0, rules: \"Enterprise plans\", owner: \"Kofi Mensah\", changedDays: 9 },\n  { id: \"f5\", key: \"new-onboarding\", description: \"The three-step setup checklist\", enabled: { dev: true, staging: true, production: true }, rollout: 100, owner: \"Ana Souza\", changedDays: 118 },\n  { id: \"f6\", key: \"legacy-invoice-pdf\", description: \"The old invoice layout, kept for a few customers\", enabled: { dev: true, staging: true, production: true }, rollout: 8, rules: \"Accounts created before 2024\", owner: \"Oliver Reed\", changedDays: 61 },\n]\n\nconst envs: { id: Flags1Env; label: string }[] = [{ id: \"dev\", label: \"Development\" }, { id: \"staging\", label: \"Staging\" }, { id: \"production\", label: \"Production\" }]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3\">\n      {[0, 1, 2, 3, 4].map((i) => (\n        <Skeleton key={i} className=\"h-20 rounded-2xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * A feature flags manager: every flag with a switch for each environment, a rollout slider for production, who gets it first, and a flag that\n * has been fully on for months marked ready to remove. Anything that changes production asks first and says who it affects, and a change that\n * fails to save is undone with the reason. Search and filters narrow the list and the count follows.\n */\nfunction Flags1({ flags: flagsProp = sample, title = \"Feature flags\", defaultOpenId, onChange, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Flags1Props) {\n  const id = React.useId()\n  const [flags, setFlags] = React.useState<Flags1Flag[]>(flagsProp)\n  React.useEffect(() => setFlags(flagsProp), [flagsProp])\n  const list = state === \"empty\" ? [] : flags\n  const [query, setQuery] = React.useState(\"\")\n  const [filter, setFilter] = React.useState<\"all\" | \"live\" | \"stale\">(\"all\")\n  const [open, setOpen] = React.useState<string | null>(defaultOpenId ?? null)\n  const [draft, setDraft] = React.useState<Record<string, number>>({})\n  const [confirm, setConfirm] = React.useState<{ flag: Flags1Flag; next: Flags1Flag; text: string } | null>(null)\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [error, setError] = React.useState<{ id: string; text: string } | null>(null)\n  const ret = useReturnFocus(!!confirm)\n  const tokens = queryTokens(query)\n\n  const stale = (f: Flags1Flag) => f.enabled.production && f.rollout === 100 && f.changedDays >= 90\n  const live = (f: Flags1Flag) => f.enabled.production && f.rollout > 0\n  const shown = list.filter((f) => (!tokens.length || matchesAll(`${f.key} ${f.description} ${f.owner}`, tokens)) && (filter === \"all\" || (filter === \"live\" ? live(f) : stale(f))))\n\n  async function commit(f: Flags1Flag, next: Flags1Flag) {\n    setError(null)\n    setBusy(f.id)\n    setFlags((x) => x.map((y) => (y.id === f.id ? next : y)))\n    try {\n      await onChange?.(next)\n    } catch (e) {\n      setFlags((x) => x.map((y) => (y.id === f.id ? f : y)))\n      setError({ id: f.id, text: e instanceof Error ? e.message : `${f.key} could not be saved, so it was put back as it was.` })\n    } finally {\n      setBusy(null)\n    }\n  }\n  function toggle(f: Flags1Flag, env: Flags1Env, on: boolean) {\n    const next = { ...f, enabled: { ...f.enabled, [env]: on }, changedDays: 0 }\n    if (env === \"production\") {\n      const who = f.rollout === 100 ? \"everyone\" : `${f.rollout}% of users`\n      setConfirm({ flag: f, next, text: on ? `${f.key} will switch on in Production for ${who}${f.rules ? `, starting with ${f.rules.charAt(0).toLowerCase()}${f.rules.slice(1)}` : \"\"}.` : `${f.key} will switch off in Production for ${who}. Anyone using it right now will lose it.` })\n    } else void commit(f, next)\n  }\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Flag className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No flags yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"A flag lets you ship code switched off, then turn it on for a few people before everyone.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <>\n      <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n        <div className=\"bg-background focus-within:ring-ring/50 flex h-10 flex-1 items-center gap-2 rounded-lg border px-3 focus-within:ring-[3px]\">\n          <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n          <input type=\"search\" aria-label=\"Search flags\" placeholder=\"Search by key, description or owner\" value={query} onChange={(e) => setQuery(e.target.value)} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n        </div>\n        <div role=\"group\" aria-label=\"Filter\" className=\"flex flex-wrap gap-2\">\n          {([[\"all\", \"All\"], [\"live\", \"Live in production\"], [\"stale\", \"Ready to remove\"]] as const).map(([k, l]) => (\n            <button key={k} type=\"button\" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn(\"focus-visible:ring-ring/50 shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", filter === k ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n              {l} <span className={cn(\"tabular-nums\", filter === k ? \"opacity-70\" : \"text-muted-foreground\")}>{k === \"all\" ? list.length : list.filter(k === \"live\" ? live : stale).length}</span>\n            </button>\n          ))}\n        </div>\n      </div>\n      <p aria-live=\"polite\" className=\"text-muted-foreground mt-4 text-sm\">\n        {shown.length} {shown.length === 1 ? \"flag\" : \"flags\"}\n      </p>\n      {shown.length ? (\n        <ul className=\"mt-3 grid gap-3\">\n          {shown.map((f) => {\n            const isOpen = open === f.id\n            const dirty = draft[f.id] !== undefined && draft[f.id] !== f.rollout\n            return (\n              <li key={f.id} className=\"bg-card rounded-2xl border\">\n                <div className=\"grid items-center gap-x-6 gap-y-3 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_auto]\">\n                  <div className=\"min-w-0\">\n                    <p className=\"flex flex-wrap items-center gap-2\">\n                      <button type=\"button\" aria-expanded={isOpen} aria-controls={`${id}-${f.id}`} onClick={() => setOpen(isOpen ? null : f.id)} className=\"focus-visible:ring-ring/50 -ms-1 flex items-center gap-2 rounded-md px-1 text-start outline-none focus-visible:ring-[3px]\">\n                        <ChevronDown aria-hidden=\"true\" className={cn(\"text-muted-foreground size-4 transition-transform motion-reduce:transition-none\", !isOpen && \"-rotate-90 rtl:rotate-90\")} />\n                        <span className=\"font-mono text-sm font-semibold\" dir=\"ltr\">\n                          <MatchHighlight text={f.key} query={tokens} />\n                        </span>\n                      </button>\n                      {stale(f) ? (\n                        <span className=\"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2.5 py-0.5 text-xs font-medium\">\n                          <Sparkles aria-hidden=\"true\" className=\"size-3\" />\n                          Ready to remove\n                        </span>\n                      ) : null}\n                    </p>\n                    <p className=\"text-muted-foreground mt-1 ps-6 text-sm text-pretty\">\n                      <MatchHighlight text={f.description} query={tokens} />\n                    </p>\n                  </div>\n                  <div className=\"flex flex-wrap items-center gap-x-5 gap-y-2 ps-6 lg:ps-0\">\n                    {envs.map((e) => (\n                      <label key={e.id} className=\"flex items-center gap-2 text-sm\">\n                        <span className={cn(e.id === \"production\" && \"font-medium\")}>{e.label}</span>\n                        <Switch size=\"sm\" checked={f.enabled[e.id]} disabled={busy === f.id} onCheckedChange={(v) => toggle(f, e.id, v)} aria-label={`${f.key} in ${e.label}`} />\n                      </label>\n                    ))}\n                  </div>\n                </div>\n                {error?.id === f.id ? (\n                  <p role=\"alert\" className=\"text-destructive border-t px-5 py-3 text-sm\">\n                    {error.text}\n                  </p>\n                ) : null}\n                {isOpen ? (\n                  <div id={`${id}-${f.id}`} className=\"bg-muted/30 grid gap-6 border-t p-5 sm:grid-cols-2\">\n                    <div>\n                      <p id={`${id}-${f.id}-r`} className=\"text-sm font-medium\">\n                        Production rollout\n                      </p>\n                      <p className=\"text-muted-foreground mt-0.5 text-xs\">{f.enabled.production ? \"Share of users who get it when it is on.\" : \"Switched off in Production, so nobody gets it yet.\"}</p>\n                      <div className=\"mt-5 flex items-center gap-4\">\n                        <Slider aria-labelledby={`${id}-${f.id}-r`} min={0} max={100} step={5} value={[draft[f.id] ?? f.rollout]} onValueChange={([n]) => setDraft((d) => ({ ...d, [f.id]: n! }))} formatValue={(n) => `${n}%`} className=\"flex-1\" />\n                        <span className=\"w-12 text-end text-xl font-semibold tabular-nums\">{draft[f.id] ?? f.rollout}%</span>\n                      </div>\n                      {dirty ? (\n                        <div className=\"mt-4 flex items-center gap-2\">\n                          <Button size=\"sm\" disabled={busy === f.id} onClick={() => { const n = { ...f, rollout: draft[f.id]!, changedDays: 0 }; setDraft((d) => { const { [f.id]: _drop, ...rest } = d; return rest }); void commit(f, n) }}>\n                            {busy === f.id ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                            Save rollout\n                          </Button>\n                          <Button size=\"sm\" variant=\"ghost\" onClick={() => setDraft((d) => { const { [f.id]: _drop, ...rest } = d; return rest })}>\n                            Cancel\n                          </Button>\n                        </div>\n                      ) : null}\n                    </div>\n                    <dl className=\"grid content-start gap-3 text-sm\">\n                      <div>\n                        <dt className=\"text-muted-foreground text-xs font-medium\">Gets it first</dt>\n                        <dd className=\"mt-0.5\">{f.rules ?? \"No special rules. Everyone in the rollout.\"}</dd>\n                      </div>\n                      <div>\n                        <dt className=\"text-muted-foreground text-xs font-medium\">Owner</dt>\n                        <dd className=\"mt-0.5\">{f.owner}</dd>\n                      </div>\n                      <div>\n                        <dt className=\"text-muted-foreground text-xs font-medium\">Last changed</dt>\n                        <dd className=\"mt-0.5\">{f.changedDays === 0 ? \"Just now\" : f.changedDays === 1 ? \"Yesterday\" : `${f.changedDays} days ago`}</dd>\n                      </div>\n                    </dl>\n                  </div>\n                ) : null}\n              </li>\n            )\n          })}\n        </ul>\n      ) : (\n        <div className=\"mt-3 rounded-2xl border border-dashed px-6 py-12 text-center\">\n          <p className=\"font-medium\">No flags match</p>\n          <p className=\"text-muted-foreground mt-1 text-sm\">Try another word, or look at all flags.</p>\n          <Button variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={() => { setQuery(\"\"); setFilter(\"all\") }}>\n            Clear filters\n          </Button>\n        </div>\n      )}\n    </>\n  )\n\n  return (\n    <section data-slot=\"flags-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">Ship code switched off, then turn it on for a few people before everyone.</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading flags\" loading={<Skel />} error={{ title: errorState?.title ?? \"Flags could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n\n      <AlertDialog open={!!confirm} onOpenChange={(o) => !o && setConfirm(null)}>\n        <AlertDialogContent {...ret}>\n          <AlertDialogHeader>\n            <AlertDialogTitle className=\"flex items-center gap-2\">\n              <ShieldAlert aria-hidden=\"true\" className=\"size-5\" />\n              Change Production?\n            </AlertDialogTitle>\n            <AlertDialogDescription>{confirm?.text}</AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction destructive={confirm ? !confirm.next.enabled.production : false} onClick={() => confirm && void commit(confirm.flag, confirm.next)}>\n              {confirm?.next.enabled.production ? \"Switch on\" : \"Switch off\"}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </section>\n  )\n}\n\nexport { Flags1, type Flags1Props, type Flags1Flag, type Flags1Env }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/flags-1/flags-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "feature flags",
      "toggles",
      "rollout",
      "release",
      "environments",
      "launchdarkly",
      "experiments",
      "developer",
      "production"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A feature flags manager. <Flags1 flags={[{ id, key: 'checkout-v2', description, enabled: { dev, staging, production }, rollout: 0..100, rules: 'Staff, and the Beta group', owner, changedDays }]} onChange={(flag) => Promise} />. A flag is 'Ready to remove' when it is on in production at 100% and unchanged for 90 days or more. Changing Production asks first and says who it affects; other environments change at once. Reject onChange and the flag goes back with the message. Rollout moves in steps of 5 and needs Save.",
      "whenToUse": [
        "An internal tool for releasing features gradually",
        "A settings area of a developer product with flags or toggles per environment"
      ],
      "whenNotToUse": [
        "A user's own preference switches (use settings-1)",
        "A/B test results (use experiments-1)"
      ],
      "composesWith": [
        "experiments-1",
        "webhooks-1",
        "dev-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Space",
          "action": "Each switch is named for the flag and the environment ('checkout-v2 in Production'); the row's expand button says whether it is open"
        },
        {
          "keys": "Production changes",
          "action": "An alert dialog says exactly who is affected and returns focus to the switch you used when it closes"
        },
        {
          "keys": "Rollout",
          "action": "A labelled slider in steps of 5 with the value read aloud; nothing is applied until Save rollout"
        }
      ],
      "customization": [
        "Keys and descriptions are highlighted when they match the search",
        "The filter buttons show how many flags each one holds, across all flags",
        "Keys are shown left to right in right-to-left layouts, like code"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "flags-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No flags yet",
        "example": "flags-1-empty",
        "trigger": "state=\"empty\" (or flags={[]})",
        "note": "Explains what a flag is; emptyState sets the copy and can add a button. A search with no match says so with Clear filters."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "flags-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A change that fails to save is undone with its message under the flag."
      }
    ],
    "examples": [
      "flags-1-demo",
      "flags-1-open",
      "flags-1-loading",
      "flags-1-empty",
      "flags-1-error"
    ],
    "url": "https://ui.ballmac.com/components/flags-1"
  },
  "categories": [
    "blocks",
    "flags"
  ],
  "type": "registry:block"
}