{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"resource-list","title":"Resource List","description":"Reduced entity list with explicit indicator, content and meta slots.","dependencies":["@base-ui/react@1.7.0"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/action-icons","@siteplane/motion"],"files":[{"path":"packages/ui/src/components/resource-list/resource-list.tsx","content":"\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as React from \"react\";\n\nimport { useResolvedDensity } from \"../../system/provider/siteplane-ui-provider\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { Button } from \"../button\";\nimport { SelectionCheckIcon } from \"../action-icons\";\nimport { MotionItem, MotionList, type MotionListProps } from \"../motion\";\nimport { useFocusHandoff } from \"./resource-list-focus\";\n\nexport type ResourceListDensity = \"default\" | \"compact\";\nexport type ResourceListVariant = \"default\" | \"soft\" | \"divided\";\nexport type ResourceListIndicatorTone =\n  | \"chart-1\"\n  | \"chart-2\"\n  | \"chart-3\"\n  | \"chart-4\"\n  | \"chart-5\"\n  | \"error\"\n  | \"info\"\n  | \"neutral\"\n  | \"success\"\n  | \"warning\";\n/** @deprecated `muted` is the earlier name of `neutral`. */\nexport type ResourceListIndicatorDeprecatedTone = \"muted\";\nexport type ResourceListIndicatorShape = \"bar\" | \"diamond\" | \"dot\" | \"ring\";\n\nexport interface ResourceListProps extends useRender.ComponentProps<\"div\"> {\n  density?: ResourceListDensity;\n  /**\n   * Sets the row content flush with the edges of the list, so that it lines up\n   * with the content around the list, such as a Filter Bar or a Card heading.\n   * The rows reach past the edges by their own padding, which keeps the hover,\n   * current and selected fills inset around the content. The container needs\n   * that much room, as a Card panel has.\n   */\n  flush?: boolean;\n  /**\n   * `soft` groups the rows on a subtle surface. `divided` draws the kit's\n   * hairline between two rows, across the row content, and keeps no space\n   * above the first row's content or below the last row's: the list reaches\n   * past its top and bottom by the row padding, as `flush` makes it reach past\n   * its sides. With clickable (`interactive`) rows the line is as wide as their\n   * hover surface. A `ResourceListHeader` gets the same line below it.\n   */\n  variant?: ResourceListVariant;\n}\n\nexport interface ResourceListItemProps extends useRender.ComponentProps<\"div\"> {\n  active?: boolean;\n  disabled?: boolean;\n  interactive?: boolean;\n  selected?: boolean;\n}\n\nexport interface ResourceListIndicatorProps extends useRender.ComponentProps<\"span\"> {\n  icon?: React.ReactNode;\n  label?: string;\n  shape?: ResourceListIndicatorShape;\n  /**\n   * The mark's color: a status value, `neutral` (the default) or a chart\n   * color. `muted` is the deprecated earlier name of `neutral`.\n   */\n  tone?: ResourceListIndicatorTone | ResourceListIndicatorDeprecatedTone;\n}\n\nconst resourceListIndicatorShapeClassNames = {\n  bar: \"h-4 w-1 rounded-full\",\n  diamond: \"size-2 rotate-45 rounded-[2px]\",\n  dot: \"size-1.5 rounded-full\",\n  ring: \"size-2.5 rounded-full border-2 border-current bg-transparent\",\n} as const satisfies Record<ResourceListIndicatorShape, string>;\n\n/** Rows read this to know they are in a list, which animates them (rulebook M6, M7). */\nconst ResourceListContext = React.createContext(false);\n\n// The line between two rows of a divided list, and below its header: the\n// kit's hairline (rulebook B7), across the row content, in the middle of the\n// space between the rows. With clickable rows it is as wide as their hover\n// surface (design-rework.css).\nconst resourceListDivider = (\n  <span\n    aria-hidden=\"true\"\n    className={slotClass(\"resource-list-divider\")}\n    data-slot=\"resource-list-divider\"\n  />\n);\n\n/**\n * Rows that are added, removed or reordered expand, collapse and glide on their\n * own; give each row a stable `key`. When a row that holds focus leaves, focus\n * moves to the next row, or to the previous one at the end.\n */\nexport function ResourceList({\n  children,\n  className,\n  density,\n  flush = false,\n  render,\n  variant = \"default\",\n  ...props\n}: ResourceListProps): React.ReactElement {\n  const resolvedDensity = useResolvedDensity(density);\n\n  return (\n    <ResourceListContext.Provider value>\n      <MotionList\n        className={cn(\n          // The offset is what lies between the list's edge and the row content:\n          // the row's padding (px-3 on ResourceListItem), and on the soft surface\n          // its own padding (p-1) as well.\n          \"grid min-w-0 [--resource-list-flush-offset:--spacing(3)] data-[variant=soft]:rounded-xl data-[variant=soft]:bg-surface-subtle data-[variant=soft]:p-1 data-[variant=soft]:[--resource-list-flush-offset:--spacing(4)]\",\n          // A flush list reaches past its container by the offset on both sides.\n          // The width names the reach as well, so that a width from the consumer\n          // widens the list instead of shifting it.\n          \"data-[flush=true]:-mx-(--resource-list-flush-offset) data-[flush=true]:w-[calc(100%+var(--resource-list-flush-offset)*2)]\",\n          className,\n        )}\n        // The kit's compact rules key on the list's own density, never on an\n        // ancestor's, such as a compact Card around a default list\n        // (design-rework.css).\n        data-density={resolvedDensity}\n        data-flush={flush || undefined}\n        data-slot=\"resource-list\"\n        data-variant={variant}\n        // The space between rows belongs to the rows, so it collapses with them.\n        gap={resolvedDensity === \"compact\" ? 0.5 : 1}\n        // A divided list draws its line in that space, so the line comes and\n        // goes with the row after it (rulebook M6, M7).\n        separator={variant === \"divided\" ? resourceListDivider : undefined}\n        render={render ?? <div />}\n        // The list is a div here; MotionList types its default element, a ul.\n        {...(props as unknown as Omit<MotionListProps, \"children\" | \"render\">)}\n      >\n        {children}\n      </MotionList>\n    </ResourceListContext.Provider>\n  );\n}\n\n/**\n * The heading of a list, before its rows. Its text starts where the rows' text\n * does, at their size and in the muted color of their metadata; a second\n * child, such as an action, sits at the end. In a list it is an entry like a\n * row: a divided list draws its line below the header, as wide as the lines\n * between the rows, and the first row keeps the space below it that every row\n * keeps below a line.\n */\nexport function ResourceListHeader({\n  className,\n  render,\n  ...props\n}: useRender.ComponentProps<\"div\">): React.ReactElement {\n  const defaultProps = {\n    \"data-slot\": \"resource-list-header\",\n    className: cn(\n      slotClass(\"resource-list-header\", props, render),\n      \"flex min-w-0 items-center justify-between gap-3 px-3 text-muted-foreground text-sm\",\n      className,\n    ),\n  };\n  const header = useRender({\n    defaultTagName: \"div\",\n    props: mergeProps<\"div\">(defaultProps, props),\n    render,\n  });\n  const inList = React.useContext(ResourceListContext);\n\n  // The entry holds the space and the line after the header, as a row's does.\n  return inList ? (\n    <MotionItem className=\"min-w-0\" render={<div />}>\n      {header}\n    </MotionItem>\n  ) : (\n    header\n  );\n}\n\n// The row's rendered element decides which ARIA states it supports.\nfunction getRenderedRowElement(\n  render: ResourceListItemProps[\"render\"],\n): \"button\" | \"link\" | \"other\" | undefined {\n  if (render === undefined) return undefined;\n  if (!React.isValidElement<{ href?: unknown }>(render)) return \"other\";\n  if (render.type === \"a\" || render.props.href !== undefined) return \"link\";\n  return render.type === \"button\" ? \"button\" : \"other\";\n}\n\nexport function ResourceListItem({\n  active = false,\n  className,\n  disabled = false,\n  interactive = false,\n  render,\n  selected,\n  ...props\n}: ResourceListItemProps): React.ReactElement {\n  const renderedElement = getRenderedRowElement(render);\n  // A linked row is the current page; any other row is the current item.\n  const ariaCurrent: \"page\" | \"true\" | undefined = active\n    ? renderedElement === \"link\"\n      ? \"page\"\n      : \"true\"\n    : undefined;\n  // Only a native button row supports a pressed selection state; links,\n  // generic rows and unknown custom elements keep selection visual only.\n  const rendersNativeButton =\n    interactive &&\n    (renderedElement === undefined || renderedElement === \"button\");\n  const defaultProps = {\n    \"aria-current\": ariaCurrent,\n    \"aria-disabled\": disabled || undefined,\n    \"aria-pressed\":\n      rendersNativeButton && selected !== undefined ? selected : undefined,\n    \"data-active\": active || undefined,\n    \"data-disabled\": disabled || undefined,\n    \"data-interactive\": interactive || undefined,\n    \"data-pressed\": interactive && (active || selected) ? \"\" : undefined,\n    \"data-resource-list-item\": \"\",\n    \"data-selected\": selected || undefined,\n    \"data-slot\": \"resource-list-item\",\n    // A row without an indicator or meta takes no room for it (rulebook S11):\n    // the content fills what the other parts leave. The space above and below\n    // the content is a kit rule, which a compact list refines. An interactive\n    // row is a Button: its chassis centres the parts, and the kit's\n    // resource-list rule makes it a row and sets its spacing and text.\n    className: cn(\n      slotClass(\"resource-list-item\", props, render),\n      \"min-w-0\",\n      !interactive &&\n        \"flex items-center gap-3 px-3 text-left text-sm rounded-lg data-[active=true]:bg-state-selected data-[selected=true]:bg-state-selected data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-(--sp-rw-disabled-opacity-action)\",\n      className,\n    ),\n  };\n\n  const row = useRender({\n    defaultTagName: \"div\",\n    props: mergeProps<\"div\">(defaultProps, props),\n    render: interactive ? (\n      <Button\n        align=\"start\"\n        disabled={disabled}\n        multiline\n        render={render}\n        variant=\"ghost\"\n        width=\"full\"\n      />\n    ) : (\n      render\n    ),\n  });\n  const inList = React.useContext(ResourceListContext);\n\n  // The entry is the box that expands and collapses; the row keeps its own\n  // padding, states and focus ring.\n  return inList ? (\n    <MotionItem\n      className=\"min-w-0\"\n      data-resource-list-entry=\"\"\n      render={<ResourceListEntry />}\n    >\n      {row}\n    </MotionItem>\n  ) : (\n    row\n  );\n}\n\nfunction ResourceListEntry(props: React.ComponentProps<\"div\">) {\n  const entry = React.useRef<HTMLDivElement>(null);\n  const focus = useFocusHandoff(entry, Boolean(props.inert));\n\n  return useRender({\n    defaultTagName: \"div\",\n    props: mergeProps<\"div\">(focus, props),\n    ref: entry,\n  });\n}\n\nexport function ResourceListIndicator({\n  children,\n  className,\n  icon,\n  label,\n  render,\n  shape = \"dot\",\n  tone: toneProp = \"neutral\",\n  ...props\n}: ResourceListIndicatorProps): React.ReactElement {\n  const tone = toneProp === \"muted\" ? \"neutral\" : toneProp;\n  const defaultProps = {\n    \"aria-hidden\": label ? undefined : true,\n    \"aria-label\": label,\n    children: icon ?? children,\n    \"data-slot\": \"resource-list-indicator\",\n    \"data-shape\": shape,\n    \"data-tone\": tone,\n    // In a row with a description the mark centres on the first line, and\n    // text in the meta sits on it (rulebook I5): two rules in the ResourceList\n    // block of design-rework.css.\n    className: cn(\n      slotClass(\"resource-list-indicator\", props, render),\n      \"inline-flex shrink-0 items-center justify-center\",\n      icon\n        ? \"size-5 rounded-full text-primary-foreground [&>svg]:size-3\"\n        : resourceListIndicatorShapeClassNames[shape],\n      tone === \"success\" && \"bg-success\",\n      tone === \"neutral\" && \"bg-muted-foreground/30\",\n      tone === \"warning\" && \"bg-warning\",\n      tone === \"error\" && \"bg-destructive\",\n      tone === \"info\" && \"bg-info\",\n      tone === \"chart-1\" && \"bg-chart-1\",\n      tone === \"chart-2\" && \"bg-chart-2\",\n      tone === \"chart-3\" && \"bg-chart-3\",\n      tone === \"chart-4\" && \"bg-chart-4\",\n      tone === \"chart-5\" && \"bg-chart-5\",\n      className,\n    ),\n    role: label ? \"img\" : undefined,\n  };\n\n  return useRender({\n    defaultTagName: \"span\",\n    props: mergeProps<\"span\">(defaultProps, props),\n    render,\n  });\n}\n\nexport function ResourceListContent({\n  className,\n  render,\n  ...props\n}: useRender.ComponentProps<\"div\">): React.ReactElement {\n  const defaultProps = {\n    \"data-slot\": \"resource-list-content\",\n    className: cn(\n      slotClass(\"resource-list-content\", props, render),\n      \"grid min-w-0 flex-1 gap-0.5\",\n      className,\n    ),\n  };\n\n  return useRender({\n    defaultTagName: \"div\",\n    props: mergeProps<\"div\">(defaultProps, props),\n    render,\n  });\n}\n\n// The text of children that are nothing but text: strings and numbers, and\n// the values React renders as nothing. It reads the same on the server and in\n// the browser. With an element among the children it is undefined.\nfunction plainText(node: React.ReactNode): string | undefined {\n  if (node === null || node === undefined || typeof node === \"boolean\")\n    return \"\";\n  if (\n    typeof node === \"string\" ||\n    typeof node === \"number\" ||\n    typeof node === \"bigint\"\n  )\n    return `${node}`;\n  if (!Array.isArray(node)) return undefined;\n  let text = \"\";\n  for (const child of node as React.ReactNode[]) {\n    const part = plainText(child);\n    if (part === undefined) return undefined;\n    text += part;\n  }\n  return text;\n}\n\n/**\n * A title and a description stay on one line and end with an ellipsis, so a\n * dense list keeps its rhythm (rulebook K5, list rows); their title attribute\n * holds the full text. Text children give it while rendering, the same on the\n * server and in the browser. With an element among the children it is read\n * from the rendered text after mount, and again after each render: a Server\n * Component hands element children over as references, which the server and\n * the browser may not resolve alike, so reading them while rendering broke\n * hydration. A title prop of the consumer's own replaces it, and then nothing\n * is read.\n */\nfunction useFullTextTitle(props: {\n  children?: React.ReactNode;\n  title?: string | undefined;\n}): {\n  ref: React.RefObject<HTMLSpanElement | null>;\n  title: string | undefined;\n} {\n  const ref = React.useRef<HTMLSpanElement | null>(null);\n  const text = plainText(props.children);\n  const read = text === undefined && !(\"title\" in props);\n  const [rendered, setRendered] = React.useState<string>();\n  React.useLayoutEffect(() => {\n    if (read) setRendered(ref.current?.textContent?.trim() || undefined);\n  });\n  return {\n    ref,\n    title: text === undefined ? rendered : text.trim() || undefined,\n  };\n}\n\nexport function ResourceListTitle({\n  className,\n  render,\n  ...props\n}: useRender.ComponentProps<\"span\">): React.ReactElement {\n  const fullText = useFullTextTitle(props);\n  const defaultProps = {\n    \"data-slot\": \"resource-list-title\",\n    className: cn(\n      slotClass(\"resource-list-title\", props, render),\n      \"truncate font-(--sp-rw-font-weight-label) text-foreground\",\n      className,\n    ),\n    title: fullText.title,\n  };\n\n  return useRender({\n    defaultTagName: \"span\",\n    props: mergeProps<\"span\">(defaultProps, props),\n    ref: fullText.ref,\n    render,\n  });\n}\n\nexport function ResourceListDescription({\n  className,\n  render,\n  ...props\n}: useRender.ComponentProps<\"span\">): React.ReactElement {\n  const fullText = useFullTextTitle(props);\n  const defaultProps = {\n    \"data-slot\": \"resource-list-description\",\n    className: cn(\n      slotClass(\"resource-list-description\", props, render),\n      \"truncate text-muted-foreground text-xs\",\n      className,\n    ),\n    title: fullText.title,\n  };\n\n  return useRender({\n    defaultTagName: \"span\",\n    props: mergeProps<\"span\">(defaultProps, props),\n    ref: fullText.ref,\n    render,\n  });\n}\n\nexport function ResourceListMeta({\n  children,\n  className,\n  render,\n  selected,\n  ...props\n}: useRender.ComponentProps<\"span\"> & {\n  selected?: boolean;\n}): React.ReactElement {\n  const defaultProps = {\n    \"data-slot\": \"resource-list-meta\",\n    className: cn(\n      slotClass(\"resource-list-meta\", props, render),\n      \"inline-flex shrink-0 items-center whitespace-nowrap\",\n      className,\n    ),\n    children: (\n      <>\n        {children}\n        {selected !== undefined && (\n          // The slot is the indicator of the shared check motion: it holds\n          // data-selected only while the row is selected, so the check draws in\n          // and back out as the attribute comes and goes.\n          <span\n            aria-hidden=\"true\"\n            data-selected={selected || undefined}\n            className=\"sp-resource-list-selection\"\n            data-slot=\"resource-list-selection\"\n          >\n            <SelectionCheckIcon />\n          </span>\n        )}\n      </>\n    ),\n  };\n\n  return useRender({\n    defaultTagName: \"span\",\n    props: mergeProps<\"span\">(defaultProps, props),\n    render,\n  });\n}\n","type":"registry:ui","target":"~/src/siteplane/components/resource-list/resource-list.tsx"},{"path":"packages/ui/src/components/resource-list/resource-list-focus.ts","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Where focus goes when a row of a ResourceList leaves. Kept apart from the\n * component so the choice of the next row can be checked without a DOM.\n */\n\nconst focusableSelector =\n  'a[href], button:not([disabled]), input:not([disabled], [type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"]), [contenteditable=\"\"], [contenteditable=\"true\"]';\n\n/** Whether focus is on nothing. */\nfunction focusIsNowhere(): boolean {\n  const active = document.activeElement;\n  return (\n    !active || active === document.body || active === document.documentElement\n  );\n}\n\n/** Whether focus is on nothing, or on something that can no longer hold it. */\nfunction focusIsLost(): boolean {\n  const active = document.activeElement;\n  return (\n    !active ||\n    focusIsNowhere() ||\n    !active.isConnected ||\n    active.closest(\"[inert]\") !== null\n  );\n}\n\n/** Whether the row holds focus: in the row itself or, by React's tree, in a popup it opened. */\nfunction holdsFocus(entry: HTMLElement, within: boolean): boolean {\n  return (\n    !focusIsNowhere() && (within || entry.contains(document.activeElement))\n  );\n}\n\n/** The nearest row that stays, after the leaving one or else before it, and the first thing in it that takes focus. */\nexport function focusTargetNear(entry: HTMLElement): HTMLElement | undefined {\n  for (const side of [\n    \"nextElementSibling\",\n    \"previousElementSibling\",\n  ] as const) {\n    for (let row = entry[side]; row; row = row[side]) {\n      if (!row.hasAttribute(\"data-resource-list-entry\")) continue;\n      const target = [\n        ...row.querySelectorAll<HTMLElement>(focusableSelector),\n      ].find(\n        (element) =>\n          element.getClientRects().length > 0 && !element.closest(\"[inert]\"),\n      );\n      if (target) return target;\n    }\n  }\n  return undefined;\n}\n\n/**\n * A leaving row is inert, so focus inside it would be lost. Focus moves on to\n * the next row when the row starts to leave, whether it sits in the row or in a\n * popup the row opened (React's focus events reach the row from the popup,\n * which is elsewhere in the DOM). Focus that was somewhere when the row started\n * to leave and is nowhere when it is gone, because a popup or a pane closed\n * with the row, moves on then.\n */\nexport function useFocusHandoff(\n  ref: React.RefObject<HTMLElement | null>,\n  leaving: boolean,\n) {\n  const within = React.useRef(false);\n  const held = React.useRef(false);\n\n  React.useLayoutEffect(() => {\n    const entry = ref.current;\n    held.current = leaving && !focusIsNowhere();\n    if (leaving && entry && holdsFocus(entry, within.current)) {\n      focusTargetNear(entry)?.focus();\n    }\n  }, [leaving, ref]);\n\n  React.useLayoutEffect(() => {\n    const entry = ref.current;\n    return () => {\n      if (\n        entry &&\n        (holdsFocus(entry, within.current) || (held.current && focusIsLost()))\n      ) {\n        focusTargetNear(entry)?.focus();\n      }\n    };\n  }, [ref]);\n\n  return {\n    onBlur: () => {\n      within.current = false;\n    },\n    onFocus: () => {\n      within.current = true;\n    },\n  };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/resource-list/resource-list-focus.ts"},{"path":"packages/ui/src/components/resource-list/index.ts","content":"export * from \"./resource-list\";\n","type":"registry:ui","target":"~/src/siteplane/components/resource-list/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/resource-list.md","categories":["Data display"],"meta":{"avoidWhen":["Avoid when the list needs managed keyboard selection; use List Box.","Avoid for data with sortable columns; use Table."],"description":"Reduced entity list with explicit indicator, content and meta slots.","knownLimitations":["Rows need a stable key. An index as the key makes a removed row leave as its neighbor.","Rows animate only as direct children of the list; a row inside a wrapper component appears and leaves at once.","When a row that holds focus leaves, focus moves to the next row, or the previous one at the end. With no other row, it is lost.","A title or description stays on one line and ends with an ellipsis; its title attribute holds the full text, which a title prop replaces. Text children give it in the server markup; with an element among the children it is set from the rendered text once the part has mounted."],"providerNotes":["Rows animate through the motion component, which the registry installs with it.","SiteplaneMotionProvider preset=\"reduced\" and the system setting make every change instant."],"releaseStatus":"stable","searchTerms":["resource-list","resource list","resource","list"],"useWhen":["Use to list resources such as files, projects or members with an indicator, content and metadata, and optional interactive rows."]}}