{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stage-position-list",
  "type": "registry:component",
  "title": "Stage position list",
  "description": "Reviews supplied named stage positions with an explicit coordinate frame, per-axis units, inclusion state and controlled selection, reorder and removal requests.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/checkbox.json",
    "https://vlak.dev/r/radio.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/stage-position-list.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useInputValue } from \"./use-input-value\";\nimport { useMergedRefs } from \"./merge-refs\";\nimport { Button } from \"./button\";\nimport { Checkbox } from \"./checkbox\";\nimport { Radio, RadioGroup } from \"./radio\";\n\nexport interface StagePosition {\n  readonly id: string;\n  name: string;\n  x: number | null;\n  y: number | null;\n  z: number | null;\n  enabled: boolean;\n  /** A supplied pending edit locks this record and adjacent reorder requests. */\n  pending?: boolean;\n}\nexport interface StageCoordinateFrame { id: string; label: string }\nexport interface StagePositionUnits { x: string | null; y: string | null; z: string | null }\nexport interface StagePositionListProps extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, \"defaultValue\"> {\n  label: React.ReactNode;\n  coordinateFrame: StageCoordinateFrame | null;\n  units: StagePositionUnits;\n  positions: readonly StagePosition[];\n  /** Controlled selected record identifier; pair with onValueChange for inspection. Missing or unknown identifiers never select a fallback. */\n  value?: string | null;\n  onValueChange?: (id: string) => void;\n  onEnabledChange?: (id: string, enabled: boolean) => void;\n  /** Requests an order of existing identifiers without changing records. */\n  onOrderChange?: (ids: readonly string[]) => void;\n  onRemove?: (id: string) => void;\n  description?: React.ReactNode;\n  /** Prevents record edits; inspection selection remains available. */\n  readOnly?: boolean;\n}\n\nconst styles = stylex.create({\n  root: { display: \"grid\", gap: \"0.75rem\", minWidth: 0, margin: 0, padding: 0, borderWidth: 0, color: vlak.ink, lineHeight: 1.45 },\n  legend: { padding: 0, marginBottom: \"0.75rem\", fontWeight: 600, fontSize: \"0.875rem\" },\n  copy: { margin: 0, fontSize: \"0.75rem\", color: vlak.gray, overflowWrap: \"anywhere\", maxWidth: \"66ch\" },\n  list: { listStyleType: \"none\", margin: 0, padding: 0, minWidth: 0 },\n  row: { paddingBlock: \"1rem\", paddingInline: \"0.75rem\", borderBottomWidth: vlak.hairline, borderBottomStyle: \"solid\", borderBottomColor: vlak.divider, minWidth: 0, \":focus-visible\": { outlineWidth: 2, outlineStyle: \"solid\", outlineColor: vlak.ink, outlineOffset: -2 } },\n  selected: { backgroundColor: { default: vlak.tableAlt, [mq.forcedColors]: \"Canvas\" } },\n  head: { display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", flexWrap: \"wrap\", gap: \"0.5rem 1rem\", minWidth: 0 },\n  identity: { display: \"grid\", minWidth: 0, overflowWrap: \"anywhere\", fontSize: \"0.875rem\" },\n  coordinateList: { display: \"grid\", gridTemplateColumns: \"repeat(3, minmax(0, 1fr))\", gap: \"0.5rem 1rem\", marginBlock: \"0.75rem\", marginInline: 0 },\n  coordinate: { minWidth: 0, overflowWrap: \"anywhere\" },\n  axis: { margin: 0, color: vlak.gray, fontSize: \"0.75rem\" },\n  number: { margin: 0, fontSize: \"0.875rem\", fontVariantNumeric: \"tabular-nums\" },\n  actions: { display: \"flex\", flexWrap: \"wrap\", gap: \"0.5rem\", marginTop: \"0.75rem\" },\n  action: { minWidth: vlak.hit, minHeight: vlak.hit, width: \"auto\" },\n  status: { position: \"absolute\", width: 1, height: 1, overflow: \"hidden\", clipPath: \"inset(50%)\", whiteSpace: \"nowrap\" },\n});\n\nconst axes = [\"x\", \"y\", \"z\"] as const;\nconst hasUnit = (unit: string | null) => typeof unit === \"string\" && unit.trim().length > 0;\nfunction coordinate(value: number | null) {\n  if (value == null) return \"Not supplied\";\n  if (!Number.isFinite(value)) return \"Unavailable\";\n  return Object.is(value, -0) ? \"-0\" : String(value);\n}\ninterface FocusRequest { kind: \"order\" | \"remove\"; id: string; name: string; order: readonly string[]; control: HTMLButtonElement; ownedFocus: boolean; next?: string; previous?: string }\n\n/** Supplied stage coordinates and controlled plan edits. No stage motion or coordinate conversion occurs. */\nexport const StagePositionList = React.forwardRef<HTMLFieldSetElement, StagePositionListProps>(function StagePositionList({ label, coordinateFrame, units, positions, value = null, onValueChange, onEnabledChange, onOrderChange, onRemove, description, readOnly = false, disabled, name, form, className, style, children, \"aria-describedby\": ariaDescribedBy, ...props }, ref) {\n  const [, refresh] = React.useReducer((revision: number) => revision + 1, 0);\n  const [, , fieldRef] = useInputValue<readonly StagePosition[], HTMLFieldSetElement>(positions, positions, undefined, refresh);\n  const mergedRef = useMergedRefs(ref, fieldRef);\n  const pendingFocus = React.useRef<FocusRequest | null>(null);\n  const rows = React.useRef(new Map<string, HTMLLIElement>());\n  const radios = React.useRef(new Map<string, HTMLInputElement>());\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const id = React.useId();\n  const valid = positions.length <= 128 && positions.every(position => typeof position.id === \"string\" && position.id.trim()) && new Set(positions.map(position => position.id)).size === positions.length;\n  const selected = valid && value != null && positions.some(position => position.id === value);\n  const frameLabel = coordinateFrame?.id.trim() && coordinateFrame.label.trim() ? `${coordinateFrame.label} (${coordinateFrame.id})` : \"Not supplied\";\n  const describedBy = [ariaDescribedBy, `${id}-frame`, `${id}-selection`, description != null && `${id}-description`].filter(Boolean).join(\" \");\n  React.useLayoutEffect(() => {\n    const request = pendingFocus.current;\n    if (!request || !valid) return;\n    const index = positions.findIndex(position => position.id === request.id);\n    const accepted = request.kind === \"remove\" ? index < 0 : request.order.length === positions.length && request.order.every((key, i) => positions[i]?.id === key);\n    if (!accepted) return;\n    pendingFocus.current = null;\n    setAnnouncement(request.kind === \"remove\" ? `${request.name} (${request.id}) removed; ${positions.length} positions remain` : `${request.name} (${request.id}), position ${index + 1} of ${positions.length}`);\n    const active = request.control.ownerDocument.activeElement;\n    if (!request.ownedFocus || active !== request.control && active !== request.control.ownerDocument.body) return;\n    if (request.kind === \"order\" && request.control.isConnected && !request.control.disabled) return;\n    const targetId = request.kind === \"order\" ? request.id : [request.next, request.previous, positions[0]?.id].find(key => key != null && rows.current.has(key));\n    const radio = targetId == null ? undefined : radios.current.get(targetId);\n    const target = radio && !radio.disabled ? radio : targetId == null ? fieldRef.current : rows.current.get(targetId);\n    target?.focus({ preventScroll: true });\n  }, [positions, valid, fieldRef]);\n  function requestOrder(index: number, delta: number, control: HTMLButtonElement) {\n    const position = positions[index];\n    if (!position || disabled || readOnly || position.pending || positions[index + delta]?.pending || !positions[index + delta]) return;\n    const order = positions.map(entry => entry.id);\n    [order[index], order[index + delta]] = [order[index + delta]!, order[index]!];\n    pendingFocus.current = { kind: \"order\", id: position.id, name: position.name || \"Unnamed position\", order, control, ownedFocus: control.ownerDocument.activeElement === control };\n    onOrderChange?.(order);\n  }\n  function requestRemoval(index: number, control: HTMLButtonElement) {\n    const position = positions[index];\n    if (!position || disabled || readOnly || position.pending) return;\n    pendingFocus.current = { kind: \"remove\", id: position.id, name: position.name || \"Unnamed position\", order: [], control, ownedFocus: control.ownerDocument.activeElement === control, next: positions[index + 1]?.id, previous: positions[index - 1]?.id };\n    onRemove?.(position.id);\n  }\n  const root = rs([\"rs-stage-position-list\", className], styles.root);\n  const legend = rs([\"rs-stage-position-list-legend\"], styles.legend);\n  const copy = rs([\"rs-stage-position-list-copy\"], styles.copy);\n  const list = rs([\"rs-stage-position-list-items\"], styles.list);\n  const head = rs([\"rs-stage-position-list-head\"], styles.head);\n  const identity = rs([\"rs-stage-position-list-identity\"], styles.identity);\n  const coordinateList = rs([\"rs-stage-position-list-coordinates\"], styles.coordinateList);\n  const coordinateStyle = rs([\"rs-stage-position-list-coordinate\"], styles.coordinate);\n  const axisStyle = rs([\"rs-stage-position-list-axis\"], styles.axis);\n  const number = rs([\"rs-stage-position-list-number\"], styles.number);\n  const actions = rs([\"rs-stage-position-list-actions\"], styles.actions);\n  const action = rs([\"rs-stage-position-list-action\"], styles.action);\n  const status = rs([\"rs-stage-position-list-status\"], styles.status);\n  return <fieldset {...props} ref={mergedRef} tabIndex={props.tabIndex ?? -1} disabled={disabled} name={name} form={form} aria-describedby={describedBy} className={root.className} style={{ ...root.style, ...style }}>\n    <legend {...legend} id={`${id}-label`}>{label}</legend>\n    {description != null && <div {...copy} id={`${id}-description`}>{description}</div>}\n    <p {...copy} id={`${id}-frame`}>Coordinate frame: {frameLabel}</p>\n    <p {...copy} id={`${id}-selection`}>{selected ? `Selected: ${positions.find(position => position.id === value)?.name || \"Unnamed position\"} (${value})` : value == null ? \"No position selected\" : \"Selected position unavailable\"}</p>\n    {!valid ? <p {...copy}>Positions unavailable: supply unique nonempty identifiers and at most 128 positions</p> : positions.length === 0 ? <p {...copy}>No stage positions supplied</p> : <RadioGroup aria-labelledby={`${id}-label`} name={name} value={selected ? value! : \"\"} onValueChange={onValueChange}>\n      <ol {...list}>{positions.map((position, index) => {\n        const isSelected = selected && position.id === value;\n        const row = rs([\"rs-stage-position-list-row\", isSelected && \"rs-stage-position-list-selected\"], styles.row, isSelected && styles.selected);\n        const context = `${position.name || \"Unnamed position\"} (${position.id})`;\n        const locked = disabled || readOnly || position.pending;\n        return <li key={position.id} {...row} ref={element => { if (element) rows.current.set(position.id, element); else rows.current.delete(position.id); }} tabIndex={-1} aria-label={context}>\n          <div {...head}><Radio ref={element => { if (element) radios.current.set(position.id, element); else radios.current.delete(position.id); }} value={position.id} form={form} disabled={disabled || !onValueChange} aria-describedby={`${id}-${index}-coordinates`} label={<span {...identity}><span>{position.name || \"Unnamed position\"}</span><span {...copy}>({position.id})</span></span>} />\n            <Checkbox checked={position.enabled === true} indeterminate={typeof position.enabled !== \"boolean\"} disabled={locked || !onEnabledChange} form={form} label=\"Include\" aria-label={`${context}: Include`} onCheckedChange={enabled => onEnabledChange?.(position.id, enabled)} /></div>\n          <dl {...coordinateList} id={`${id}-${index}-coordinates`}>{axes.map(axis => <div key={axis} {...coordinateStyle}><dt {...axisStyle}>{axis.toUpperCase()} ({hasUnit(units[axis]) ? units[axis] : \"Unit not supplied\"})</dt><dd {...number}>{coordinate(position[axis])}</dd></div>)}</dl>\n          <p {...copy}>{typeof position.enabled !== \"boolean\" ? \"Inclusion unavailable\" : position.enabled ? \"Included in plan\" : \"Excluded from plan\"}</p>\n          {position.pending && <p {...copy}>Edit pending; supplied record remains unchanged</p>}\n          {(onOrderChange || onRemove) && <div {...actions}>\n            {onOrderChange && <><Button {...action} type=\"button\" variant=\"ghost\" aria-label={`${context}: Move up`} disabled={locked || index === 0 || positions[index - 1]?.pending} onClick={event => requestOrder(index, -1, event.currentTarget)}>Move up</Button><Button {...action} type=\"button\" variant=\"ghost\" aria-label={`${context}: Move down`} disabled={locked || index === positions.length - 1 || positions[index + 1]?.pending} onClick={event => requestOrder(index, 1, event.currentTarget)}>Move down</Button></>}\n            {onRemove && <Button {...action} type=\"button\" variant=\"ghost\" aria-label={`${context}: Remove`} disabled={locked} onClick={event => requestRemoval(index, event.currentTarget)}>Remove</Button>}\n          </div>}\n        </li>;\n      })}</ol>\n    </RadioGroup>}\n    {name && selected && !onValueChange && <input type=\"hidden\" name={name} form={form} value={value!} />}\n    <span {...status} role=\"status\">{announcement}</span>{children}\n  </fieldset>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/stage-position-list.tsx"
    },
    {
      "path": "vlak/use-input-value.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/** Shared state for compound inputs. Native form reset restores uncontrolled defaults. */\nexport function useInputValue<T, E extends HTMLElement>(\n  value: T | undefined,\n  defaultValue: T,\n  onValueChange?: (value: T) => void,\n  onReset?: () => void,\n) {\n  const [inner, setInner] = React.useState(defaultValue);\n  const element = React.useRef<E>(null);\n  const controlled = value !== undefined;\n  const reset = React.useRef({ controlled, defaultValue, onReset });\n  reset.current = { controlled, defaultValue, onReset };\n  React.useEffect(() => {\n    const node = element.current;\n    const form = node && \"form\" in node ? (node as unknown as HTMLInputElement).form : node?.closest(\"form\");\n    if (!form) return;\n    const handleReset = (event: Event) => {\n      queueMicrotask(() => {\n        if (!event.defaultPrevented) {\n          if (!reset.current.controlled) setInner(reset.current.defaultValue);\n          reset.current.onReset?.();\n        }\n      });\n    };\n    form.addEventListener(\"reset\", handleReset);\n    return () => form.removeEventListener(\"reset\", handleReset);\n  }, []);\n  const setValue = (next: T) => {\n    if (!controlled) setInner(next);\n    onValueChange?.(next);\n  };\n  return [controlled ? value : inner, setValue, element] as const;\n}\n",
      "type": "registry:file",
      "target": "components/vlak/use-input-value.ts"
    },
    {
      "path": "vlak/styles/stage-position-list.css",
      "content": "/* ── stage-position-list: generated from packages/react/src/components/stage-position-list.tsx ── */\n.rs-stage-position-list{display:grid;gap:0.75rem;min-width:0;margin:0;padding:0;border-width:0;color:var(--text);line-height:1.45}\n.rs-stage-position-list-legend{padding:0;margin-bottom:0.75rem;font-weight:600;font-size:0.875rem}\n.rs-stage-position-list-copy{margin:0;font-size:0.75rem;color:var(--text-secondary);overflow-wrap:anywhere;max-width:66ch}\n.rs-stage-position-list-items{list-style-type:none;margin:0;padding:0;min-width:0}\n.rs-stage-position-list-row{padding-block:1rem;padding-inline:0.75rem;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:var(--divider);min-width:0}\n.rs-stage-position-list-row:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:-2px}\n.rs-stage-position-list-selected{background-color:var(--table-alt)}\n@media (forced-colors: active){.rs-stage-position-list-selected{background-color:Canvas}}\n.rs-stage-position-list-head{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.5rem 1rem;min-width:0}\n.rs-stage-position-list-identity{display:grid;min-width:0;overflow-wrap:anywhere;font-size:0.875rem}\n.rs-stage-position-list-coordinates{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:0.5rem 1rem;margin-block:0.75rem;margin-inline:0}\n.rs-stage-position-list-coordinate{min-width:0;overflow-wrap:anywhere}\n.rs-stage-position-list-axis{margin:0;color:var(--text-secondary);font-size:0.75rem}\n.rs-stage-position-list-number{margin:0;font-size:0.875rem;font-variant-numeric:tabular-nums}\n.rs-stage-position-list-actions{display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.75rem}\n.rs-stage-position-list-action{min-width:var(--hit);min-height:var(--hit);width:auto}\n.rs-stage-position-list-status{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}\n",
      "type": "registry:file",
      "target": "styles/vlak/stage-position-list.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "science",
      "classes": [
        "rs-stage-position-list",
        "rs-stage-position-list-legend",
        "rs-stage-position-list-copy",
        "rs-stage-position-list-items",
        "rs-stage-position-list-row",
        "rs-stage-position-list-selected",
        "rs-stage-position-list-head",
        "rs-stage-position-list-identity",
        "rs-stage-position-list-coordinates",
        "rs-stage-position-list-coordinate",
        "rs-stage-position-list-axis",
        "rs-stage-position-list-number",
        "rs-stage-position-list-actions",
        "rs-stage-position-list-action",
        "rs-stage-position-list-status"
      ],
      "snippet": "<fieldset class=\"rs-stage-position-list\"><legend class=\"rs-stage-position-list-legend\">Slide positions</legend><p class=\"rs-stage-position-list-copy\">Coordinate frame: Recorded stage (stage-01)</p><ol class=\"rs-stage-position-list-items\"><li class=\"rs-stage-position-list-row\"><p class=\"rs-stage-position-list-identity\">Overview (field-01)</p><dl class=\"rs-stage-position-list-coordinates\"><div class=\"rs-stage-position-list-coordinate\"><dt class=\"rs-stage-position-list-axis\">X (µm)</dt><dd class=\"rs-stage-position-list-number\">0</dd></div><div class=\"rs-stage-position-list-coordinate\"><dt class=\"rs-stage-position-list-axis\">Y (µm)</dt><dd class=\"rs-stage-position-list-number\">-12.5</dd></div><div class=\"rs-stage-position-list-coordinate\"><dt class=\"rs-stage-position-list-axis\">Z (µm)</dt><dd class=\"rs-stage-position-list-number\">Not supplied</dd></div></dl><p class=\"rs-stage-position-list-copy\">Included in plan</p></li></ol></fieldset>",
      "cssOnly": false,
      "registryDependencies": [
        "button",
        "checkbox",
        "radio",
        "vlak-lib"
      ],
      "aliases": [
        "stage positions",
        "position list",
        "microscopy positions",
        "multi-position acquisition",
        "waypoint list"
      ],
      "example": "import { useState } from \"react\";\nimport { StagePositionList } from \"@noorddev/vlak-react\";\nimport type { StagePosition } from \"@noorddev/vlak-react\";\n\nexport function PositionPlan() {\n  const [positions, setPositions] = useState<readonly StagePosition[]>([\n    { id: \"field-01\", name: \"Overview\", x: 0, y: -12.5, z: null, enabled: true },\n    { id: \"field-02\", name: \"Edge detail\", x: 120.25, y: 36, z: 0, enabled: false },\n  ]);\n  const [selected, setSelected] = useState<string | null>(null);\n  return <StagePositionList\n    label=\"Slide positions\"\n    coordinateFrame={{ id: \"stage-01\", label: \"Recorded stage\" }}\n    units={{ x: \"µm\", y: \"µm\", z: \"µm\" }}\n    positions={positions}\n    value={selected}\n    onValueChange={setSelected}\n    onEnabledChange={(id, enabled) => setPositions(current => current.map(position => position.id === id ? { ...position, enabled } : position))}\n    onOrderChange={ids => setPositions(current => ids.map(id => current.find(position => position.id === id)!))}\n    onRemove={id => { setPositions(current => current.filter(position => position.id !== id)); if (selected === id) setSelected(null); }}\n  />;\n}",
      "usage": {
        "use": [
          "Reviewing a microscopy stage-position plan with explicit frame identity, supplied coordinates and per-axis units.",
          "Selection requires value and onValueChange; there is no uncontrolled selection. Inclusion, order and removal are controlled requests. The host supplies accepted records; no optimistic edits or instrument commands occur.",
          "Names may repeat; immutable unique IDs distinguish rows and actions. At most 128 positions are rendered. Invalid identifiers or excess records produce an explicit unavailable state.",
          "Missing coordinates show Not supplied; nonfinite coordinates show Unavailable. Zero, negative zero and signed finite values retain their supplied representation. Missing frame or units stay explicit.",
          "readOnly locks record edits while inspection selection remains available. pending locks edits to a record and swaps across it. disabled uses a native fieldset and locks every control.",
          "An optional name submits the selected record ID; form associates the selection with an external form. Coordinates and inclusion records remain host-owned data. Controlled values survive native form reset."
        ],
        "avoid": [
          "Moving a stage, calculating travel, transforming coordinate frames or converting physical units.",
          "Treating selection as an instrument position, missing coordinates as zero, or an edit request as an accepted plan.",
          "Large position inventories that need pagination or virtualization; use a bounded window instead."
        ]
      },
      "keyboard": [
        {
          "keys": "Tab, Shift+Tab",
          "does": "Moves between the native radio group, inclusion checkboxes and enabled move/remove buttons."
        },
        {
          "keys": "Arrow keys",
          "does": "Changes the focused native radio selection and requests its record ID; the host controls the accepted selection."
        },
        {
          "keys": "Space",
          "does": "Selects a focused radio or requests a focused inclusion checkbox change."
        },
        {
          "keys": "Enter, Space",
          "does": "Activates a move or remove button. Reorder focus follows the same ID; accepted removal focuses an adjacent surviving row or the empty fieldset without stealing focus from elsewhere."
        }
      ],
      "a11y": [
        "A fieldset legend names the position list; frame context, selection status and optional host descriptions are associated with it.",
        "Native radios, checkboxes and buttons retain keyboard behavior, visible focus and at least 44px targets. Coordinates use a labelled definition list with explicit axis units.",
        "Visible immutable IDs keep repeated names distinguishable. Unknown selected IDs leave every radio unselected and produce an explicit message.",
        "A polite announcement reports only host-accepted reorder or removal, not the initial request. Pending and inclusion states have text equivalents.",
        "The fieldset ref and native attributes pass through; className, style, children and aria-describedby merge with component context.",
        "Logical spacing, wrapping actions and bounded coordinate columns support narrow layouts. Forced-colors styling preserves native control states and focus."
      ]
    }
  }
}
