{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-stepper-18","type":"registry:block","title":"Stepper with key-value stage details","description":"5 delivery run stages from loading to return, each with a time and dashed-leader rows such as gate, delay, ETA and battery.","dependencies":["cn"],"registryDependencies":["avatar","@reui/badge","button","carousel","@reui/stepper"],"files":[{"path":"c-stepper-18.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useEffect, useRef, useState, type PointerEvent } from \"react\"\nimport { Badge, type BadgeProps } from \"@/components/reui/badge\"\nimport {\n  Stepper,\n  StepperContent,\n  StepperIndicator,\n  StepperItem,\n  StepperNav,\n  StepperPanel,\n  StepperSeparator,\n  StepperTitle,\n  StepperTrigger,\n} from \"@/components/reui/stepper\"\nimport { cn } from \"cn\"\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@/components/ui/avatar\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  type CarouselApi,\n} from \"@/components/ui/carousel\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\ninterface Detail {\n  label: string\n  value: string\n  badge?: BadgeProps[\"variant\"]\n  avatar?: string\n}\n\ninterface Stage {\n  title: string\n  time: string\n  summary: string\n  details: Detail[]\n}\n\nconst stages: Stage[] = [\n  {\n    title: \"Loaded\",\n    time: \"15:08\",\n    summary: \"Parcels scanned and loaded at depot B23.\",\n    details: [\n      { label: \"Depot\", value: \"B23\" },\n      {\n        label: \"Agent\",\n        value: \"Mark\",\n        avatar:\n          \"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80\",\n      },\n    ],\n  },\n  {\n    title: \"Out\",\n    time: \"15:10\",\n    summary: \"Left the yard through gate A3 on schedule.\",\n    details: [\n      { label: \"Gate\", value: \"A3\" },\n      { label: \"Status\", value: \"On time\", badge: \"success-light\" },\n    ],\n  },\n  {\n    title: \"Transit\",\n    time: \"15:18\",\n    summary: \"On the ring road at 18 km/h, 6 minutes behind plan.\",\n    details: [\n      { label: \"Speed\", value: \"18 km/h\" },\n      { label: \"Delay\", value: \"+6 min\", badge: \"warning-light\" },\n    ],\n  },\n  {\n    title: \"Delivery\",\n    time: \"15:28\",\n    summary: \"Last of 8 stops, arriving in about 5 minutes.\",\n    details: [\n      { label: \"Stop\", value: \"8/8\" },\n      { label: \"ETA\", value: \"5 min\", badge: \"success-light\" },\n    ],\n  },\n  {\n    title: \"Return\",\n    time: \"15:45\",\n    summary: \"Back to depot B23 to charge, battery at 62%.\",\n    details: [\n      { label: \"Depot\", value: \"B23\" },\n      { label: \"Battery\", value: \"62%\" },\n    ],\n  },\n]\n\nfunction Pending({ spin = false }: { spin?: boolean }) {\n  return (\n    <span className=\"bg-ring/80 flex size-5.5 items-center justify-center rounded-full text-white\">\n      <IconPlaceholder\n        lucide=\"CircleDashedIcon\"\n        tabler=\"IconCircleDashed\"\n        hugeicons=\"CircleDashedIcon\"\n        phosphor=\"CircleDashedIcon\"\n        remixicon=\"RiCheckboxBlankCircleLine\"\n        className={cn(\"size-3.5\", spin && \"animate-spin\")}\n        aria-hidden=\"true\"\n      />\n    </span>\n  )\n}\n\n// The 26px ring stays transparent and the 22px dot inside it carries the\n// state color, so the 1px gap between them shows whatever surface is behind.\nconst indicators = {\n  completed: (\n    <span className=\"bg-success flex size-5.5 items-center justify-center rounded-full text-white\">\n      <IconPlaceholder\n        lucide=\"CheckIcon\"\n        tabler=\"IconCheck\"\n        hugeicons=\"Tick02Icon\"\n        phosphor=\"CheckIcon\"\n        remixicon=\"RiCheckLine\"\n        className=\"size-3.5\"\n        aria-hidden=\"true\"\n      />\n    </span>\n  ),\n  inactive: <Pending />,\n  loading: <Pending spin />,\n}\n\n// A flex item with no text has its baseline at its bottom edge, so in an\n// items-baseline row this dashed rule lands on the text baseline.\nfunction Leader() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"border-ring/30 min-w-2 flex-1 border-b border-dashed\"\n    />\n  )\n}\n\n// 4px dots spaced edge to edge by mask-repeat: space, so a connector at least\n// one tile wide never shows a clipped dot. Tiles are in rem, like the box.\nconst dots =\n  \"m-0 bg-transparent bg-linear-to-r mask-[radial-gradient(circle_closest-side,#000_90%,transparent)] mask-size-[0.4375rem_0.25rem] mask-repeat-space rtl:bg-linear-to-l group-data-[orientation=horizontal]/stepper-nav:h-1\"\n\nconst START_STEP = 4\n// Matches the fade width: a mouse resting this close to a faded edge\n// steps the row that way, one stage per beat.\nconst EDGE_PX = 40\nconst STEP_MS = 600\n\nconst reducedMotion = () =>\n  window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n// Embla follows focus only after a Tab press, but the stepper moves focus\n// with the arrow keys, so the browser would scroll the clipped viewport\n// natively. Undo that and bring the stage in through Embla instead.\nfunction followFocus(api: NonNullable<CarouselApi>, event: FocusEvent) {\n  const root = api.rootNode()\n  root.scrollLeft = 0\n  const slide = (event.target as HTMLElement).closest<HTMLElement>(\n    \"[data-slot=carousel-item]\"\n  )\n  const index = slide ? api.slideNodes().indexOf(slide) : -1\n  if (index < 0) return false\n  const box = root.getBoundingClientRect()\n  const { left, right } = slide!.getBoundingClientRect()\n  if (left < box.left || right > box.right) {\n    api.scrollTo(\n      Math.min(index, api.scrollSnapList().length - 1),\n      reducedMotion()\n    )\n  }\n  return false\n}\n\nconst carouselOptions = {\n  align: \"start\",\n  dragFree: true,\n  inViewThreshold: 1,\n  startIndex: START_STEP - 1,\n  watchFocus: followFocus,\n} as const\n\nexport function Pattern() {\n  const [current, setCurrent] = useState(START_STEP)\n  const [api, setApi] = useState<CarouselApi>()\n  const [edges, setEdges] = useState({ prev: false, next: false })\n  const prevRef = useRef<HTMLButtonElement>(null)\n  const nextRef = useRef<HTMLButtonElement>(null)\n  // One past the last step marks every step complete. No tab is selected\n  // then, so the last one keeps the tablist's tab stop.\n  const allDone = current > stages.length\n  const hover = useRef({ dir: 0, timer: 0 })\n\n  // Each faded edge shows only while there is more row past it, so a\n  // row that fits renders as a plain stepper: no fade, nothing to drag.\n  useEffect(() => {\n    if (!api) return\n    const sync = () =>\n      setEdges((prev) => {\n        const next = { prev: api.canScrollPrev(), next: api.canScrollNext() }\n        return prev.prev === next.prev && prev.next === next.next ? prev : next\n      })\n    sync()\n    api.on(\"scroll\", sync).on(\"reInit\", sync)\n    return () => {\n      api.off(\"scroll\", sync).off(\"reInit\", sync)\n    }\n  }, [api])\n\n  // Previous and Next live outside the row, so bring the active stage in\n  // when it is not fully visible. Snaps are trimmed at the end, so a late\n  // stage shares the last snap.\n  useEffect(() => {\n    if (!api || api.slidesInView().includes(current - 1)) return\n    const snap = Math.min(current - 1, api.scrollSnapList().length - 1)\n    api.scrollTo(snap, reducedMotion())\n  }, [api, current])\n\n  useEffect(() => () => window.clearInterval(hover.current.timer), [])\n\n  function hoverEdge(dir: number) {\n    if (!api || dir === hover.current.dir) return\n    window.clearInterval(hover.current.timer)\n    hover.current = { dir, timer: 0 }\n    if (!dir) return\n    const step = () => {\n      if (dir < 0 ? !api.canScrollPrev() : !api.canScrollNext()) {\n        return hoverEdge(0)\n      }\n      if (dir < 0) api.scrollPrev(reducedMotion())\n      else api.scrollNext(reducedMotion())\n    }\n    step()\n    hover.current.timer = window.setInterval(step, STEP_MS)\n  }\n\n  function onPointerMove(event: PointerEvent<HTMLDivElement>) {\n    if (event.pointerType !== \"mouse\" || event.buttons) return hoverEdge(0)\n    const { left, right } = event.currentTarget.getBoundingClientRect()\n    hoverEdge(\n      event.clientX < left + EDGE_PX && edges.prev\n        ? -1\n        : event.clientX > right - EDGE_PX && edges.next\n          ? 1\n          : 0\n    )\n  }\n\n  function goTo(step: number) {\n    setCurrent(step)\n    // The pressed button disables itself at either end, so focus moves\n    // to its partner.\n    if (step === 1) nextRef.current?.focus()\n    if (step > stages.length) prevRef.current?.focus()\n  }\n\n  return (\n    <Stepper\n      value={current}\n      onValueChange={setCurrent}\n      indicators={indicators}\n      className=\"flex max-w-3xl flex-col gap-6\"\n      aria-label=\"Shipment timeline\"\n    >\n      <StepperNav>\n        {/* The stepper owns the tablist roles and the arrow keys, so the\n            carousel's own region role and key handler are switched off; it\n            only moves the row. Slides grow to fill a wide box and keep their\n            content width in a narrow one, which is when the row scrolls. */}\n        <Carousel\n          setApi={setApi}\n          opts={carouselOptions}\n          role={undefined}\n          aria-roledescription={undefined}\n          onKeyDownCapture={undefined}\n          onPointerMove={onPointerMove}\n          onPointerLeave={() => hoverEdge(0)}\n          data-prev={edges.prev || undefined}\n          data-next={edges.next || undefined}\n          className=\"-m-1 min-w-0 flex-1 data-next:mask-r-from-[calc(100%-2.5rem)] data-prev:mask-l-from-[calc(100%-2.5rem)]\"\n        >\n          <CarouselContent className=\"ml-0 py-1\">\n            {stages.map((stage, index) => {\n              const step = index + 1\n              const last = step === stages.length\n\n              // The primitive shows loading on the active stage only, so every\n              // stage carries the flag, the spinner follows the selection and\n              // `indicators` needs no `active` entry.\n              return (\n                <CarouselItem\n                  key={stage.title}\n                  role={undefined}\n                  aria-roledescription={undefined}\n                  className={cn(\n                    \"basis-auto pl-1\",\n                    last ? \"min-w-32 pr-1\" : \"grow\"\n                  )}\n                >\n                  <StepperItem\n                    step={step}\n                    loading\n                    className=\"items-stretch justify-start gap-2 group-data-[orientation=horizontal]/stepper-nav:flex-col\"\n                  >\n                    <div className=\"flex items-center gap-1\">\n                      <StepperTrigger\n                        tabIndex={\n                          allDone && step === stages.length ? 0 : undefined\n                        }\n                        aria-label={stage.title}\n                      >\n                        <StepperIndicator className=\"border-ring/80 data-[state=completed]:border-success size-6.5 border bg-transparent data-[state=active]:bg-transparent data-[state=completed]:bg-transparent\" />\n                      </StepperTrigger>\n\n                      {/* data-state greens the start once this stage is done;\n                          the end follows only when the next stage is done too.\n                          The last connector fades out: the route continues past\n                          the part this timeline shows. */}\n                      {step < stages.length ? (\n                        <StepperSeparator\n                          className={cn(\n                            dots,\n                            \"from-ring/80 to-ring/80 data-[state=completed]:from-success to-70%\",\n                            step + 1 < current\n                              ? \"to-success\"\n                              : \"data-[state=completed]:to-ring\"\n                          )}\n                        />\n                      ) : (\n                        <StepperSeparator\n                          className={cn(dots, \"from-ring/80 to-transparent\")}\n                        />\n                      )}\n                    </div>\n\n                    {/* The title and the detail labels share one auto column\n                        through subgrid, so every leader in a stage starts at the\n                        same x and the column follows the font, not a fixed width. */}\n                    <div\n                      className={cn(\n                        \"grid grid-cols-[auto_1fr] items-baseline gap-x-1 gap-y-1.5 ps-0.5\",\n                        last ? \"pe-1\" : \"pe-5\"\n                      )}\n                    >\n                      <StepperTitle>{stage.title}</StepperTitle>\n                      <div className=\"flex items-baseline gap-1\">\n                        <Leader />\n                        <span className=\"text-muted-foreground text-xs whitespace-nowrap\">\n                          {stage.time}\n                        </span>\n                      </div>\n\n                      <dl className=\"col-span-2 grid grid-cols-subgrid gap-y-1.5\">\n                        {stage.details.map((detail) => (\n                          <div\n                            key={detail.label}\n                            className=\"col-span-2 grid grid-cols-subgrid items-baseline\"\n                          >\n                            <dt className=\"text-xs font-medium\">\n                              {detail.label}\n                            </dt>\n                            <dd className=\"flex items-baseline gap-1\">\n                              <Leader />\n                              {detail.avatar ? (\n                                // Only the name takes part in baseline alignment (the avatar\n                                // centers itself), so the badge's baseline is its text and the\n                                // row lines up with its neighbours.\n                                <Badge\n                                  variant=\"outline\"\n                                  radius=\"full\"\n                                  className=\"items-baseline\"\n                                >\n                                  <Avatar className=\"size-3.5 self-center\">\n                                    <AvatarImage src={detail.avatar} alt=\"\" />\n                                    <AvatarFallback>\n                                      {detail.value.charAt(0)}\n                                    </AvatarFallback>\n                                  </Avatar>\n                                  {detail.value}\n                                </Badge>\n                              ) : detail.badge ? (\n                                <Badge variant={detail.badge} radius=\"full\">\n                                  {detail.value}\n                                </Badge>\n                              ) : (\n                                <span className=\"text-muted-foreground text-xs whitespace-nowrap\">\n                                  {detail.value}\n                                </span>\n                              )}\n                            </dd>\n                          </div>\n                        ))}\n                      </dl>\n                    </div>\n                  </StepperItem>\n                </CarouselItem>\n              )\n            })}\n          </CarouselContent>\n        </Carousel>\n      </StepperNav>\n\n      <StepperPanel className=\"text-center\">\n        {stages.map((stage, index) => (\n          <StepperContent key={stage.title} value={index + 1}>\n            <p className=\"text-sm font-medium\">{stage.title}</p>\n            <p className=\"text-muted-foreground text-sm\">{stage.summary}</p>\n          </StepperContent>\n        ))}\n        <StepperContent value={stages.length + 1}>\n          <p className=\"text-sm font-medium\">Run complete</p>\n          <p className=\"text-muted-foreground text-sm\">\n            Back at depot B23 with every stop delivered.\n          </p>\n        </StepperContent>\n        <div className=\"mt-3 flex justify-center gap-2\">\n          <Button\n            ref={prevRef}\n            variant=\"outline\"\n            size=\"sm\"\n            disabled={current === 1}\n            onClick={() => goTo(current - 1)}\n          >\n            Previous\n          </Button>\n          <Button\n            ref={nextRef}\n            variant=\"outline\"\n            size=\"sm\"\n            disabled={allDone}\n            onClick={() => goTo(current + 1)}\n          >\n            Next\n          </Button>\n        </div>\n      </StepperPanel>\n    </Stepper>\n  )\n}","target":"components/examples/c-stepper-18.tsx"}],"meta":{"order":18,"gridSize":1,"label":"Stage Details"}}