{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "workflow-button-shadcn",
  "title": "Workflow Button (shadcn-native)",
  "author": "Lloyd Humphreys",
  "description": "EXPERIMENTAL — the API is still settling and will change in breaking ways; pin what you install. The workflow split button composed from your app's actual shadcn <Button> and <DropdownMenu> — identical variants, sizes, theming, focus rings, and dark mode; menu rows and custom primary content are plain ReactNode, and each step's icon is a component (icon?: ComponentType). Same flow model as workflow-button: `to` transition lists as the definition, `context` for role-aware disabling/prominence, per-step `advanceVariant` emphasis, attribution `meta`, and the caret hides when nothing is reachable. Self-contained in one file.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "dropdown-menu"
  ],
  "files": [
    {
      "path": "registry/workflow-button/workflow-button-shadcn.tsx",
      "content": "// workflow-button-shadcn — the workflow split button composed from shadcn primitives.\n//\n// EXPERIMENTAL: the API is still settling and will change in breaking ways.\n//\n// Same flow semantics as the vanilla `workflow-button` (primary advances the happy path\n// and re-labels itself; the caret menu jumps anywhere the flow allows), but built from\n// your app's actual <Button> and <DropdownMenu> — so it *is* a shadcn button: identical\n// variants, sizes, theming, focus rings, dark mode. Menu rows and custom primary content\n// are plain ReactNode; a step's `icon` is a component (like shadcn's own icon props), so\n// the affordance owns instantiation and sizing instead of trusting a pre-built element.\n//\n// State ownership: `current` + `onMove` is the whole contract — a workflow stage is host-\n// app domain data (it lives in your database, not in a widget), so this wrapper is always\n// controlled. The vanilla core's `manageState` exists for zero-framework, self-managed use.\n//\n// The flow is data: steps with optional `to` transition lists (the state-machine model —\n// `to[0]` is the happy path, `to: []` is terminal), an app `context` (viewer role,\n// permissions) threaded into every resolver for role-aware disabling and prominence, and\n// per-step `advanceVariant` for emphasis. When nothing is reachable (terminal and not\n// restartable, or role-locked), the caret hides and the control reads as a status.\n//\n// Self-contained on purpose: the flow math is inlined rather than imported from the\n// vanilla core, so this file installs alone (plus shadcn's button + dropdown-menu, pulled\n// in as registryDependencies).\n\n'use client'\n\nimport { CheckIcon, ChevronDownIcon } from 'lucide-react'\nimport { Button, buttonVariants } from '@/components/ui/button'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { cn } from '@/lib/utils'\nimport type { VariantProps } from 'class-variance-authority'\nimport type { ComponentType, ReactNode } from 'react'\n\ntype ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>['variant']>\n\n/** shadcn Button variants usable by the split control, plus 'primary' as an alias for\n *  'default'. ('link' is deliberately unsupported — a link-styled split button isn't a\n *  coherent control.) */\ntype WorkflowVariant = Exclude<ButtonVariant, 'link'> | 'primary'\n\ntype CanonicalVariant = Exclude<WorkflowVariant, 'primary'>\n\nconst canonical = (v: WorkflowVariant): CanonicalVariant =>\n  v === 'primary' ? 'default' : v\n\n/** One stage in the flow. */\ninterface WorkflowStep {\n  /** Stable identity — what `current` points at and `onMove` reports. */\n  id: string\n  /** Menu label, and the primary's label when this step is the advance target\n   *  (unless `advanceLabel` overrides it). */\n  label: string\n  /** Primary-button label when advancing *to* this step, e.g. \"Submit for review\". */\n  advanceLabel?: string\n  /** Prominence when this step is the advance target — emphasis lives on the destination.\n   *  Idiom: quiet base (`variant=\"outline\"`), `advanceVariant: 'primary'` at the stages\n   *  that demand action, `'destructive'` for high-consequence moves. */\n  advanceVariant?: WorkflowVariant\n  /** Optional secondary line under the label in the menu. */\n  description?: string\n  /** Instance annotation — who did this step, when (\"Astrid · 2d\"). When present it\n   *  REPLACES `description` as the line under the label. Richer → `renderItem`. */\n  meta?: string\n  /** Optional leading icon (menu item + primary when targeted). A component, not an\n   *  element — the affordance instantiates and sizes it (matches shadcn's icon rules). */\n  icon?: ComponentType<{ className?: string }>\n  /** Optional status color — a dot shown when no `icon` is given. */\n  color?: string\n  /** Hard-disable jumping to this step, regardless of `canMoveTo`. */\n  disabled?: boolean\n  /**\n   * Explicit transitions out of this step — the workflow definition as data:\n   * `to[0]` is the happy path (the primary's target), the menu enables exactly these\n   * ids, and `to: []` marks an explicitly terminal stage. Absent → array order.\n   */\n  to?: string[]\n}\n\n/** Resolve the advance target from a step. Return null for a terminal stage. */\ntype NextResolver<TCtx = unknown> = (\n  current: WorkflowStep,\n  steps: WorkflowStep[],\n  context: TCtx | undefined,\n) => string | null\n\n/** May we move `from` → `to`? Governs the primary's enablement and each menu item. */\ntype MovePredicate<TCtx = unknown> = (\n  to: WorkflowStep,\n  from: WorkflowStep,\n  steps: WorkflowStep[],\n  context: TCtx | undefined,\n) => boolean\n\n/** Advance target by array order: the next step, or null at the end. */\nconst nextInOrder: NextResolver = (current, steps) => {\n  const i = steps.findIndex((s) => s.id === current.id)\n  return i >= 0 && i < steps.length - 1 ? steps[i + 1].id : null\n}\n\n/** The built-in `next`: the step's `to[0]` (happy path) when declared, else array order. */\nconst defaultNext: NextResolver = (current, steps, context) => {\n  if (current.to) return current.to[0] ?? null\n  return nextInOrder(current, steps, context)\n}\n\n/** The built-in `canMoveTo`: `to`-membership when declared, else any non-disabled step. */\nconst defaultCanMoveTo: MovePredicate = (to, from) => {\n  if (to.disabled) return false\n  if (from.to) return from.to.includes(to.id)\n  return true\n}\n\n/** Array-order DAG flows: only steps after the current one are reachable — never back. */\nconst forwardOnly: MovePredicate = (to, from, steps) => {\n  if (to.disabled) return false\n  const fromI = steps.findIndex((s) => s.id === from.id)\n  const toI = steps.findIndex((s) => s.id === to.id)\n  return toI > fromI\n}\n\nconst TRIGGER_SIZE = { sm: 'icon-sm', default: 'icon', lg: 'icon-lg' } as const\n\n/* The trigger's divider against the primary, per resolved variant. Outline shares its\n   border via the wrapper's -space-x-px instead; ghost gets a plain border divider. */\nconst DIVIDER: Record<CanonicalVariant, string> = {\n  default: 'border-l border-primary-foreground/20',\n  secondary: 'border-l border-secondary-foreground/15',\n  destructive: 'border-l border-white/25',\n  outline: '',\n  ghost: 'border-l border-border',\n}\n\ninterface WorkflowButtonProps<TCtx = unknown> {\n  steps: WorkflowStep[]\n  /** Id of the current stage (controlled — reflect `onMove` back into this prop). */\n  current: string\n  /** Fired on advance (primary) or a menu pick, with the target and previous ids. */\n  onMove: (toId: string, fromId: string) => void\n  /** App data (viewer role, permissions, assignee…) threaded into every resolver —\n   *  role-aware disabling via `canMoveTo`, role-aware prominence via `variantFor`. */\n  context?: TCtx\n  next?: NextResolver<TCtx>\n  canMoveTo?: MovePredicate<TCtx>\n  /** Override the primary label. Falls back to `target.advanceLabel ?? target.label`. */\n  advanceLabelFor?: (\n    target: WorkflowStep,\n    from: WorkflowStep,\n    context: TCtx | undefined,\n  ) => string\n  /** Fully own the primary's content. `target` is null at a terminal stage. */\n  renderPrimary?: (ctx: {\n    target: WorkflowStep | null\n    current: WorkflowStep\n  }) => ReactNode\n  /** Own a menu item's row (left of the current-step check). Default rendering shows\n   *  icon/dot, label, description, and the muted `meta` annotation. */\n  renderItem?: (\n    step: WorkflowStep,\n    state: { isCurrent: boolean; reachable: boolean },\n  ) => ReactNode\n  size?: 'sm' | 'default' | 'lg'\n  /** Base presentation; per-step `advanceVariant` / `variantFor` win over it. */\n  variant?: WorkflowVariant\n  /** Dynamic emphasis resolver. Wins over `advanceVariant`; null falls through. */\n  variantFor?: (\n    target: WorkflowStep,\n    from: WorkflowStep,\n    context: TCtx | undefined,\n  ) => WorkflowVariant | null | undefined\n  /** Accessible name for the menu trigger. Default \"Choose stage\". */\n  menuLabel?: string\n  className?: string\n}\n\n/**\n * The workflow split button, in shadcn parts. Primary = <Button> advancing the happy\n * path; caret = <DropdownMenu> of every stage, disabled per `canMoveTo`, hidden entirely\n * when nothing is reachable. Fully controlled.\n */\nfunction WorkflowButton<TCtx = unknown>({\n  steps,\n  current,\n  onMove,\n  context,\n  next = defaultNext,\n  canMoveTo = defaultCanMoveTo,\n  advanceLabelFor,\n  renderPrimary,\n  renderItem,\n  size = 'default',\n  variant = 'default',\n  variantFor,\n  menuLabel = 'Choose stage',\n  className,\n}: WorkflowButtonProps<TCtx>) {\n  const cur = steps.find((s) => s.id === current)\n  if (!cur) return null\n\n  const targetId = next(cur, steps, context)\n  const target = (targetId && steps.find((s) => s.id === targetId)) || null\n  const advance = target && canMoveTo(target, cur, steps, context) ? target : null\n  const anyReachable = steps.some(\n    (s) => s.id !== current && canMoveTo(s, cur, steps, context),\n  )\n\n  // Emphasis resolves per advance target: variantFor → the destination's advanceVariant →\n  // the base. Terminal/blocked reads as a quiet secondary readout (outline/ghost stay put).\n  const base = canonical(variant)\n  const resolved: CanonicalVariant = advance\n    ? canonical(variantFor?.(advance, cur, context) ?? advance.advanceVariant ?? variant)\n    : base === 'outline' || base === 'ghost'\n      ? base\n      : 'secondary'\n\n  const primaryLabel = advance\n    ? (advanceLabelFor?.(advance, cur, context) ??\n      advance.advanceLabel ??\n      advance.label)\n    : cur.label\n  const shown = advance ?? cur\n\n  return (\n    <div\n      data-slot=\"workflow-button\"\n      role=\"group\"\n      className={cn(\n        'inline-flex rounded-md',\n        resolved !== 'ghost' && 'shadow-xs',\n        resolved === 'outline' && '-space-x-px',\n        className,\n      )}\n    >\n      <Button\n        type=\"button\"\n        variant={resolved}\n        size={size}\n        disabled={!advance}\n        onClick={() => advance && onMove(advance.id, cur.id)}\n        aria-label={advance ? `${primaryLabel} (advance from ${cur.label})` : undefined}\n        className={cn(\n          'shadow-none focus-visible:z-10',\n          anyReachable ? 'rounded-r-none' : undefined,\n          // A terminal stage is a readout, not a broken button — keep it legible.\n          !advance && 'disabled:opacity-100 text-muted-foreground',\n        )}\n      >\n        {renderPrimary ? (\n          renderPrimary({ target: advance, current: cur })\n        ) : (\n          <>\n            <StepAffordance step={shown} />\n            {primaryLabel}\n          </>\n        )}\n      </Button>\n      {anyReachable ? (\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button\n              type=\"button\"\n              variant={resolved}\n              size={TRIGGER_SIZE[size]}\n              className={cn(\n                'rounded-l-none shadow-none focus-visible:z-10',\n                'data-[state=open]:[&_svg]:rotate-180',\n                DIVIDER[resolved],\n              )}\n            >\n              <ChevronDownIcon className=\"transition-transform duration-200\" />\n              <span className=\"sr-only\">{menuLabel}</span>\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"end\" className=\"min-w-56\">\n            <DropdownMenuGroup>\n              {steps.map((step) => {\n                const isCurrent = step.id === current\n                const reachable = canMoveTo(step, cur, steps, context)\n                return (\n                  <DropdownMenuItem\n                    key={step.id}\n                    disabled={!reachable && !isCurrent}\n                    aria-current={isCurrent || undefined}\n                    onSelect={() => {\n                      if (!isCurrent && reachable) onMove(step.id, cur.id)\n                    }}\n                  >\n                    {renderItem ? (\n                      renderItem(step, { isCurrent, reachable: reachable && !isCurrent })\n                    ) : (\n                      <>\n                        <StepAffordance step={step} />\n                        <span className=\"flex min-w-0 flex-1 flex-col\">\n                          <span className={cn(isCurrent && 'font-medium')}>\n                            {step.label}\n                          </span>\n                          {/* Attribution (what happened) beats the static hint. */}\n                          {(step.meta ?? step.description) ? (\n                            <span className=\"text-muted-foreground text-xs\">\n                              {step.meta ?? step.description}\n                            </span>\n                          ) : null}\n                        </span>\n                      </>\n                    )}\n                    {isCurrent ? <CheckIcon className=\"ml-2\" /> : null}\n                  </DropdownMenuItem>\n                )\n              })}\n            </DropdownMenuGroup>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      ) : null}\n    </div>\n  )\n}\n\n/** A step's leading visual: its icon, else a status-color dot, else nothing. */\nfunction StepAffordance({ step }: { step: WorkflowStep }) {\n  const Icon = step.icon\n  if (Icon) {\n    return (\n      <span\n        data-slot=\"workflow-button-affordance\"\n        aria-hidden=\"true\"\n        className=\"[&_svg:not([class*='size-'])]:size-4\"\n      >\n        <Icon />\n      </span>\n    )\n  }\n  if (!step.color) return null\n  return (\n    <span\n      data-slot=\"workflow-button-affordance\"\n      aria-hidden=\"true\"\n      className=\"size-2 shrink-0 rounded-full\"\n      style={{\n        backgroundColor: step.color,\n        boxShadow: `0 0 0 2px color-mix(in srgb, ${step.color} 25%, transparent)`,\n      }}\n    />\n  )\n}\n\ninterface UseWorkflowOptions<TCtx = unknown> {\n  steps: WorkflowStep[]\n  current: string\n  context?: TCtx\n  next?: NextResolver<TCtx>\n  canMoveTo?: MovePredicate<TCtx>\n}\n\n/** Headless flow math — build your own control (stepper, command entry, shortcut) on top. */\nfunction useWorkflow<TCtx = unknown>({\n  steps,\n  current,\n  context,\n  next = defaultNext as NextResolver<TCtx>,\n  canMoveTo = defaultCanMoveTo as MovePredicate<TCtx>,\n}: UseWorkflowOptions<TCtx>) {\n  const cur = steps.find((s) => s.id === current)\n  const byId = (id: string) => steps.find((s) => s.id === id)\n\n  let advanceTarget: string | null = null\n  if (cur) {\n    const targetId = next(cur, steps, context)\n    const target = targetId ? byId(targetId) : null\n    if (target && canMoveTo(target, cur, steps, context)) advanceTarget = targetId\n  }\n\n  return {\n    currentStep: cur,\n    advanceTarget,\n    canAdvance: advanceTarget !== null,\n    canMoveTo: (id: string) => {\n      const to = byId(id)\n      return !!(to && cur && id !== current && canMoveTo(to, cur, steps, context))\n    },\n    anyReachable:\n      !!cur && steps.some((s) => s.id !== current && canMoveTo(s, cur, steps, context)),\n  }\n}\n\nexport {\n  defaultCanMoveTo,\n  defaultNext,\n  forwardOnly,\n  nextInOrder,\n  useWorkflow,\n  WorkflowButton,\n  type MovePredicate,\n  type NextResolver,\n  type UseWorkflowOptions,\n  type WorkflowButtonProps,\n  type WorkflowStep,\n  type WorkflowVariant,\n}\n",
      "type": "registry:component",
      "target": "components/workflow-button/workflow-button-shadcn.tsx"
    }
  ],
  "type": "registry:component"
}