{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sequence",
  "title": "Sequence builder",
  "description": "Shuffled steps the learner drags back into order, with equivalent keyboard reordering, live-region announcements, and an xAPI answered statement carrying the submitted order.",
  "dependencies": [
    "@dnd-kit/dom",
    "@dnd-kit/helpers",
    "@dnd-kit/react"
  ],
  "registryDependencies": [
    "https://beadkit.dev/r/learning-core.json"
  ],
  "files": [
    {
      "path": "components/learning/SequenceBuilder.tsx",
      "content": "\"use client\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  Accessibility,\n  Feedback,\n  KeyboardSensor,\n  PointerActivationConstraints,\n  PointerSensor,\n  defaultPreset,\n} from \"@dnd-kit/dom\";\nimport { move } from \"@dnd-kit/helpers\";\nimport { DragDropProvider } from \"@dnd-kit/react\";\nimport { useSortable } from \"@dnd-kit/react/sortable\";\nimport { cn } from \"@/lib/utils\";\nimport { LearningCard } from \"./LearningCard\";\nimport { useTelemetry, type XApiStatement } from \"./xapi\";\n\nexport interface SequenceStep {\n  id: string;\n  text: string;\n}\n\nexport interface SequenceBuilderProps {\n  activityId: string;\n  prompt: string;\n  /** The correct order. Shown shuffled; the learner reorders back to this. */\n  steps: SequenceStep[];\n  onTrackTelemetry?: (statement: XApiStatement) => void;\n}\n\n/**\n * Deterministic shuffle: same seed, same order, every time.\n *\n * `Math.random()` here would produce one order on the server and another in the\n * browser, which is a hydration mismatch. Seeding from the activity id keeps the\n * function pure, so both sides agree.\n */\nfunction shuffle(steps: SequenceStep[], seed: string): SequenceStep[] {\n  let h = 2166136261;\n  for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619);\n\n  const out = [...steps];\n  for (let i = out.length - 1; i > 0; i--) {\n    h = Math.imul(h ^ (h >>> 15), 2246822507);\n    const j = (h >>> 0) % (i + 1);\n    [out[i], out[j]] = [out[j], out[i]];\n  }\n  // A shuffle that lands on the answer hands the learner a solved activity.\n  // Rotate rather than reshuffle: one step is enough to break it, and it always\n  // terminates.\n  return out.every((s, i) => s.id === steps[i].id) ? [...out.slice(1), out[0]] : out;\n}\n\n/**\n * Local copy rather than the shared hook: this file is what the registry\n * installs, and it lands in projects that have none of this repo's hooks.\n */\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setReduced(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n\n  return reduced;\n}\n\n/**\n * Mouse and pen commit after a few pixels of travel, which is what makes a drag\n * feel immediate. Touch cannot: a finger that starts moving on a list is far\n * more likely to be scrolling the page than reordering it. The delay hands the\n * gesture to the browser unless the learner holds still first, and the\n * tolerance forgives the wobble of a finger that meant to hold.\n */\nconst sensors = [\n  PointerSensor.configure({\n    activationConstraints(event: PointerEvent) {\n      return event.pointerType === \"touch\"\n        ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 8 })]\n        : [new PointerActivationConstraints.Distance({ value: 4 })];\n    },\n  }),\n  KeyboardSensor,\n];\n\n/** The shape of the sortable source as it reaches an announcement. */\ninterface AnnouncedSource {\n  id: string | number;\n  index: number;\n  data?: { text?: string };\n}\n\ninterface AnnouncedEvent {\n  operation: {\n    source: AnnouncedSource | null;\n    target?: { id: string | number; index?: number } | null;\n  };\n  canceled?: boolean;\n}\n\nconst label = (source: AnnouncedSource) => source.data?.text ?? String(source.id);\n\nfunction GripIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"h-4 w-4 fill-current\">\n      <circle cx=\"6\" cy=\"4\" r=\"1.3\" />\n      <circle cx=\"10\" cy=\"4\" r=\"1.3\" />\n      <circle cx=\"6\" cy=\"8\" r=\"1.3\" />\n      <circle cx=\"10\" cy=\"8\" r=\"1.3\" />\n      <circle cx=\"6\" cy=\"12\" r=\"1.3\" />\n      <circle cx=\"10\" cy=\"12\" r=\"1.3\" />\n    </svg>\n  );\n}\n\ninterface StepRowProps {\n  step: SequenceStep;\n  index: number;\n  total: number;\n  submitted: boolean;\n  /** Only meaningful once submitted: whether this step landed in its right place. */\n  placed: boolean;\n  animate: boolean;\n}\n\nfunction StepRow({ step, index, total, submitted, placed, animate }: StepRowProps) {\n  const { ref, handleRef, isDragging } = useSortable({\n    id: step.id,\n    index,\n    disabled: submitted,\n    // Read back in the announcements: an id is what the library knows, but\n    // \"Negotiate TLS\" is what the learner is holding.\n    data: { text: step.text },\n    // `null` drops the index-change animation entirely. The row still moves, it\n    // just arrives rather than sliding there.\n    transition: animate ? undefined : null,\n  });\n\n  return (\n    <li\n      ref={ref}\n      data-sequence-row={step.id}\n      data-dragging={isDragging || undefined}\n      className={cn(\n        \"bg-card flex items-center gap-1.5 rounded-lg border py-1 pr-2 pl-1 text-xs\",\n        animate && \"transition-colors\",\n        !submitted && !isDragging && \"hover:border-foreground/25\",\n        isDragging && \"border-ring ring-ring/40 relative z-10 shadow-lg ring-2\",\n        submitted && (placed ? \"border-success/50\" : \"border-destructive/50\")\n      )}\n    >\n      <button\n        ref={handleRef}\n        type=\"button\"\n        disabled={submitted}\n        data-sequence-handle={step.id}\n        // The visible number says where the step is; the handle has to say it\n        // too, because a screen reader user arrives here by focus, not by eye.\n        aria-label={`Reorder “${step.text}”, step ${index + 1} of ${total}`}\n        className={cn(\n          \"text-muted-foreground focus-visible:ring-ring flex h-9 w-7 shrink-0 items-center justify-center rounded-md focus-visible:ring-2 focus-visible:outline-none\",\n          animate && \"transition-colors\",\n          submitted\n            ? \"opacity-40\"\n            : \"hover:bg-accent hover:text-foreground cursor-grab aria-pressed:cursor-grabbing\"\n        )}\n      >\n        <GripIcon />\n      </button>\n      <span className=\"text-muted-foreground w-4 shrink-0 text-center font-mono\">{index + 1}</span>\n      <span className=\"flex-1 py-1\">{step.text}</span>\n      {submitted && (\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 16 16\"\n          className={cn(\n            \"h-3.5 w-3.5 shrink-0 fill-none stroke-current stroke-2\",\n            placed ? \"text-success\" : \"text-destructive\"\n          )}\n        >\n          {placed ? <path d=\"M3 8.5 6.5 12 13 4.5\" /> : <path d=\"M4 4l8 8M12 4l-8 8\" />}\n        </svg>\n      )}\n    </li>\n  );\n}\n\nexport function SequenceBuilder({\n  activityId,\n  prompt,\n  steps,\n  onTrackTelemetry,\n}: SequenceBuilderProps) {\n  const [order, setOrder] = useState(() => shuffle(steps, activityId));\n  const [submitted, setSubmitted] = useState(false);\n  const track = useTelemetry(activityId, prompt, onTrackTelemetry);\n  const reducedMotion = useReducedMotion();\n  const animate = !reducedMotion;\n  const total = steps.length;\n\n  // A pointer drop leaves focus wherever the pointer happened to leave it.\n  // Putting it back on the handle means the next keyboard move starts from the\n  // step just placed; `:focus-visible` keeps the ring off for mouse users.\n  //\n  // The placeholder the library leaves in the list during a drag is a clone of\n  // the row, handle and all, so the lookup has to skip it or focus can land on\n  // a copy that is about to be removed.\n  const list = useRef<HTMLOListElement>(null);\n  const refocus = useRef<string | null>(null);\n  useEffect(() => {\n    if (!refocus.current) return;\n    list.current\n      ?.querySelector<HTMLElement>(\n        `li:not([data-dnd-placeholder]) [data-sequence-handle=\"${CSS.escape(refocus.current)}\"]`\n      )\n      ?.focus();\n    refocus.current = null;\n  });\n\n  // Escape has to restore the order from before the drag, not from before the\n  // last optimistic shuffle the library applied while the item was in the air.\n  const snapshot = useRef(order);\n\n  const announcements = useMemo(\n    () => ({\n      dragstart({ operation: { source } }: AnnouncedEvent) {\n        if (!source) return;\n        return `Picked up “${label(source)}”, step ${source.index + 1} of ${total}.`;\n      },\n      // The learner is told where the step landed, not which way the pointer\n      // moved — position is the thing they are trying to change. A step resting\n      // over its own slot has not moved, and saying so would talk over the\n      // pickup message the moment the step leaves the ground.\n      dragover({ operation: { source, target } }: AnnouncedEvent) {\n        if (!source || source.id === target?.id) return;\n        // Mid-drag the source still carries the index it started from; the step\n        // being displaced is the one whose slot it is taking.\n        const landing = target?.index ?? source.index;\n        return `“${label(source)}” is now step ${landing + 1} of ${total}.`;\n      },\n      dragend({ operation: { source }, canceled }: AnnouncedEvent) {\n        if (!source) return;\n        return canceled\n          ? `Move cancelled. “${label(source)}” is back at step ${source.index + 1} of ${total}.`\n          : `Dropped “${label(source)}” at step ${source.index + 1} of ${total}.`;\n      },\n    }),\n    [total]\n  );\n\n  // Memoized because the provider compares plugin arrays by identity and\n  // rebuilds every plugin when the identity changes — a fresh array per render\n  // would tear down the live region mid-drag.\n  const plugins = useMemo(\n    () => [\n      ...defaultPreset.plugins.filter(\n        (plugin) => plugin !== Accessibility && plugin !== Feedback\n      ),\n      Accessibility.configure({\n        // Deterministic ids: the live region lands on document.body, and a page\n        // holding more than one sequence would otherwise be a lottery for\n        // anything trying to read a specific one.\n        id: activityId,\n        announcements,\n        screenReaderInstructions: {\n          draggable:\n            \"Press space or enter to pick up a step. Use the up and down arrow keys to move it, space or enter to drop it in place, and escape to cancel.\",\n        },\n        // The default half-second debounce swallows the intermediate positions\n        // of a fast arrow-key run, which is the whole account of the move.\n        debounce: 0,\n      }),\n      Feedback.configure(\n        reducedMotion ? { dropAnimation: null, keyboardTransition: null } : {}\n      ),\n    ],\n    [activityId, announcements, reducedMotion]\n  );\n\n  const correct = order.every((s, i) => s.id === steps[i].id);\n\n  const handleSubmit = () => {\n    if (submitted) return;\n    setSubmitted(true);\n    track(\"answered\", { response: order.map((s) => s.id).join(\",\"), success: correct });\n  };\n\n  return (\n    <LearningCard label=\"Put it in order\" type=\"sequence\">\n      <p id={`${activityId}-prompt`} className=\"mb-3 text-sm font-semibold\">\n        {prompt}\n      </p>\n\n      <DragDropProvider\n        sensors={sensors}\n        plugins={plugins}\n        onDragStart={() => {\n          snapshot.current = order;\n        }}\n        onDragOver={(event) => setOrder((current) => move(current, event))}\n        onDragEnd={(event) => {\n          if (event.canceled) {\n            setOrder(snapshot.current);\n            return;\n          }\n          const source = event.operation.source;\n          if (source) refocus.current = String(source.id);\n          setOrder((current) => move(current, event));\n        }}\n      >\n        <ol\n          ref={list}\n          aria-labelledby={`${activityId}-prompt`}\n          className=\"space-y-1.5\"\n        >\n          {order.map((step, i) => (\n            <StepRow\n              key={step.id}\n              step={step}\n              index={i}\n              total={total}\n              submitted={submitted}\n              placed={step.id === steps[i]?.id}\n              animate={animate}\n            />\n          ))}\n        </ol>\n      </DragDropProvider>\n\n      {!submitted && (\n        <button\n          onClick={handleSubmit}\n          className=\"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring mt-3 w-full rounded-lg py-2 text-xs font-medium transition focus-visible:ring-2 focus-visible:ring-offset-1\"\n        >\n          Check order\n        </button>\n      )}\n\n      <div aria-live=\"polite\">\n        {submitted && (\n          <div\n            className={`mt-3 rounded-lg border p-3 text-xs ${\n              correct ? \"border-success/30 bg-success/10\" : \"border-destructive/30 bg-destructive/10\"\n            }`}\n          >\n            <span\n              className={`mb-1 block font-bold ${correct ? \"text-success\" : \"text-destructive\"}`}\n            >\n              {correct ? \"Correct\" : \"Not quite\"}\n            </span>\n            {correct\n              ? \"That is the right sequence.\"\n              : `The right order is: ${steps.map((s) => s.text).join(\" → \")}`}\n          </div>\n        )}\n      </div>\n    </LearningCard>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/learning/SequenceBuilder.tsx"
    }
  ],
  "type": "registry:component"
}
