{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sort-header",
  "title": "Sort Header",
  "description": "One sortable column heading for a table you already have: press it and the rows re-order, press it again for the other direction, press it a third time and the table goes back to the order it arrived in. Reach for it wherever a list of rows is long enough that people want it arranged their own way — an admin users or accounts table, an orders, invoices or billing-history list, search results, a dashboard's data table, a logs or audit-trail view, a file or document browser, a products or inventory grid, a leaderboard or ranking, a tickets/issues queue, transactions, and any report with a date, size, count or amount column. Common asks it answers: \"sortable table header react\", \"table column sorting react\", \"click column header to sort\", \"sort table by column react\", \"shadcn sortable table\", \"shadcn table sorting\", \"shadcn data table sort without tanstack\", \"tanstack table alternative for just sorting\", \"react-table is too much for one column\", \"sort indicator arrow table header\", \"three state sort asc desc none\", \"how to clear sorting in a table\", \"reset table sort to default order\", \"aria-sort react\", \"accessible sortable table\", \"screen reader table sorting\", \"aria-sort not announced\", \"two columns both say ascending\", \"table header button accessibility\", \"th onclick not keyboard accessible\", \"sort numbers in strings correctly\", \"Item 10 sorts before Item 2\", \"natural sort react\", \"localeCompare numeric true\", \"Intl.Collator sort table\", \"accented names sort to the bottom\", \"null values first when sorting descending\", \"empty cells at top when sorting\", \"sorting mutates state array react\", \"table not re-rendering after sort\", \"テーブル ソート 見出し react\", \"並び替え カラム\". Official shadcn/ui gives you nothing to start from here, and the measurement is not close: fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 211,725 bytes of source, aria-sort, ariaSort, onSort, sortable, sortBy, sortDirection, ascending, descending, localeCompare, Intl.Collator, toSorted, ArrowUpDown and ChevronsUpDown are every one of them a zero hit. Its table component is 2,859 bytes of six styling wrappers in which the word sort never appears once. So every sortable table gets hand-rolled, and three things go wrong each time. The first is that the sort has two states when it needs three. Flipping between ascending and descending quietly takes something away: once a column has been pressed, the order the table came in — almost always the meaningful one, newest first, or a relevance rank the server computed — cannot be got back. There is no third press, no button that says stop sorting, and reloading the page is the only way out, which is exactly what people do. Here the cycle is first direction, opposite, gone, and gone hands back null so you render the rows as they came. Which direction comes first is per column, because ascending is the wrong first guess for a date, a size, a count or a score, where the first press is meant to mean newest or biggest and answers with the oldest and smallest instead. The second is aria-sort, which is not an attribute saying a column can be sorted — it says how the table is ordered right now, so it belongs on exactly one header at a time and reads none on the others. Held as a flag on each header, the failure always has the same shape: pressing a second column sets the new one and forgets to unset the old, and a screen reader is told two different columns are each sorting the table, which looks perfectly fine on screen. This takes the whole table's sort state as one value and derives each header's share of it, so that state cannot be represented. It also puts the attribute on the <th> rather than on the button inside it: aria-sort is defined for columnheader, and on a role=button it is silently dropped — it validates, it looks done, and it announces nothing. The third is the hit area. Putting onClick on a <th> gives a table that sorts with a mouse and for nobody else: nothing to tab to, nothing answering Enter or Space, and a cell that gives no hint it does anything, because a <th> is not interactive and no amount of ARIA makes it so. The heading here is a real button stretched across the cell — the cell gives up its padding to it — so it gets the keyboard, the focus ring and the announcement for free, and the pressable area matches the thing that looks pressable. Sorting a table also announces nothing on its own: the rows are replaced, focus has not moved, and aria-sort changing on an element is not an event any reader speaks. So a press puts \"Table sorted by Name, ascending.\" through a polite live region — from the header that was actually pressed, not from the one that just lost the sort — and clears it again a moment later, since a live region inside a header cell would otherwise become part of that cell's own content and be read back on every future visit. The comparison is exported separately as sortRows, compareValues and isBlankValue, because the browser and the server that paginates the same table have to agree on what the order is. Descending is the negated comparison, never the ascending result reversed: reversing floats every empty cell to the top the moment the arrow flips, and scrambles the rows that tied on the way up, so the same three Pending rows appear in a different order each way and the table reads as if it is shuffling itself. Blank cells — null, undefined, empty string, NaN, an invalid Date, but not 0 and not false — stay at the bottom in both directions. Text goes through Intl.Collator with numeric ordering, so Item 2 comes before Item 10 and Ångström does not land below Zulu where nobody scrolls; dates compare as instants and booleans as false-then-true. It never sorts the array you pass it, which is the bug that renders nothing at all, and it is meant to be run over the rows the server sent rather than the ones already on screen — sorting what is displayed lets the previous sort survive inside every group of ties, so the table depends on the order of presses rather than on its state. A disabled heading keeps its tab stop and ignores presses instead of taking the disabled attribute, which would drop it out of the tab order and lose a keyboard user's place the moment the table starts loading. align=\"end\" right-aligns a numeric column and moves the arrow to the label's left so the heading stays flush with the figures beneath it, and the unsorted double-chevron is visible at rest rather than on hover, because hover does not exist on a phone and it is the only thing saying the column can be sorted at all. useSortHeader, nextSortState and ariaSortFor are exported for a header laid out as divs or one that already holds a filter menu. Within pulld it is the table counterpart to sortable-list, which drags rows into an order a person chooses rather than computing one, and it sits beside scroll-shadow and virtual-list, the other two pieces a long table wants. One file, one column, your <table> stays yours, and its only dependency is lucide-react for the arrows.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/sort-header.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown, ChevronUp, ChevronsUpDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Which way a column is sorted.\n *\n * These are deliberately the words `aria-sort` itself takes, rather than the `\"asc\"` / `\"desc\"` a\n * hand-rolled table usually carries around. The state you store and the attribute the browser\n * exposes are then the same value, so there is no mapping step to write backwards — and a mapping\n * step written backwards is silent: the table re-orders correctly, the arrow points the right way,\n * and only the screen reader is told the opposite of what happened.\n */\nexport type SortDirection = \"ascending\" | \"descending\"\n\n/** The one sorted column, or `null` for \"the table's own order\". */\nexport interface SortState {\n  column: string\n  direction: SortDirection\n}\n\n/** What `aria-sort` should say for a given column. `\"none\"` means sortable but not sorted. */\nexport type AriaSort = SortDirection | \"none\"\n\nexport interface SortCycleOptions {\n  /**\n   * The direction the first press produces. Defaults to `\"ascending\"`.\n   *\n   * Worth setting per column rather than leaving alone. Ascending is right for a name, an email, a\n   * status; it is the wrong guess for every column whose interesting end is the top — a date, a\n   * file size, a count, a score, an amount — where the first press is meant to answer \"newest\",\n   * \"biggest\", \"worst\", and ascending answers it with the oldest and smallest instead, so everyone\n   * presses twice, every time.\n   */\n  firstDirection?: SortDirection\n  /**\n   * Whether a third press clears the sort. Defaults to `true`.\n   *\n   * Turn it off for a table that must always be sorted by something — a leaderboard, a queue — so\n   * the cycle is just the two directions.\n   */\n  clearable?: boolean\n}\n\nconst OPPOSITE: Record<SortDirection, SortDirection> = {\n  ascending: \"descending\",\n  descending: \"ascending\",\n}\n\n/**\n * The state after pressing `column`'s header, given the state before it.\n *\n * **Three states, shown as two.** The reflex is to flip between ascending and descending, and it\n * quietly removes something: once a column has been touched, the order the table arrived in — which\n * is almost always the meaningful one, newest first, or a rank the server computed — cannot be got\n * back. There is no third press that returns it, no button that says \"stop sorting\", and reloading\n * the page is the only way out, which is why people do that. So the cycle here is first direction,\n * opposite, gone, and \"gone\" hands `null` back: sort by nothing and render the rows as they came.\n */\nexport function nextSortState(\n  current: SortState | null,\n  column: string,\n  { firstDirection = \"ascending\", clearable = true }: SortCycleOptions = {}\n): SortState | null {\n  if (!current || current.column !== column) return { column, direction: firstDirection }\n  if (current.direction === firstDirection) {\n    return { column, direction: OPPOSITE[firstDirection] }\n  }\n  return clearable ? null : { column, direction: firstDirection }\n}\n\n/**\n * What `aria-sort` should be for `column`, derived from the single sort state.\n *\n * Deriving it is the whole point, and the reason this component takes the table's sort state rather\n * than a per-header `sorted` flag. `aria-sort` does not mean \"this column can be sorted\" — it means\n * \"this is how the table is ordered right now\", so it may be `ascending` or `descending` on exactly\n * one header at a time and `none` on the rest. Held as a flag per header, the failure is always the\n * same shape: pressing a second column sets the new one and forgets to unset the old, and a screen\n * reader is then told two different columns are each sorting the table. It reads as a table that\n * cannot be trusted, and nothing on screen looks wrong.\n */\nexport function ariaSortFor(state: SortState | null, column: string): AriaSort {\n  return state?.column === column ? state.direction : \"none\"\n}\n\n/**\n * Whether a value is an empty cell rather than a value that happens to be small.\n *\n * `null`, `undefined`, `\"\"`, `NaN` and an invalid `Date` — the five ways a missing cell arrives from\n * an API — as against `0` and `false`, which are values and sort as values.\n */\nexport function isBlankValue(value: unknown): boolean {\n  if (value === null || value === undefined || value === \"\") return true\n  if (typeof value === \"number\") return Number.isNaN(value)\n  if (value instanceof Date) return Number.isNaN(value.getTime())\n  return false\n}\n\n// Built once: constructing a Collator costs far more than a comparison does, and a sort calls the\n// comparator O(n log n) times. `undefined` locale means the runtime's own, which is what a table of\n// user-facing text wants.\nconst collatorCache = new Map<string, Intl.Collator>()\n\n/**\n * The collator used for text, cached per locale.\n *\n * `numeric` so that `Item 2` comes before `Item 10` rather than after it — an id, an invoice number,\n * a version or a row label that arrives as a string is otherwise ordered by digit, which looks like\n * the sort is simply broken. `sensitivity: \"base\"` so that case and accents do not split a column\n * into runs: a plain `sort()` puts every capitalised word above every lowercase one, and drops\n * `Ångström` and `Öberg` below `Zulu` where nobody looks for them.\n */\nexport function sortCollator(locale?: string): Intl.Collator {\n  const key = locale ?? \"\"\n  let collator = collatorCache.get(key)\n  if (!collator) {\n    collator = new Intl.Collator(locale, { numeric: true, sensitivity: \"base\" })\n    collatorCache.set(key, collator)\n  }\n  return collator\n}\n\nfunction toComparable(value: unknown): number | string {\n  if (typeof value === \"number\") return value\n  if (typeof value === \"bigint\") return Number(value)\n  if (typeof value === \"boolean\") return value ? 1 : 0\n  if (value instanceof Date) return value.getTime()\n  return String(value)\n}\n\n/**\n * Compares two cell values in ascending order, with blanks after everything.\n *\n * Exported because the comparison and the header have to agree about what the order *is*, and the\n * server that paginates the same table has to agree with both.\n */\nexport function compareValues(a: unknown, b: unknown, collator = sortCollator()): number {\n  const aBlank = isBlankValue(a)\n  const bBlank = isBlankValue(b)\n  if (aBlank || bBlank) return aBlank && bBlank ? 0 : aBlank ? 1 : -1\n  const left = toComparable(a)\n  const right = toComparable(b)\n  if (typeof left === \"number\" && typeof right === \"number\") {\n    return left < right ? -1 : left > right ? 1 : 0\n  }\n  // A column holding both numbers and text is a data problem rather than a sorting one; numbers\n  // first is at least a stable answer to it.\n  if (typeof left === \"number\") return -1\n  if (typeof right === \"number\") return 1\n  return collator.compare(left, right)\n}\n\nexport interface SortRowsOptions {\n  collator?: Intl.Collator\n  locale?: string\n}\n\n/**\n * Returns `rows` in `state`'s order. Never mutates, and always hands back a new array.\n *\n * Two things here are the reason this is not three lines at the call site.\n *\n * **Descending is not ascending reversed.** Reversing is the obvious implementation and it moves\n * two things it should not. Blank cells, which belong at the bottom in both directions — an empty\n * \"Last seen\" is not the most recent one — jump to the top the moment the arrow flips, so half the\n * screen is empty rows. And rows that tie, having been left in their original order on the way up,\n * come back scrambled on the way down: a name column sorted descending shows the same three\n * \"Pending\" rows in a different order than ascending did, and to the person reading it the table\n * looks like it is shuffling rows at random. Negating the comparison instead, and pinning blanks\n * outside it, keeps both still.\n *\n * **Sort the source, not the screen.** `Array.prototype.sort` is stable, which is a good property\n * and also a trap: sorting the array you last displayed means the previous sort survives inside\n * every group of ties, so the result depends on the order of presses rather than on the state, and\n * pressing Name then Status then Name again gives a different table than pressing Name alone. Keep\n * the rows the server sent and run this over them each time.\n */\nexport function sortRows<T>(\n  rows: readonly T[],\n  state: SortState | null,\n  getValue: (row: T, column: string) => unknown,\n  { collator, locale }: SortRowsOptions = {}\n): T[] {\n  const out = rows.slice()\n  if (!state) return out\n  const compare = collator ?? sortCollator(locale)\n  const sign = state.direction === \"descending\" ? -1 : 1\n  return out.sort((a, b) => {\n    const left = getValue(a, state.column)\n    const right = getValue(b, state.column)\n    const leftBlank = isBlankValue(left)\n    const rightBlank = isBlankValue(right)\n    if (leftBlank || rightBlank) {\n      return leftBlank && rightBlank ? 0 : leftBlank ? 1 : -1\n    }\n    return sign * compareValues(left, right, compare)\n  })\n}\n\nexport interface SortHeaderLabels {\n  /** Spoken after a press. `direction` is `null` when the press cleared the sort. */\n  announce: (column: string, direction: SortDirection | null) => string\n}\n\nexport const defaultSortHeaderLabels: SortHeaderLabels = {\n  announce: (column, direction) =>\n    direction === null\n      ? `Sorting by ${column} cleared. Table is back to its default order.`\n      : `Table sorted by ${column}, ${direction}.`,\n}\n\nexport interface UseSortHeaderOptions extends SortCycleOptions {\n  /** This header's column key. Matched against `sort.column`. */\n  column: string\n  /** The table's current sort, or `null`. The same value goes to every header. */\n  sort: SortState | null\n  onSortChange: (next: SortState | null) => void\n  /** Column name for announcements. Defaults to `column`. */\n  label?: string\n  /** Accept presses but do nothing, e.g. while rows are loading. */\n  disabled?: boolean\n  /** Announce the new order after a press. Defaults to `true`. */\n  announce?: boolean\n  labels?: Partial<SortHeaderLabels>\n}\n\nexport interface UseSortHeaderResult {\n  /** `\"ascending\"`, `\"descending\"` or `\"none\"` — this column's share of the table's state. */\n  ariaSort: AriaSort\n  /** Whether this is the column the table is sorted by. */\n  active: boolean\n  /** This column's direction, or `null` when it is not the sorted one. */\n  direction: SortDirection | null\n  /** What the next press would produce, without making it happen. */\n  next: SortState | null\n  /** Non-empty for a moment after a press. Belongs in a polite live region. */\n  announcement: string\n  /** Spread onto the `<th>`. The cell, not the button, is what may carry `aria-sort`. */\n  headerProps: { scope: \"col\"; \"aria-sort\": AriaSort }\n  /** Spread onto the `<button>` inside it. */\n  buttonProps: {\n    type: \"button\"\n    onClick: (event: React.MouseEvent<HTMLButtonElement>) => void\n    \"aria-disabled\"?: true\n  }\n}\n\n/**\n * The header's behaviour without its markup — for a column laid out as a grid of `div`s, a header\n * that already has a filter menu in it, or a table component whose `<th>` is not yours to render.\n */\nexport function useSortHeader({\n  column,\n  sort,\n  onSortChange,\n  label,\n  firstDirection = \"ascending\",\n  clearable = true,\n  disabled = false,\n  announce = true,\n  labels: labelOverrides,\n}: UseSortHeaderOptions): UseSortHeaderResult {\n  const ariaSort = ariaSortFor(sort, column)\n  const active = ariaSort !== \"none\"\n  const direction = active ? (ariaSort as SortDirection) : null\n  const next = nextSortState(sort, column, { firstDirection, clearable })\n\n  const labels = React.useMemo(\n    () => ({ ...defaultSortHeaderLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  // Set by this header's own click, read by the effect below. Announcing from a state change alone\n  // would make every other header speak too — pressing Status takes Name from ascending to none,\n  // and Name has no business saying anything about a press that was not on it.\n  const pressed = React.useRef(false)\n  const lastAriaSort = React.useRef(ariaSort)\n\n  React.useEffect(() => {\n    if (lastAriaSort.current === ariaSort) return\n    lastAriaSort.current = ariaSort\n    if (!pressed.current) return\n    pressed.current = false\n    if (announce) setAnnouncement(labels.announce(label ?? column, direction))\n  }, [ariaSort, direction, announce, labels, label, column])\n\n  // Cleared again shortly after, because the live region sits inside the header cell: left there,\n  // the sentence becomes part of the cell's own content, and the next reader to land on the column\n  // hears \"Name, ascending, Table sorted by Name, ascending\".\n  React.useEffect(() => {\n    if (!announcement) return\n    const timer = setTimeout(() => setAnnouncement(\"\"), 1000)\n    return () => clearTimeout(timer)\n  }, [announcement])\n\n  const handleClick = React.useCallback(() => {\n    if (disabled) return\n    pressed.current = true\n    onSortChange(next)\n  }, [disabled, next, onSortChange])\n\n  return {\n    ariaSort,\n    active,\n    direction,\n    next,\n    announcement,\n    headerProps: { scope: \"col\", \"aria-sort\": ariaSort },\n    buttonProps: {\n      type: \"button\",\n      onClick: handleClick,\n      ...(disabled ? { \"aria-disabled\": true as const } : {}),\n    },\n  }\n}\n\nexport interface SortHeaderProps\n  extends Omit<\n      React.ComponentPropsWithoutRef<\"th\">,\n      // `scope` and `aria-sort` are this component's to set. `align` is the deprecated presentational\n      // HTML attribute, taken over here for the prop below — it has no business in a Tailwind table,\n      // and its values are the physical \"left\"/\"right\" rather than the two ends of the column.\n      \"onChange\" | \"scope\" | \"aria-sort\" | \"align\"\n    >,\n    SortCycleOptions {\n  column: string\n  sort: SortState | null\n  onSortChange: (next: SortState | null) => void\n  /** The visible heading. */\n  children: React.ReactNode\n  /** Column name used in announcements, when `children` is not plain text. */\n  label?: string\n  /** Right-align, for a column of numbers. Puts the indicator on the label's left. */\n  align?: \"start\" | \"end\"\n  /** Take presses but ignore them — while rows are loading, say. Keeps its place in the tab order. */\n  disabled?: boolean\n  announce?: boolean\n  labels?: Partial<SortHeaderLabels>\n  /** Classes for the button, which is what carries the cell's padding. */\n  buttonClassName?: string\n}\n\n/** The plain-text of `children`, for announcements, or `null` if it is not plain text. */\nfunction textOf(children: React.ReactNode): string | null {\n  if (typeof children === \"string\") return children\n  if (typeof children === \"number\") return String(children)\n  if (Array.isArray(children)) {\n    const parts = children.map(textOf)\n    return parts.every((part) => part !== null) ? parts.join(\"\") : null\n  }\n  return null\n}\n\n/**\n * One sortable column heading: a `<th>` with a button across it, an arrow, and the `aria-sort` the\n * table's state says it should have.\n *\n * ```tsx\n * const [sort, setSort] = React.useState<SortState | null>({ column: \"created\", direction: \"descending\" })\n * const rows = React.useMemo(\n *   () => sortRows(people, sort, (person, column) => person[column as keyof Person]),\n *   [people, sort]\n * )\n *\n * <table>\n *   <thead>\n *     <tr>\n *       <SortHeader column=\"name\" sort={sort} onSortChange={setSort}>Name</SortHeader>\n *       <SortHeader column=\"created\" sort={sort} onSortChange={setSort} firstDirection=\"descending\">\n *         Created\n *       </SortHeader>\n *       <SortHeader column=\"size\" sort={sort} onSortChange={setSort} align=\"end\">Size</SortHeader>\n *     </tr>\n *   </thead>\n *   <tbody>{rows.map(…)}</tbody>\n * </table>\n * ```\n *\n * It is one column, on purpose: the `<table>`, the rows and where the sorting happens all stay\n * yours, so this drops into a table you already have, and works the same whether the sort is done\n * in the browser with {@link sortRows} or handed to a server that returns the next page.\n *\n * **The heading is a button, not a clickable cell.** Putting `onClick` on the `<th>` is the version\n * that gets written, and it produces a table that can be sorted with a mouse and by nobody else:\n * there is nothing to tab to, nothing that answers Enter or Space, and a screen reader announcing\n * the cell gives no hint that pressing it would do anything, because a `<th>` is not interactive\n * and saying so is not something ARIA can bolt on afterwards. A real `<button>` filling the cell\n * gets the keyboard, the focus ring and the announcement for free — and it has to fill the cell,\n * or the hit area is the width of the word while the thing that looks pressable is the column.\n *\n * **`aria-sort` goes on the cell, and on one cell.** Not on the button: `aria-sort` is defined for\n * `columnheader`, and a `role=\"button\"` inside is a different element that does not carry it, so the\n * attribute is simply dropped — it validates, it reads as done, and it announces nothing. And\n * because the whole table's state comes in as one value, the other headers cannot be left claiming\n * a sort they no longer have.\n */\nexport function SortHeader({\n  column,\n  sort,\n  onSortChange,\n  children,\n  label,\n  align = \"start\",\n  firstDirection = \"ascending\",\n  clearable = true,\n  disabled = false,\n  announce = true,\n  labels,\n  className,\n  buttonClassName,\n  ...props\n}: SortHeaderProps) {\n  const header = useSortHeader({\n    column,\n    sort,\n    onSortChange,\n    label: label ?? textOf(children) ?? column,\n    firstDirection,\n    clearable,\n    disabled,\n    announce,\n    labels,\n  })\n  const { active, direction, announcement, headerProps, buttonProps } = header\n\n  const Indicator = direction === \"ascending\" ? ChevronUp : direction === \"descending\" ? ChevronDown : ChevronsUpDown\n\n  const indicator = (\n    <Indicator\n      // Decorative: the direction is already on the cell as `aria-sort`, and reading the arrow as\n      // well gives \"ascending\" twice in a row from one heading.\n      aria-hidden=\"true\"\n      className={cn(\n        \"h-4 w-4 shrink-0 transition-opacity\",\n        // Shown even when the column is not the sorted one, and shown at rest rather than on hover.\n        // The pair of arrows is the only thing saying the column can be sorted at all, and a hover\n        // state does not exist on a phone — where a heading that reveals nothing and does something\n        // when tapped is worse than a heading that looks plain.\n        active ? \"opacity-100\" : \"opacity-40 group-hover:opacity-70\"\n      )}\n    />\n  )\n\n  return (\n    <th\n      // No padding of its own: the button carries it, so the whole cell is the hit area.\n      className={cn(\"p-0 align-middle\", className)}\n      {...headerProps}\n      {...props}\n    >\n      <button\n        className={cn(\n          \"group flex w-full items-center gap-1.5 px-4 py-3 text-sm font-medium transition-colors\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n          active ? \"text-foreground\" : \"text-muted-foreground\",\n          // Added rather than overridden: two `hover:text-*` classes on one element are decided by\n          // their order in the stylesheet, not in this string, so a disabled heading written as an\n          // override would still light up under the pointer on whichever build put it second.\n          !active && !disabled && \"hover:text-foreground\",\n          align === \"end\" ? \"justify-end text-right\" : \"justify-start text-left\",\n          disabled && \"cursor-default opacity-60\",\n          buttonClassName\n        )}\n        {...buttonProps}\n      >\n        {align === \"end\" && indicator}\n        <span className=\"truncate\">{children}</span>\n        {align === \"start\" && indicator}\n      </button>\n      {/* Polite, and emptied again a moment later — see useSortHeader. A sorted table says nothing\n          on its own: the rows below have been replaced, the focus has not moved, and `aria-sort`\n          changing on an element is not an event any screen reader announces. */}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </th>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
