{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bar-list","title":"Bar List","description":"Rank labeled values with a bar for each, relative to the largest. Search terms: ranking, top list, leaderboard, horizontal bars, chart, analytics.","registryDependencies":["@siteplane/base","@siteplane/chart","@siteplane/motion","@siteplane/separator"],"files":[{"path":"packages/ui/src/components/bar-list/bar-list.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport {\n  CHART_DEFAULT_LOCALE,\n  chartNumberFormatter,\n} from \"../chart/chart-format\";\nimport { chartBrandShades } from \"../chart/chart-palette\";\nimport {\n  MotionItem,\n  MotionList,\n  MotionNumber,\n  useTweenedRows,\n} from \"../motion\";\nimport { Separator } from \"../separator\";\n\nexport type BarListItem = {\n  /** Names the row for as long as it is in the data, so that its bar glides when the data changes. Unique in the list. */\n  id: string;\n  label: string;\n  /**\n   * A second, quieter line under the label, such as the path under a page\n   * title. It stays on one line, ends in an ellipsis when it is too long and\n   * keeps its full text in its title.\n   */\n  description?: string;\n  value: number;\n  /** Shown before the label. Pass an svg element to take the icon size of the kit; it is decoration. */\n  icon?: React.ReactNode;\n};\n\n/** The names of the two columns of a Bar List, shown in a heading row above the rows. */\nexport type BarListHeading = {\n  /** What the rows are, such as \"Top pages\", above the labels. */\n  label: string;\n  /** What the values count, such as \"Visitors\", above the values. */\n  value: string;\n};\n\nexport type BarListProps = {\n  /** The rows, in the order they are shown. */\n  data: readonly BarListItem[];\n  /** The accessible name of the list. */\n  label: string;\n  /**\n   * A heading row with the names of the columns above the rows, over a\n   * separator: what the labels are and what the values count.\n   */\n  heading?: BarListHeading;\n  /** Writes a value at the end of its row. */\n  formatValue?: (value: number) => string;\n  /** The locale of the numbers. */\n  locale?: string;\n  /** A CSS colour or token for the bars. The lightest brand shade by default. */\n  color?: string;\n  /** Shown in place of the rows when there is no data. */\n  emptyLabel?: string;\n  /** Classes for the list, or with a `heading` for the frame that holds the heading and the list. */\n  className?: string;\n};\n\nconst lightestShade = chartBrandShades[chartBrandShades.length - 1]!;\n// Every value above zero keeps at least this share of the track, so that a small\n// value is still a bar and never a hairline.\nconst smallestShare = 2;\nconst hundredths = (value: number) => Math.round(value * 100) / 100;\n\n// A figure that changes in place rolls to its new value; text without digits has\n// nothing to roll. It sits on the first line of its label (rulebook I5): the\n// row's padding above it, a line of the label's height.\nfunction Value({ text }: { text: string }) {\n  const className =\n    \"justify-self-end py-1.5 text-sm font-(--sp-rw-font-weight-label)\";\n  return /[0-9]/.test(text) ? (\n    <MotionNumber className={className} value={text} />\n  ) : (\n    <span className={cn(className, \"tabular-nums\")}>{text}</span>\n  );\n}\n\n/**\n * Ranked rows with a label, an optional second line, an optional icon, a bar\n * and a value: each bar is as long as its value is large next to the largest,\n * and at least 2 % of the track for any value above zero. The rows keep the\n * order of the data, and glide when it changes: a row that is new grows its bar\n * out of nothing, one that is gone shrinks away, and the others move to their\n * new place. A heading row can name the two columns above the rows.\n */\nexport function BarList({\n  data,\n  label,\n  heading,\n  formatValue,\n  locale = CHART_DEFAULT_LOCALE,\n  color = lightestShade,\n  emptyLabel = \"No data\",\n  className,\n}: BarListProps): React.ReactElement {\n  const list = React.useRef<HTMLUListElement>(null);\n  const format = formatValue ?? chartNumberFormatter(locale);\n  // A value that is not a number counts as 0, as it does in the Pie Chart.\n  const rows = data.map((item) => ({\n    ...item,\n    value: Number.isFinite(item.value) ? item.value : 0,\n  }));\n  const largest = Math.max(0, ...rows.map((row) => row.value));\n  // The bars as they are drawn now, in percent of the track: the largest value\n  // fills it, any other value above zero takes its share of that but at least\n  // 2 %, and a value that is zero or negative has no bar. When the data changes\n  // each bar glides to its new width; at rest this is the data itself.\n  const bars = useTweenedRows(\n    rows,\n    (row) =>\n      row.value > 0 ? Math.max((row.value / largest) * 100, smallestShare) : 0,\n    { container: list },\n  );\n  const rowsList = (\n    <MotionList\n      aria-label={label}\n      // One grid for all the rows: the values share one column, so that every\n      // bar has the same track whatever its value is written like.\n      className={cn(\n        \"grid grid-cols-[minmax(0,1fr)_auto] gap-x-6\",\n        !heading && className,\n      )}\n      data-slot=\"bar-list\"\n      gap={2}\n      ref={list}\n    >\n      {bars.map((bar) => (\n        <MotionItem\n          className=\"col-span-full grid grid-cols-subgrid items-start\"\n          key={bar.key}\n          // A row that the data no longer has is on its way out.\n          {...(bar.leaving ? { \"aria-hidden\": true } : {})}\n        >\n          <div className=\"relative min-w-0\">\n            {/* The bar is an SVG, so that its width and its colour are\n                attributes of the data and not an inline style. It starts where\n                the text starts, and its corners are the kit's. */}\n            <svg\n              aria-hidden=\"true\"\n              className=\"sp-bar-list-bar absolute inset-y-0 start-0 h-full overflow-hidden rounded-md\"\n              data-slot=\"bar-list-bar\"\n              width={`${hundredths(bar.value)}%`}\n            >\n              <rect fill={color} height=\"100%\" width=\"100%\" />\n            </svg>\n            {/* A row is 32 px for a label of one line. The label and its\n                second line each stay on one line and end in an ellipsis, with\n                the full text in their title (rulebook K5, dense rows); a second\n                line makes the row and its bar taller. The icon sits on the\n                first line (I5). The second line is quieter than the label by\n                its size and the secondary text color, which keeps 4.5:1 on the\n                lightest bar in both themes, where the tertiary did not. */}\n            <div className=\"relative flex min-h-8 items-start gap-2 px-2 py-1.5 text-sm\">\n              {bar.row.icon && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"inline-flex h-5 shrink-0 items-center [&>svg]:size-4\"\n                >\n                  {bar.row.icon}\n                </span>\n              )}\n              <span className=\"grid min-w-0\">\n                <span className=\"truncate\" title={bar.row.label}>\n                  {bar.row.label}\n                </span>\n                {bar.row.description ? (\n                  <span\n                    className={cn(\n                      slotClass(\"bar-list-description\"),\n                      \"truncate text-(length:--sp-rw-font-13) leading-(--sp-rw-line-18) text-(--sp-rw-fg-secondary)\",\n                    )}\n                    data-slot=\"bar-list-description\"\n                    title={bar.row.description}\n                  >\n                    {bar.row.description}\n                  </span>\n                ) : null}\n              </span>\n            </div>\n          </div>\n          <Value text={format(bar.row.value)} />\n        </MotionItem>\n      ))}\n      {data.length === 0 && (\n        <MotionItem className=\"col-span-full\" key=\"bar-list-empty\">\n          <p className=\"flex h-8 items-center px-2 text-sm text-muted-foreground\">\n            {emptyLabel}\n          </p>\n        </MotionItem>\n      )}\n    </MotionList>\n  );\n  if (!heading) return rowsList;\n  // The heading names the columns above the rows: its label stands over the\n  // labels, at the start of the bars, and its value name over the values, at\n  // the end of the row. A separator runs between it and the rows.\n  return (\n    <div\n      className={cn(\n        slotClass(\"bar-list-frame\"),\n        \"flex flex-col gap-2\",\n        className,\n      )}\n      data-slot=\"bar-list-frame\"\n    >\n      <div\n        className={cn(\n          slotClass(\"bar-list-heading\"),\n          \"flex items-start justify-between gap-6 text-sm font-(--sp-rw-font-weight-label) text-(--sp-rw-fg-tertiary)\",\n        )}\n        data-slot=\"bar-list-heading\"\n      >\n        <span className=\"min-w-0 truncate\" title={heading.label}>\n          {heading.label}\n        </span>\n        <span className=\"shrink-0\">{heading.value}</span>\n      </div>\n      <Separator role=\"presentation\" />\n      {rowsList}\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/bar-list/bar-list.tsx"},{"path":"packages/ui/src/components/bar-list/index.ts","content":"export * from \"./bar-list\";\n","type":"registry:ui","target":"~/src/siteplane/components/bar-list/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/bar-list.md","categories":["Charts"],"meta":{"avoidWhen":["Avoid when rows must be clicked, selected or linked: the rows are not interactive; use a Table, a List Box or a Resource List.","Avoid when values can be negative or must be read against an axis; use a Bar Chart."],"description":"Rank labeled values with a bar for each, relative to the largest.","knownLimitations":["The rows take no click, hover or focus, and the list is not a focus stop.","The rows keep the order of the data and are never sorted: sort the data to rank it.","Each bar is as long as its value is large next to the largest, which fills the track, but any value above zero keeps at least 2% of the track, so a very small value is a short bar and not a hairline. A value that is zero or negative has no bar, and a negative value is written as it is. A value that is not a number counts as 0.","The values share one column as wide as the widest of them, so every bar has the same track however the values are written.","A label stays on one line and ends in an ellipsis when it does not fit, with the full text in its title; so does the optional second line, a description in quieter 13 px text under the label, which makes its row and bar taller. The value stays on the first line.","The optional heading names the two columns above the rows, over a separator; with it, className goes to the frame that holds the heading and the list.","An icon is decoration and is hidden from assistive technology. Pass an svg element to take the icon size of the kit.","Row ids must be unique and stay the same while a row is in the data: a bar glides by its id. A new row grows its bar out of nothing, a row that is gone shrinks its bar away and then leaves, and a value with digits rolls to its new value, unless motion is reduced."],"providerNotes":["The glide and the rolling values use the installed Motion component and its tokens. SiteplaneMotionProvider preset=\"reduced\" and the system setting show new data at once.","The line under the heading is the installed Separator."],"releaseStatus":"stable","searchTerms":["bar-list","bar list","ranking","top list","leaderboard","horizontal bars","chart","analytics"],"useWhen":["Use when a short ranked list of labeled values, such as top pages or traffic sources, should read at a glance.","Use when the order of the rows is the point and each value should be seen next to the largest."]}}