{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-stepper-21","type":"registry:block","title":"Stepper with grouped security checks","description":"29 security checks under 12 numbered steps in 4 areas, with finding counts and severity badges, and Previous and Next closing the branch they leave.","dependencies":["@headless-tree/core","@headless-tree/react","cn"],"registryDependencies":["@reui/badge","button","separator","@reui/tree"],"files":[{"path":"c-stepper-21.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useId, useRef, useState, type ReactNode } from \"react\"\nimport { Badge } from \"@/components/reui/badge\"\nimport {\n  Tree,\n  TreeItem,\n  TreeItemLabel,\n} from \"@/components/reui/tree\"\nimport {\n  hotkeysCoreFeature,\n  selectionFeature,\n  syncDataLoaderFeature,\n} from \"@headless-tree/core\"\nimport { useTree } from \"@headless-tree/react\"\nimport { cn } from \"cn\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\nconst severities = {\n  High: \"destructive-light\",\n  Mild: \"success-light\",\n} as const\n\nconst statusLabels = {\n  complete: \"complete\",\n  current: \"in progress\",\n  upcoming: \"pending\",\n} as const\n\ntype Status = keyof typeof statusLabels\n\ninterface Entry {\n  name: string\n  icon?: ReactNode\n  count?: number\n  severity?: keyof typeof severities\n  items?: (Entry | string)[]\n}\n\n// Areas hold numbered steps and steps hold checks. The checks are what the\n// stepper moves through, in this order.\nconst areas: Entry[] = [\n  {\n    name: \"Endpoint Security\",\n    icon: (\n      <IconPlaceholder\n        lucide=\"MonitorSmartphoneIcon\"\n        tabler=\"IconDevices\"\n        hugeicons=\"ComputerPhoneSyncIcon\"\n        phosphor=\"DevicesIcon\"\n        remixicon=\"RiDeviceLine\"\n        className=\"size-4\"\n        aria-hidden=\"true\"\n      />\n    ),\n    items: [\n      {\n        name: \"Inventory\",\n        count: 248,\n        items: [\"Device Discovery\", \"Agent Coverage\", \"Installed Software\"],\n      },\n      {\n        name: \"Threat Detection\",\n        count: 12,\n        items: [\n          \"Overview\",\n          \"Suspicious Activity\",\n          \"Malware Events\",\n          \"Network Anomalies\",\n        ],\n      },\n      {\n        name: \"Vulnerabilities\",\n        count: 37,\n        severity: \"High\",\n        items: [\"Critical CVEs\", \"Missing Patches\", \"Exposed Services\"],\n      },\n      {\n        name: \"Policies\",\n        count: 8,\n        items: [\"Disk Encryption\", \"Firewall Rules\", \"Screen Lock\"],\n      },\n      {\n        name: \"Quarantine\",\n        count: 5,\n        items: [\"Isolated Devices\", \"Held Files\"],\n      },\n      {\n        name: \"Response\",\n        count: 7,\n        severity: \"Mild\",\n        items: [\"Open Incidents\", \"Remediation\"],\n      },\n    ],\n  },\n  {\n    name: \"Identity & Access\",\n    icon: (\n      <IconPlaceholder\n        lucide=\"KeyRoundIcon\"\n        tabler=\"IconKey\"\n        hugeicons=\"Key02Icon\"\n        phosphor=\"KeyIcon\"\n        remixicon=\"RiKey2Line\"\n        className=\"size-4\"\n        aria-hidden=\"true\"\n      />\n    ),\n    items: [\n      {\n        name: \"Accounts\",\n        count: 64,\n        items: [\"Privileged Users\", \"Stale Accounts\"],\n      },\n      {\n        name: \"Authentication\",\n        count: 9,\n        severity: \"High\",\n        items: [\"MFA Coverage\", \"Password Policy\"],\n      },\n    ],\n  },\n  {\n    name: \"Application Security\",\n    icon: (\n      <IconPlaceholder\n        lucide=\"BracesIcon\"\n        tabler=\"IconBraces\"\n        hugeicons=\"CodeIcon\"\n        phosphor=\"BracketsCurly\"\n        remixicon=\"RiBracesLine\"\n        className=\"size-4\"\n        aria-hidden=\"true\"\n      />\n    ),\n    items: [\n      {\n        name: \"Dependencies\",\n        count: 112,\n        severity: \"High\",\n        items: [\"Known CVEs\", \"License Risks\"],\n      },\n      {\n        name: \"Code Scanning\",\n        count: 23,\n        items: [\"Hardcoded Secrets\", \"Injection Flaws\"],\n      },\n    ],\n  },\n  {\n    name: \"Data Protection\",\n    icon: (\n      <IconPlaceholder\n        lucide=\"DatabaseZapIcon\"\n        tabler=\"IconDatabase\"\n        hugeicons=\"DatabaseLightningIcon\"\n        phosphor=\"DatabaseIcon\"\n        remixicon=\"RiDatabase2Line\"\n        className=\"size-4\"\n        aria-hidden=\"true\"\n      />\n    ),\n    items: [\n      {\n        name: \"Encryption\",\n        count: 4,\n        items: [\"Data at Rest\", \"Data in Transit\"],\n      },\n      {\n        name: \"Backups\",\n        count: 3,\n        severity: \"Mild\",\n        items: [\"Retention\", \"Restore Tests\"],\n      },\n    ],\n  },\n]\n\ninterface TreeNode extends Omit<Entry, \"items\"> {\n  parent: string\n  children?: string[]\n  first: number\n  last: number\n}\n\nconst nodes: Record<string, TreeNode> = {}\nconst checks: string[] = []\n\n// first and last are the positions of the first and last check under a node,\n// so comparing them with the active check gives any row its status.\nfunction add(id: string, entry: Entry | string, parent: string): string {\n  const { items, ...rest }: Entry =\n    typeof entry === \"string\" ? { name: entry } : entry\n  const first = checks.length\n  const children = items?.map((item, index) => add(`${id}.${index}`, item, id))\n  if (!children) checks.push(id)\n  nodes[id] = { ...rest, parent, children, first, last: checks.length - 1 }\n  return id\n}\n\nnodes.root = {\n  name: \"Security review\",\n  parent: \"\",\n  children: areas.map((area, index) => add(`${index}`, area, \"root\")),\n  first: 0,\n  last: checks.length - 1,\n}\n\nfunction foldersAbove(id: string): string[] {\n  const parent = nodes[id].parent\n  return parent === \"root\" ? [] : [parent, ...foldersAbove(parent)]\n}\n\n// Rows sit flush (no pb-0.5) and each draws the guide of every indent level\n// it sits under, so a guide runs unbroken down a branch and stops at its end.\n// The gradient only runs left to right, so RTL mirrors the strip instead.\nconst guides =\n  \"relative not-last:pb-0 before:absolute before:inset-y-0 before:-start-1 before:w-(--tree-padding) before:bg-[repeating-linear-gradient(to_right,transparent_0,transparent_calc(var(--tree-indent)-1px),var(--border)_calc(var(--tree-indent)-1px),var(--border)_calc(var(--tree-indent)))] rtl:before:-scale-x-100\"\n\n// r=7 gives a circumference of 44: the track is eleven 3+1 dashes and the\n// in-progress arc is 17 long (139 degrees), offset to start at 11 o'clock as\n// in the design. Only the arc spins, so the dashed track holds still.\nfunction StatusIcon({ status }: { status: Status }) {\n  return (\n    <span className=\"flex size-4.5 shrink-0 items-center justify-center\">\n      {status === \"complete\" ? (\n        <span className=\"border-success bg-success/10 text-success flex size-4 items-center justify-center rounded-full border\">\n          <IconPlaceholder\n            lucide=\"CheckIcon\"\n            tabler=\"IconCheck\"\n            hugeicons=\"Tick02Icon\"\n            phosphor=\"CheckIcon\"\n            remixicon=\"RiCheckLine\"\n            className=\"size-3\"\n            aria-hidden=\"true\"\n          />\n        </span>\n      ) : (\n        <svg\n          viewBox=\"0 0 16 16\"\n          fill=\"none\"\n          strokeWidth=\"1.6\"\n          aria-hidden=\"true\"\n          className=\"size-4\"\n        >\n          <circle\n            cx=\"8\"\n            cy=\"8\"\n            r=\"7\"\n            strokeDasharray=\"3 1\"\n            className=\"stroke-ring/50\"\n          />\n          {status === \"current\" && (\n            <circle\n              cx=\"8\"\n              cy=\"8\"\n              r=\"7\"\n              strokeDasharray=\"17 27\"\n              strokeDashoffset=\"14.4\"\n              className=\"stroke-success origin-center motion-safe:animate-spin\"\n            />\n          )}\n        </svg>\n      )}\n    </span>\n  )\n}\n\nexport function Pattern() {\n  const labelId = useId()\n  // Ids are index paths, so 0.1.1 is Endpoint Security, Threat Detection,\n  // Suspicious Activity.\n  const [active, setActive] = useState(\"0.1.1\")\n  // Next on the last check finishes the review: every row then reads\n  // complete, and Previous returns to that check.\n  const [allDone, setAllDone] = useState(false)\n  const activeIndex = checks.indexOf(active)\n  const progress = allDone ? checks.length : activeIndex\n  const prevRef = useRef<HTMLButtonElement>(null)\n  const nextRef = useRef<HTMLButtonElement>(null)\n  const step = nodes[nodes[active].parent]\n\n  const statusOf = (node: TreeNode): Status =>\n    node.last < progress\n      ? \"complete\"\n      : node.first > progress\n        ? \"upcoming\"\n        : \"current\"\n\n  const tree = useTree<TreeNode>({\n    // Selection is pinned to the active check, so a click on a folder or a\n    // multi-select gesture snaps back and only a check's primary action\n    // moves it. Focusing it up front makes Tab land on it too.\n    state: { selectedItems: allDone ? [] : [active] },\n    initialState: {\n      expandedItems: foldersAbove(active),\n      focusedItem: active,\n    },\n    rootItemId: \"root\",\n    // TreeItem announces this name in place of the row's content, so it\n    // carries the count, severity and status the row shows.\n    getItemName: (item) => {\n      const node = item.getItemData()\n      return [\n        node.name,\n        node.count !== undefined && `${node.count} findings`,\n        node.severity && `${node.severity} severity`,\n        statusLabels[statusOf(node)],\n      ]\n        .filter(Boolean)\n        .join(\", \")\n    },\n    isItemFolder: (item) => Boolean(item.getItemData().children),\n    onPrimaryAction: (item) => {\n      if (item.isFolder()) return\n      setActive(item.getId())\n      setAllDone(false)\n    },\n    dataLoader: {\n      getItem: (itemId) => nodes[itemId],\n      getChildren: (itemId) => nodes[itemId].children ?? [],\n    },\n    features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature],\n  })\n\n  // Previous and Next fold the branch being left and open the new check's, so\n  // the open branch follows the active check as on first render, and move the\n  // tree's focus along so Tab lands on the new check.\n  function goTo(index: number) {\n    const check = checks[index]\n    const path = foldersAbove(check)\n    for (const id of foldersAbove(active)) {\n      if (!path.includes(id)) tree.getItemInstance(id).collapse()\n    }\n    for (const id of path) {\n      const folder = tree.getItemInstance(id)\n      if (!folder.isExpanded()) folder.expand()\n    }\n    tree.getItemInstance(check).setFocused()\n    setActive(check)\n    // The pressed button disables itself at either end, so focus moves to\n    // its partner.\n    if (index === 0) nextRef.current?.focus()\n  }\n\n  function finish() {\n    setAllDone(true)\n    prevRef.current?.focus()\n  }\n\n  // Side by side from 32rem, which leaves 12rem beside the 18rem tree for the\n  // text and the Previous and Next row.\n  return (\n    <div className=\"@container w-full\">\n      <div className=\"mx-auto flex max-w-72 flex-col gap-6 @lg:max-w-none @lg:flex-row @lg:items-start\">\n        {/* Tree spreads getContainerProps() last, and its empty aria-label\n            wins over ours, so the tree is named through aria-labelledby. */}\n        <span id={labelId} className=\"sr-only\">\n          {nodes.root.name}\n        </span>\n        <Tree\n          tree={tree}\n          aria-labelledby={labelId}\n          className=\"@lg:w-72 @lg:shrink-0\"\n        >\n          {tree.getItems().map((item) => {\n            const node = item.getItemData()\n            const status = statusOf(node)\n            const current = status === \"current\"\n\n            return (\n              <TreeItem\n                key={item.getId()}\n                item={item}\n                aria-current={item.getId() === active ? \"step\" : undefined}\n                className={guides}\n              >\n                {/* The primitive's chevron is the first svg child and an area\n                    icon the only other one, so the row tone reaches both while\n                    the 14px size reaches the chevron alone. Only hover and the\n                    active check fill a row; the primitive's bg-background would\n                    show as a block on any other surface. */}\n                <TreeItemLabel\n                  className={cn(\n                    \"h-8 gap-1.5 bg-transparent font-medium not-in-data-[folder=true]:ps-2 [&>svg:first-child]:size-3.5\",\n                    current ? \"[&>svg]:text-foreground\" : \"[&>svg]:text-ring\"\n                  )}\n                >\n                  {node.icon ??\n                    (item.isFolder() ? (\n                      <Badge\n                        variant={\n                          status === \"upcoming\"\n                            ? \"warning-light\"\n                            : \"success-light\"\n                        }\n                        radius=\"full\"\n                      >\n                        {item.getIndexInParent() + 1}\n                      </Badge>\n                    ) : (\n                      <StatusIcon status={status} />\n                    ))}\n                  <span\n                    className={cn(\n                      \"truncate\",\n                      !current && \"text-muted-foreground\"\n                    )}\n                  >\n                    {node.name}\n                  </span>\n                  {node.count !== undefined && (\n                    <Badge variant=\"outline\">{node.count}</Badge>\n                  )}\n                  {node.severity && (\n                    <Badge\n                      variant={severities[node.severity]}\n                      className=\"ms-auto\"\n                    >\n                      {node.severity}\n                    </Badge>\n                  )}\n                  {/* The theme has no focus token for focus-light to read, so\n                      the design's focus blue comes from the palette. */}\n                  {node.icon && current && (\n                    <Badge\n                      variant=\"focus-light\"\n                      className=\"ms-auto border-blue-500/15 bg-blue-500/10 text-blue-900 dark:border-blue-500/25 dark:bg-blue-500/15 dark:text-blue-400\"\n                    >\n                      Review\n                    </Badge>\n                  )}\n                </TreeItemLabel>\n              </TreeItem>\n            )\n          })}\n        </Tree>\n\n        {/* Decorative, so hidden from assistive tech: a line across while the\n            columns stack, a line between them once they sit side by side. */}\n        <Separator aria-hidden=\"true\" className=\"@lg:hidden\" />\n        <Separator\n          aria-hidden=\"true\"\n          orientation=\"vertical\"\n          className=\"hidden @lg:block\"\n        />\n        <div className=\"min-w-0 flex-1 px-2 @lg:px-0\">\n          <div role=\"status\" className=\"flex flex-col gap-1\">\n            {allDone ? (\n              <>\n                <p className=\"text-muted-foreground text-xs\">\n                  All {checks.length} checks done\n                </p>\n                <p className=\"text-sm font-medium\">Review complete</p>\n                <p className=\"text-muted-foreground text-sm\">\n                  {nodes.root.name} finished across every area\n                </p>\n              </>\n            ) : (\n              <>\n                <p className=\"text-muted-foreground text-xs\">\n                  Check {activeIndex - step.first + 1} of{\" \"}\n                  {step.last - step.first + 1}\n                </p>\n                <p className=\"text-sm font-medium\">{nodes[active].name}</p>\n                <p className=\"text-muted-foreground text-sm\">\n                  {step.name} in {nodes[step.parent].name}\n                </p>\n              </>\n            )}\n          </div>\n          <div className=\"mt-3 flex justify-start gap-2\">\n            <Button\n              ref={prevRef}\n              variant=\"outline\"\n              size=\"sm\"\n              disabled={activeIndex === 0 && !allDone}\n              onClick={() =>\n                allDone ? setAllDone(false) : goTo(activeIndex - 1)\n              }\n            >\n              Previous\n            </Button>\n            <Button\n              ref={nextRef}\n              variant=\"outline\"\n              size=\"sm\"\n              disabled={allDone}\n              onClick={() =>\n                activeIndex === checks.length - 1\n                  ? finish()\n                  : goTo(activeIndex + 1)\n              }\n            >\n              Next\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}","target":"components/examples/c-stepper-21.tsx"}],"meta":{"order":21,"gridSize":1,"label":"Security Checks"}}