{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"contextual-dock","type":"registry:component","title":"Contextual Dock","description":"A toolbar that reconfigures around the selection — with no layout jump.","dependencies":[],"categories":["navigation","toolbar","selection","context","actions"],"files":[{"path":"components/lab/contextual-dock/contextual-dock.tsx","target":"components/lab/contextual-dock/contextual-dock.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport \"./contextual-dock.css\";\n\nexport type DockContext = \"none\" | \"text\" | \"object\" | \"multi\";\n\nexport type DockAction = {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  onSelect?: () => void;\n};\n\nexport type ContextualDockProps = {\n  /** The host’s current selection kind. */\n  context: DockContext;\n  /** Actions per context. The dock always reserves the widest set. */\n  sets: Record<DockContext, DockAction[]>;\n  /** Status line, e.g. “3 blocks selected”. */\n  label?: string;\n  placement?: \"floating\" | \"inline\";\n  /** Fired with the action id after `action.onSelect()`. */\n  onAction?: (id: string, context: DockContext) => void;\n  className?: string;\n};\n\nconst CONTEXT_ORDER: DockContext[] = [\"none\", \"text\", \"object\", \"multi\"];\n\nconst CONTEXT_NAMES: Record<DockContext, string> = {\n  none: \"No selection\",\n  text: \"Text selection\",\n  object: \"Object selection\",\n  multi: \"Multi-selection\",\n};\n\n/**\n * CONTEXTUAL DOCK — a toolbar that reconfigures without moving.\n *\n * Every context’s action set is rendered into the same grid cell, so\n * the dock’s width is inherently the widest set; the inactive sets\n * are visibility:hidden and only opacity cross-fades. Nothing shifts\n * under the pointer when the selection changes. Focus is roving —\n * exactly one button is tabbable — and each change is announced.\n */\nexport function ContextualDock({\n  context,\n  sets,\n  label,\n  placement = \"floating\",\n  onAction,\n  className,\n}: ContextualDockProps) {\n  const activeSet = sets[context] ?? [];\n  const activeCount = activeSet.length;\n  const [focusIndex, setFocusIndex] = useState(0);\n  const buttonRefs = useRef<Partial<Record<DockContext, (HTMLButtonElement | null)[]>>>(\n    {},\n  );\n\n  const safeIndex =\n    activeSet.length === 0 ? 0 : Math.min(focusIndex, activeSet.length - 1);\n\n  // The remembered position survives a context change, but never\n  // points past the new set.\n  const max = Math.max(0, activeCount - 1);\n  if (focusIndex > max) {\n    setFocusIndex(max);\n  }\n\n  const handleToolbarKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const count = activeSet.length;\n    if (count === 0) return;\n    let next: number | null = null;\n    switch (event.key) {\n      case \"ArrowRight\":\n        next = (safeIndex + 1) % count;\n        break;\n      case \"ArrowLeft\":\n        next = (safeIndex - 1 + count) % count;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = count - 1;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    setFocusIndex(next);\n    buttonRefs.current[context]?.[next]?.focus();\n  };\n\n  const runAction = (action: DockAction) => {\n    action.onSelect?.();\n    onAction?.(action.id, context);\n  };\n\n  const announcement = `${CONTEXT_NAMES[context]} — ${activeSet.length} ${\n    activeSet.length === 1 ? \"action\" : \"actions\"\n  }`;\n\n  return (\n    <div\n      className={`cd-dock${className ? ` ${className}` : \"\"}`}\n      data-placement={placement}\n      data-context={context}\n    >\n      <div className=\"cd-status\">\n        <span className=\"cd-status-text\" aria-live=\"polite\">\n          {label ?? \"\"}\n        </span>\n        <span className=\"cd-sr\" aria-live=\"polite\">\n          {announcement}\n        </span>\n      </div>\n\n      <div\n        className=\"cd-sets\"\n        role=\"toolbar\"\n        aria-label=\"Selection actions\"\n        aria-orientation=\"horizontal\"\n        onKeyDown={handleToolbarKeyDown}\n      >\n        {CONTEXT_ORDER.map((setContext) => {\n          const actions = sets[setContext] ?? [];\n          const isActive = setContext === context;\n          return (\n            <div\n              key={setContext}\n              className=\"cd-set\"\n              data-active={isActive ? \"true\" : undefined}\n              aria-hidden={!isActive}\n            >\n              {actions.map((action, index) => (\n                <button\n                  key={action.id}\n                  type=\"button\"\n                  className=\"cd-btn\"\n                  data-selected={isActive && index === safeIndex ? \"true\" : undefined}\n                  tabIndex={isActive && index === safeIndex ? 0 : -1}\n                  ref={(element) => {\n                    const list = (buttonRefs.current[setContext] ??= []);\n                    list[index] = element;\n                  }}\n                  onFocus={() => {\n                    if (isActive) setFocusIndex(index);\n                  }}\n                  onClick={() => runAction(action)}\n                >\n                  {action.icon ? (\n                    <span className=\"cd-icon\" aria-hidden=\"true\">\n                      {action.icon}\n                    </span>\n                  ) : null}\n                  <span className=\"cd-btn-label\">{action.label}</span>\n                </button>\n              ))}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/lab/contextual-dock/contextual-dock.css","target":"components/lab/contextual-dock/contextual-dock.css","type":"registry:file","content":"/* ============================================================\n   CONTEXTUAL DOCK — self-contained styles.\n   Tokens are scoped on .cd-dock with fallbacks; nothing here\n   depends on the Lab’s stylesheet.\n   ============================================================ */\n\n.cd-dock {\n  --cd-ink: #141418;\n  --cd-muted: #5f5f68;\n  --cd-faint: #8c8c94;\n  --cd-line: rgba(20, 20, 24, 0.1);\n  --cd-line-soft: rgba(20, 20, 24, 0.06);\n  --cd-surface: #ffffff;\n  --cd-surface-soft: #f4f4f3;\n  --cd-accent: #4f27e0;\n  --cd-accent-soft: rgba(79, 39, 224, 0.09);\n  --cd-focus: #4f27e0;\n\n  box-sizing: border-box;\n  width: 100%;\n  max-width: 100%;\n  font-family: inherit;\n  color: var(--cd-ink);\n  background: var(--cd-surface);\n}\n\n.cd-dock *,\n.cd-dock *::before,\n.cd-dock *::after {\n  box-sizing: border-box;\n}\n\n/* Floating lifts off the page; inline sits flush in the flow.\n   Both keep the same internal height. */\n.cd-dock[data-placement=\"floating\"] {\n  border: 1px solid var(--cd-line);\n  border-radius: 12px;\n  box-shadow: 0 12px 32px -14px rgba(20, 20, 24, 0.28),\n    0 2px 6px -2px rgba(20, 20, 24, 0.1);\n}\n\n/* Inline keeps the same 1px frame so both placements share a height. */\n.cd-dock[data-placement=\"inline\"] {\n  border: 1px solid transparent;\n  border-top-color: var(--cd-line);\n  border-radius: 0;\n  box-shadow: none;\n}\n\n/* ---------- status line ---------- */\n\n.cd-status {\n  display: flex;\n  align-items: center;\n  min-height: 22px;\n  padding: 8px 10px 0;\n}\n\n.cd-status-text {\n  font-size: 0.7188rem;\n  line-height: 1;\n  color: var(--cd-muted);\n}\n\n.cd-sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n  border: 0;\n}\n\n/* ---------- stacked sets: one grid cell, widest set defines width ---------- */\n\n.cd-sets {\n  display: grid;\n  grid-template-areas: \"stack\";\n  align-items: center;\n  overflow-x: auto;\n  padding: 8px 10px 10px;\n  scrollbar-width: none;\n}\n\n.cd-sets::-webkit-scrollbar {\n  display: none;\n}\n\n.cd-set {\n  grid-area: stack;\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  min-width: 0;\n  opacity: 0;\n  visibility: hidden;\n  transition: opacity 140ms ease, visibility 0s linear 140ms;\n}\n\n.cd-set[data-active=\"true\"] {\n  opacity: 1;\n  visibility: visible;\n  transition: opacity 140ms ease, visibility 0s;\n}\n\n/* ---------- buttons ---------- */\n\n.cd-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  flex: none;\n  padding: 6px 10px;\n  border: 1px solid transparent;\n  border-radius: 8px;\n  background: transparent;\n  font: inherit;\n  font-size: 0.7813rem;\n  font-weight: 500;\n  line-height: 1.2;\n  color: var(--cd-ink);\n  white-space: nowrap;\n  cursor: pointer;\n}\n\n.cd-btn:hover {\n  background: var(--cd-surface-soft);\n}\n\n.cd-btn:active {\n  background: var(--cd-accent-soft);\n}\n\n.cd-btn:focus-visible {\n  outline: 2px solid var(--cd-focus);\n  outline-offset: 1px;\n}\n\n.cd-btn[data-selected=\"true\"] {\n  border-color: var(--cd-line);\n  background: var(--cd-surface-soft);\n}\n\n.cd-icon {\n  display: inline-flex;\n  flex: none;\n  color: var(--cd-muted);\n}\n\n.cd-icon svg {\n  display: block;\n  width: 14px;\n  height: 14px;\n}\n\n.cd-btn[data-selected=\"true\"] .cd-icon {\n  color: var(--cd-accent);\n}\n\n.cd-btn-label {\n  white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .cd-set,\n  .cd-set[data-active=\"true\"] {\n    transition: none;\n  }\n}\n"}]}