{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "column-visibility",
  "title": "Column Visibility",
  "description": "The \"Columns\" control for a table: a button that opens a short panel of checkboxes, one per column, so a person can take the columns they do not care about off the screen and put the ones they do in the order they want them. Reach for it wherever a table has more columns than fit, or more than any one person needs at once: an admin users, accounts or organisations list, an orders, invoices or billing-history table, a CRM or help-desk queue, a logs, events or audit-trail view, an analytics or reporting table, a data grid or spreadsheet-like view, an inventory or product catalogue, a tickets or issues list, a candidates or applications pipeline, a transactions ledger, an asset or device inventory, a saved or shared view somebody is about to send a colleague, and any table whose columns differ between a phone and a desk. Common asks it answers: \"column visibility react\", \"show hide table columns react\", \"column chooser react\", \"column picker component\", \"toggle table columns react\", \"shadcn column visibility\", \"shadcn table hide columns\", \"shadcn data table column toggle without tanstack\", \"tanstack table column visibility alternative\", \"ag-grid column chooser alternative\", \"mui datagrid column visibility alternative\", \"react column selector dropdown\", \"columns dropdown checkbox table\", \"reorder table columns react\", \"drag to reorder columns react\", \"column order state react\", \"persist column preferences localstorage\", \"save column visibility per user\", \"column settings in url query string\", \"shareable table view link\", \"column preferences out of sync after deploy\", \"new column not visible to existing users\", \"saved column settings missing new column\", \"stale column preference stored keys\", \"prevent hiding all columns\", \"at least one column must stay visible\", \"lock actions column from being hidden\", \"pin checkbox column table\", \"accessible column visibility menu\", \"dropdown menu checkbox accessibility react\", \"aria-disabled vs disabled checkbox\", \"announce column hidden screen reader\", \"テーブル 列 表示 切り替え\", \"列の表示 非表示 react\", \"カラム 並び替え 保存\", \"表示する列 選択 ui\", \"管理画面 列設定\". Five things decide the shape of this component, and four of them are about a preference that is always out of date. A stored preference was written by an older deploy against an older set of columns, or typed into a URL by hand, and every way it can disagree with the table has an obvious-looking answer that is wrong. A column the preference has never seen is shown, not hidden: reading the stored list as the allow-list is the one-line version and it is the expensive bug here, because everyone who has ever opened the menu has a stored preference, so a column shipped next week is invisible to exactly the established users and visible only to people who have never used the product — nobody reports a column they cannot see, and the team shipping it sees it on their own fresh profile and concludes it works. It is shown in its defined position rather than appended, since appending puts a new Status column after the row-actions column at the far right, past the horizontal scroll; each new key is anchored after the nearest column before it that the preference did know, so it lands among its neighbours and a run of new columns keeps its own order. A key that no longer exists is dropped rather than carried, because carried it leaves a gap the next reorder shuffles invisibly and a hidden-set entry that resurrects a column if the key is ever reused. And a locked column is pulled back to its defined position and un-hidden, because `locked` is a fact about this deploy's table and not about the stored value: a column that was ordinary when the preference was written and is load-bearing now would otherwise stay hidden for precisely the people who hid it. The floor is then applied over the result of all four, because each of them can take the visible count below the minimum on its own, and a table of zero columns renders as a strip of nothing with the control that would undo it inside one of the columns that went. The fifth is that the newness of a column is measured against the preference's `order` and nothing else. `order` is the only field that enumerates what a preference knew; `hidden` lists a subset and says nothing about the rest. Measure against both and a caller who stores only `{ hidden: [...] }` — a complete preference, and a common way to keep one — has every unlisted column reported as new on every single load; measure against nothing and a first-time visitor is greeted with \"5 new columns\", since every column is new to an empty preference. So `added` is empty unless a preference that spelled out which columns there were met a table that has more. Where the preference is kept is deliberately not this component's business: it takes a value and emits the next one, and `localStorage`, a query parameter and a column on the user row are each two lines at the call site. The one that is right depends on whether the choice should follow the person to another browser or follow the link to a colleague, and a component that picked for you would have picked `localStorage`, the one that can do neither. `serializeColumnPreference` and `parseColumnPreference` are there for the short form a URL or a storage key wants — `\"created,-email,name\"`, a leading `-` meaning hidden — which is a third the length of JSON once a URL has percent-encoded the braces, and which escapes the two characters the format itself uses so a column whose key holds a comma is not a column nobody can get back. Parsing never throws: this value arrives from a URL, which means it arrives from anyone, and the worst a hand-edited link can do is show the default table. Three decisions about the control itself. It is a disclosure, not a menu: `role=\"menu\"` is the reflex for a button that opens a list and it is wrong twice here, since a menu item is one actionable thing while every row holds a checkbox and two move buttons, and a menu owns the arrow keys those buttons need to be reachable with — so the panel is a plain group of real checkboxes that Tab walks, and nothing has to be re-implemented with `aria-activedescendant` to behave like what it already is. A column it will not let you hide stays a checkbox you can press, marked `aria-disabled` rather than `disabled`: `disabled` is tidier to look at and takes the row out of the tab order while leaving no way to find out why, which matters because the usual reason is the floor and the floor lifts the moment another column comes back — here the press is refused and the refusal is said out loud, with the second identical refusal still heard, since repeating a sentence into a live region is not a change and is not announced. And a move steps over the columns you cannot see: swapping with the literal neighbour in the array is shorter and produces a control that does nothing when a hidden column sits between two visible ones, so the table is identical and the button looks broken, while hidden columns keep their index through the swap they were skipped in — un-hide one later and it is back between the same two neighbours. Locked columns are walls rather than columns you move through, which is the whole point of pinning the row-actions column to the end. Official shadcn/ui has nothing of the kind, measured today rather than remembered. Fetching every entry in its registry — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of source, `columnVisibility`, `visibleColumns`, `hiddenColumns`, `toggleColumn`, `Toggle columns` and even the bare word `columns` are every one of them a zero hit. Three words are not zero and all three are worth unfolding rather than rounding: `View` returns 22, every one of them a `Viewport` in message-scroller, navigation-menu, scroll-area, select or toast; `hide` returns 10, every one of them `hideLabel`, `hideIndicator` or `hideIcon` inside the chart tooltip and legend; `visib` returns 81, every one of them a `focus-visible:` Tailwind variant apart from one `invisible` on a calendar's blank day. The check that settles it is the name list rather than the word count, because official can ship a component whose source says nothing about what it does: no entry among the 65 is a column control of any kind, and its `table` is 2,859 bytes of six presentational wrappers. Within pulld this is the last piece of the table family, and the other four are each a different axis: `sort-header` is the order of the rows, `column-resizer` is how wide a column is, `sticky-table-header` keeps the heading on screen, `table-row-expander` opens a row. None of them decides which columns exist, and none composes into this one, so it has no `registryDependencies`. It is also not `multi-select` with a table-shaped label — what is being chosen here has an order, a floor and members that cannot be deselected — not `transfer-list`, which moves items between two panes, and not `sortable-list`: if dragging is what you want for reordering, that is the component, and the reorder here is two buttons per row on purpose, because a drag inside a 240-pixel panel that has to auto-scroll itself is the part that breaks, while two buttons are unambiguous on a touch screen and need no live-region choreography beyond a position. The API: `columns` is `{ key, label, locked? }[]`; `preference` and `onPreferenceChange` make it controlled, and a `null`, `undefined` or partial preference is always valid; `minVisible` (default 1), `reorderable`, `align`, `disabled` and `labels` are the rest. The logic is exported as plain functions so a table that renders on the server or paginates through an API can have the resolved order without mounting a menu: `resolveColumnPreference`, `setColumnVisible`, `toggleColumnVisibility`, `showAllColumns`, `moveColumn`, `hideBlockedReason`, `isDefaultColumnPreference`, `defaultColumnPreference`, `serializeColumnPreference` and `parseColumnPreference`, plus `useColumnVisibility` for a panel of your own. Every label is replaceable one at a time for another language. One file, five lucide icons, your `cn`, and no table library.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/column-visibility.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, Check, Columns3, Lock, RotateCcw } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** One column the table could render. */\nexport interface ColumnDef {\n  /** Stable identifier. It is what gets persisted, so it outlives the label. */\n  key: string\n  /** What the menu shows, and what announcements name. */\n  label: string\n  /**\n   * Always shown, never moved.\n   *\n   * For the columns that are not really data: the leading checkbox, the row-actions column at the\n   * end, the name column a row is identified by. Hiding one of those leaves a table of values with\n   * no way to tell which row is which and no way to act on any of them, and — the part that makes\n   * it worth a flag rather than a convention — no way back, because the control that would bring it\n   * back is in the column that just disappeared.\n   */\n  locked?: boolean\n}\n\n/**\n * What the person chose: the order to render columns in, and which of them are hidden.\n *\n * Two plain arrays rather than a `Set` and a `Map`, because this value's whole purpose is to leave\n * the component — into `localStorage`, into a query string, into a `user_preferences` row — and\n * `JSON.stringify(new Set())` is `{}`. A preference that silently serialises to nothing is the\n * failure this shape exists to prevent.\n *\n * Both fields are tolerated empty or partial: `{ hidden: [\"email\"] }` is a complete, valid\n * preference meaning \"default order, email hidden\". {@link resolveColumnPreference} fills the rest\n * in from the column definitions, which is where every decision about a stale preference is made.\n */\nexport interface ColumnPreference {\n  order: string[]\n  hidden: string[]\n}\n\n/** A fresh preference meaning \"whatever the column definitions say\". Also what Reset emits. */\nexport function defaultColumnPreference(): ColumnPreference {\n  return { order: [], hidden: [] }\n}\n\nexport interface ResolveColumnOptions {\n  /**\n   * How many columns must stay visible. Defaults to `1`.\n   *\n   * Clamped to the number of columns there are, so a `minVisible` larger than the table cannot\n   * make the floor unreachable.\n   */\n  minVisible?: number\n}\n\nexport interface ResolvedColumns {\n  /** Every defined key, in the order the table should render them. */\n  order: string[]\n  /** The hidden keys. A subset of `order`, and never a locked key. */\n  hidden: string[]\n  /** The columns to render, in order — the one list a table actually needs. */\n  visible: ColumnDef[]\n  /**\n   * Defined keys the saved preference had never heard of, in definition order.\n   *\n   * **Measured against `order`, and empty when the preference had none**, which is the distinction\n   * that makes this usable rather than noise. `order` is the only field that enumerates the columns\n   * a preference knew; `hidden` lists a subset and says nothing about the rest. So on a first visit\n   * — and for a caller that stores only `{ hidden }`, which is a perfectly good way to use this —\n   * nothing is reported, instead of every column being announced as new on every single load. It is\n   * non-empty only when a preference that spelled out *which* columns there were met a table that\n   * has more: a deploy that added one.\n   */\n  added: string[]\n  /** Keys the saved preference carried that no longer exist, in the order it carried them. */\n  dropped: string[]\n  /** Keys the preference hid that had to be shown again to keep `minVisible` met. */\n  revealed: string[]\n}\n\nfunction dedupeColumns(columns: readonly ColumnDef[]): ColumnDef[] {\n  const out: ColumnDef[] = []\n  const seen = new Set<string>()\n  for (const column of columns) {\n    if (typeof column.key !== \"string\" || column.key === \"\" || seen.has(column.key)) continue\n    seen.add(column.key)\n    out.push(column)\n  }\n  return out\n}\n\nfunction asArray(value: unknown): string[] {\n  return Array.isArray(value) ? value.filter((item): item is string => typeof item === \"string\") : []\n}\n\n/**\n * Turns a stored preference and the table's current columns into one answer the table can render.\n *\n * This is the whole component in one function, and the reason it is exported: a table that renders\n * its columns on the server, or paginates through an API, needs the resolved order without ever\n * mounting a menu.\n *\n * **A stored preference is always out of date, and the defaults decide what that costs.** It was\n * written by an older deploy against an older set of columns, or typed into a URL by hand, and the\n * four ways it can disagree with the table each have an obvious-looking answer that is wrong:\n *\n *   - **A column the preference has never seen is shown, not hidden.** Treating the stored list as\n *     the allow-list is the one-line version, and it is the expensive bug in this component:\n *     everyone who ever opened the menu has a stored preference, so a column added next week is\n *     invisible to exactly the established users, and visible only to people who have never used\n *     the product. Nobody reports it — they cannot see it — and the team shipping the column sees\n *     it on their own fresh profile and concludes it works.\n *   - **It is shown in its defined position, not appended.** The definition is where the author\n *     said a column belongs, and appending puts a new \"Status\" column after the actions column at\n *     the far right, past the horizontal scroll. Each new key is anchored after the nearest column\n *     before it that the preference did know, so it lands among its neighbours.\n *   - **A key that no longer exists is dropped rather than carried.** Carried, it leaves a gap in\n *     the order that the next reorder shuffles around invisibly, and a hidden-set entry that\n *     resurrects a column if the key is ever reused.\n *   - **A locked column is pulled back to its defined position and un-hidden.** `locked` is a\n *     property of this deploy's table, not of the stored preference: a column that was ordinary\n *     when the preference was written and is load-bearing now would otherwise stay hidden for the\n *     people who hid it, which is precisely the group whose table is missing the column that makes\n *     it readable.\n *\n * Then the floor is applied last, over the result of all four — because each of them can take the\n * visible count below `minVisible` on its own, and a table of zero columns renders as a strip of\n * nothing with no control left to undo it.\n */\nexport function resolveColumnPreference(\n  columns: readonly ColumnDef[],\n  saved?: Partial<ColumnPreference> | null,\n  { minVisible = 1 }: ResolveColumnOptions = {}\n): ResolvedColumns {\n  const defined = dedupeColumns(columns)\n  const byKey = new Map(defined.map((column) => [column.key, column]))\n  const locked = new Set(defined.filter((column) => column.locked).map((column) => column.key))\n\n  const savedOrder = asArray(saved?.order)\n  const savedHidden = asArray(saved?.hidden)\n  // \"Known to the preference\" spans both fields — a key it hid is a key it has heard of — but only\n  // `order` *enumerates*, so only a preference that has one can make a column new by omission.\n  const known = new Set([...savedOrder, ...savedHidden])\n  const enumerated = savedOrder.length > 0\n\n  const merged: string[] = []\n  for (const key of savedOrder) {\n    if (byKey.has(key) && !merged.includes(key)) merged.push(key)\n  }\n\n  const added: string[] = []\n  for (let i = 0; i < defined.length; i++) {\n    const key = defined[i].key\n    if (merged.includes(key)) continue\n    if (!known.has(key)) added.push(key)\n    // Anchored after the nearest preceding column that is already placed. Walking definition order\n    // means an earlier new key is placed before this one reaches it, so a run of new columns keeps\n    // its own order instead of reversing.\n    let at = 0\n    for (let j = i - 1; j >= 0; j--) {\n      const previous = merged.indexOf(defined[j].key)\n      if (previous !== -1) {\n        at = previous + 1\n        break\n      }\n    }\n    merged.splice(at, 0, key)\n  }\n\n  // Locked keys take the index they hold in the definition; the movable ones fill what is left, in\n  // the order the preference asked for. Because `merged` holds exactly the defined keys, the two\n  // sets fit together with no gaps left over.\n  const movable = merged.filter((key) => !locked.has(key))\n  const order: string[] = []\n  let next = 0\n  for (const column of defined) {\n    if (locked.has(column.key)) order.push(column.key)\n    else order.push(movable[next++])\n  }\n\n  const hiddenSet = new Set(savedHidden.filter((key) => byKey.has(key) && !locked.has(key)))\n\n  const revealed: string[] = []\n  const floor = Math.max(0, Math.min(Math.floor(minVisible) || 0, defined.length))\n  for (const key of order) {\n    if (defined.length - hiddenSet.size >= floor) break\n    if (!hiddenSet.delete(key)) continue\n    revealed.push(key)\n  }\n\n  const dropped: string[] = []\n  for (const key of [...savedOrder, ...savedHidden]) {\n    if (!byKey.has(key) && !dropped.includes(key)) dropped.push(key)\n  }\n\n  const visible: ColumnDef[] = []\n  for (const key of order) {\n    const column = byKey.get(key)\n    if (column && !hiddenSet.has(key)) visible.push(column)\n  }\n\n  return {\n    order,\n    hidden: order.filter((key) => hiddenSet.has(key)),\n    visible,\n    added: enumerated ? added : [],\n    dropped,\n    revealed,\n  }\n}\n\n/**\n * The index `from` would swap with when moved one step in `direction`, or `-1`.\n *\n * Shared by {@link moveColumn} and the hook's `canMove`, rather than written twice: a greyed-out\n * button whose rule has drifted from the rule the press follows is the one bug in a reorder control\n * that nobody sees in review — the button looks pressable and does nothing, or looks dead and works.\n */\nfunction swapTarget(\n  order: readonly string[],\n  hidden: ReadonlySet<string>,\n  locked: ReadonlySet<string>,\n  from: number,\n  direction: -1 | 1\n): number {\n  for (let i = from + direction; i >= 0 && i < order.length; i += direction) {\n    const candidate = order[i]\n    if (locked.has(candidate)) return -1\n    if (!hidden.has(candidate)) return i\n  }\n  return -1\n}\n\n/** Why a column cannot be hidden, or `null` when it can. */\nexport type HideBlockedReason = \"locked\" | \"minimum\"\n\n/**\n * Whether hiding `key` is refused, and why.\n *\n * Two reasons, and they want different words on screen: `\"locked\"` is permanent and about that one\n * column, `\"minimum\"` is temporary and about the table — it lifts the moment another column is\n * shown. Rendered as the same greyed-out checkbox, the second one reads as \"this column is special\n * too\", and the way out of it is never found.\n */\nexport function hideBlockedReason(\n  columns: readonly ColumnDef[],\n  preference: Partial<ColumnPreference> | null | undefined,\n  key: string,\n  options: ResolveColumnOptions = {}\n): HideBlockedReason | null {\n  const defined = dedupeColumns(columns)\n  const column = defined.find((candidate) => candidate.key === key)\n  if (!column) return null\n  if (column.locked) return \"locked\"\n  const resolved = resolveColumnPreference(defined, preference, options)\n  if (resolved.hidden.includes(key)) return null\n  const floor = Math.max(0, Math.min(Math.floor(options.minVisible ?? 1) || 0, defined.length))\n  return resolved.visible.length - 1 < floor ? \"minimum\" : null\n}\n\n/**\n * Shows or hides one column. Returns the next preference, or `null` when nothing would change —\n * the column is already in that state, it is locked, it does not exist, or hiding it would take the\n * table below its floor.\n *\n * `null` rather than the same object or a thrown error: a refusal is an ordinary outcome here\n * (clicking the last visible column's checkbox is a thing people do on purpose, to find out whether\n * they can), and it is the caller's cue to say why instead of writing a preference that resolves\n * back to what it already was.\n *\n * The preference it returns carries the **resolved** order, not the stored one. That is deliberate:\n * the first time anybody touches this menu, the carry-over decisions above — where a new column\n * landed, which stale keys went — stop being recomputed on every load and become the choice.\n */\nexport function setColumnVisible(\n  columns: readonly ColumnDef[],\n  preference: Partial<ColumnPreference> | null | undefined,\n  key: string,\n  visible: boolean,\n  options: ResolveColumnOptions = {}\n): ColumnPreference | null {\n  const defined = dedupeColumns(columns)\n  const column = defined.find((candidate) => candidate.key === key)\n  if (!column || column.locked) return null\n\n  const resolved = resolveColumnPreference(defined, preference, options)\n  const isHidden = resolved.hidden.includes(key)\n  // Already in the state being asked for.\n  if (visible === !isHidden) return null\n  if (!visible && hideBlockedReason(defined, preference, key, options) !== null) return null\n\n  const hidden = visible\n    ? resolved.hidden.filter((entry) => entry !== key)\n    : [...resolved.hidden, key]\n  return { order: resolved.order, hidden: resolved.order.filter((entry) => hidden.includes(entry)) }\n}\n\n/** Flips one column. Same refusals as {@link setColumnVisible}. */\nexport function toggleColumnVisibility(\n  columns: readonly ColumnDef[],\n  preference: Partial<ColumnPreference> | null | undefined,\n  key: string,\n  options: ResolveColumnOptions = {}\n): ColumnPreference | null {\n  const resolved = resolveColumnPreference(columns, preference, options)\n  return setColumnVisible(columns, preference, key, resolved.hidden.includes(key), options)\n}\n\n/** Un-hides everything, keeping the order. `null` when nothing is hidden. */\nexport function showAllColumns(\n  columns: readonly ColumnDef[],\n  preference: Partial<ColumnPreference> | null | undefined,\n  options: ResolveColumnOptions = {}\n): ColumnPreference | null {\n  const resolved = resolveColumnPreference(columns, preference, options)\n  if (resolved.hidden.length === 0) return null\n  return { order: resolved.order, hidden: [] }\n}\n\n/**\n * Moves one column one step towards the start (`-1`) or the end (`1`). `null` when it cannot go —\n * it is locked, or there is nothing on that side to swap with.\n *\n * **The step is over the columns you can see, not over the array.** Swapping with the literal\n * neighbour is the shorter implementation and it produces a control that does nothing: with Email\n * hidden between Name and Created, pressing \"move down\" on Name trades Name for a column that is\n * not on screen, so the table is identical and the button looks broken. Press it twice and Name\n * finally moves, which is worse — it reads as a control that needs two presses sometimes.\n *\n * Hidden columns keep their index through the swap, so un-hiding one later puts it back between the\n * same two neighbours rather than at whichever end the moves pushed it to. Locked columns are not\n * passed over, they are not passed through: they hold their defined position, and a movable column\n * on the far side of one is not reachable by moving — which is the point of pinning the row-actions\n * column to the end.\n */\nexport function moveColumn(\n  columns: readonly ColumnDef[],\n  preference: Partial<ColumnPreference> | null | undefined,\n  key: string,\n  direction: -1 | 1,\n  options: ResolveColumnOptions = {}\n): ColumnPreference | null {\n  const defined = dedupeColumns(columns)\n  const column = defined.find((candidate) => candidate.key === key)\n  if (!column || column.locked) return null\n\n  const resolved = resolveColumnPreference(defined, preference, options)\n  const locked = new Set(defined.filter((entry) => entry.locked).map((entry) => entry.key))\n  const hidden = new Set(resolved.hidden)\n  const from = resolved.order.indexOf(key)\n  if (from === -1) return null\n\n  const to = swapTarget(resolved.order, hidden, locked, from, direction)\n  if (to === -1) return null\n\n  const order = resolved.order.slice()\n  order[from] = resolved.order[to]\n  order[to] = key\n  return { order, hidden: order.filter((entry) => hidden.has(entry)) }\n}\n\n/** Whether `preference` resolves to exactly what no preference at all would resolve to. */\nexport function isDefaultColumnPreference(\n  columns: readonly ColumnDef[],\n  preference: Partial<ColumnPreference> | null | undefined,\n  options: ResolveColumnOptions = {}\n): boolean {\n  const resolved = resolveColumnPreference(columns, preference, options)\n  const base = resolveColumnPreference(columns, null, options)\n  return (\n    resolved.hidden.length === 0 &&\n    base.hidden.length === 0 &&\n    resolved.order.length === base.order.length &&\n    resolved.order.every((key, index) => key === base.order[index])\n  )\n}\n\n// `~` escapes, so a key may contain the two characters the format itself uses. Keys are developer\n// chosen and usually look like `created_at`, but they are also sometimes a JSON path or a header\n// straight out of a CSV, and a preference that drops a column whose name has a comma in it is a\n// column nobody can get back from the menu.\nfunction encodeKey(key: string): string {\n  const escaped = key.replace(/~/g, \"~~\").replace(/,/g, \"~c\")\n  return escaped.startsWith(\"-\") ? `~-${escaped.slice(1)}` : escaped\n}\n\nfunction decodeKey(token: string): string {\n  let out = \"\"\n  for (let i = 0; i < token.length; i++) {\n    const char = token[i]\n    if (char !== \"~\") {\n      out += char\n      continue\n    }\n    const following = token[i + 1]\n    if (following === \"~\") out += \"~\"\n    else if (following === \"c\") out += \",\"\n    else if (following === \"-\") out += \"-\"\n    else {\n      // A stray tilde: kept, rather than eating the character after it. This string may have been\n      // typed by hand, and swallowing a letter would quietly rename the column.\n      out += \"~\"\n      continue\n    }\n    i++\n  }\n  return out\n}\n\n/**\n * The preference as one short string, for a query parameter or a `localStorage` value:\n * `\"name,-email,created\"`, where a leading `-` means hidden.\n *\n * Short on purpose. `JSON.stringify` works and is three times the length once a URL has\n * percent-encoded the braces and quotes, which matters when this is one of several parameters a\n * shared link carries.\n */\nexport function serializeColumnPreference(preference: Partial<ColumnPreference>): string {\n  const order = asArray(preference.order)\n  const hidden = asArray(preference.hidden)\n  const hiddenSet = new Set(hidden)\n  const parts: string[] = []\n  const written = new Set<string>()\n  for (const key of order) {\n    if (written.has(key)) continue\n    written.add(key)\n    parts.push((hiddenSet.has(key) ? \"-\" : \"\") + encodeKey(key))\n  }\n  // A hidden key that the order never mentioned still has to survive the round trip, or\n  // `{ hidden: [\"email\"] }` — a complete preference — serialises to the empty string.\n  for (const key of hidden) {\n    if (written.has(key)) continue\n    written.add(key)\n    parts.push(`-${encodeKey(key)}`)\n  }\n  return parts.join(\",\")\n}\n\n/**\n * Reads back what {@link serializeColumnPreference} wrote. Never throws.\n *\n * This value arrives from a URL, which means it arrives from anyone. Garbage in it resolves to\n * garbage keys, which {@link resolveColumnPreference} drops as not existing — so the worst a\n * hand-edited link can do is show the default table.\n *\n * The round trip is over the *resolved* preference rather than the literal object: a hidden key\n * that was not in `order` comes back inside `order`, which is the same table.\n */\nexport function parseColumnPreference(text: string | null | undefined): ColumnPreference {\n  const order: string[] = []\n  const hidden: string[] = []\n  const seen = new Set<string>()\n  for (const token of String(text ?? \"\").split(\",\")) {\n    if (token === \"\") continue\n    const isHidden = token.startsWith(\"-\")\n    const key = decodeKey(isHidden ? token.slice(1) : token)\n    if (key === \"\" || seen.has(key)) continue\n    seen.add(key)\n    order.push(key)\n    if (isHidden) hidden.push(key)\n  }\n  return { order, hidden }\n}\n\n/** Every string a screen reader or a reader of the panel sees. Override to translate or reword. */\nexport interface ColumnVisibilityLabels {\n  /** The trigger's accessible name. */\n  trigger: (visible: number, total: number) => string\n  /** Visible text on the trigger. */\n  triggerText: string\n  /** The panel's heading, which also names the group of checkboxes. */\n  heading: string\n  /** Announced after a column is shown. */\n  shown: (label: string, visible: number, total: number) => string\n  /** Announced after a column is hidden. */\n  concealed: (label: string, visible: number, total: number) => string\n  /** Announced after a move. `position` counts every column, hidden ones included. */\n  moved: (label: string, position: number, total: number) => string\n  /** Announced when the floor refuses a press, and shown in the panel while it is reached. */\n  minimum: (minVisible: number) => string\n  /** Why a locked column has no checkbox. Read by screen readers only. */\n  locked: string\n  showAll: string\n  reset: string\n  /** Announced after Show all. */\n  shownAll: (total: number) => string\n  /** Announced after Reset. */\n  didReset: string\n  moveUp: (label: string) => string\n  moveDown: (label: string) => string\n}\n\nexport const defaultColumnVisibilityLabels: ColumnVisibilityLabels = {\n  trigger: (visible, total) => `Columns, ${visible} of ${total} shown`,\n  triggerText: \"Columns\",\n  heading: \"Columns\",\n  shown: (label, visible, total) => `${label} shown. ${visible} of ${total} columns shown.`,\n  concealed: (label, visible, total) => `${label} hidden. ${visible} of ${total} columns shown.`,\n  moved: (label, position, total) => `${label} moved to position ${position} of ${total}.`,\n  minimum: (minVisible) =>\n    minVisible === 1\n      ? \"At least one column has to stay visible.\"\n      : `At least ${minVisible} columns have to stay visible.`,\n  locked: \"Always shown\",\n  showAll: \"Show all\",\n  reset: \"Reset\",\n  shownAll: (total) => `All ${total} columns shown.`,\n  didReset: \"Columns reset to their default order, all shown.\",\n  moveUp: (label) => `Move ${label} earlier`,\n  moveDown: (label) => `Move ${label} later`,\n}\n\nexport interface UseColumnVisibilityOptions extends ResolveColumnOptions {\n  columns: readonly ColumnDef[]\n  /** The stored preference. `null`, `undefined` or a partial object are all fine. */\n  preference?: Partial<ColumnPreference> | null\n  /** Receives the next preference. Persist it and pass it back — this hook stores nothing. */\n  onPreferenceChange: (next: ColumnPreference) => void\n  labels?: Partial<ColumnVisibilityLabels>\n  /** Announce changes through a live region. Defaults to `true`. */\n  announce?: boolean\n}\n\nexport interface UseColumnVisibilityResult extends ResolvedColumns {\n  /** The deduplicated column definitions, in render order. */\n  columns: ColumnDef[]\n  visibleCount: number\n  total: number\n  /** True when the preference resolves to the definitions' own order with nothing hidden. */\n  isDefault: boolean\n  /** True while the visible count sits on `minVisible`, so hiding anything more is refused. */\n  atMinimum: boolean\n  /**\n   * `minVisible` as it actually applies: floored to an integer and clamped to the number of\n   * columns there are, so a panel of your own shows the same number the refusal enforces.\n   */\n  minVisible: number\n  /** Non-empty for a moment after a change. Belongs in a polite live region. */\n  announcement: string\n  hideBlocked: (key: string) => HideBlockedReason | null\n  canMove: (key: string, direction: -1 | 1) => boolean\n  setVisible: (key: string, visible: boolean) => void\n  toggle: (key: string) => void\n  move: (key: string, direction: -1 | 1) => void\n  showAll: () => void\n  reset: () => void\n}\n\n/**\n * The column preference without the menu — for a panel of your own, a drawer on a phone, or a\n * server-rendered table that only needs {@link ResolvedColumns}.\n */\nexport function useColumnVisibility({\n  columns,\n  preference,\n  onPreferenceChange,\n  minVisible = 1,\n  labels: labelOverrides,\n  announce = true,\n}: UseColumnVisibilityOptions): UseColumnVisibilityResult {\n  const labels = React.useMemo(\n    () => ({ ...defaultColumnVisibilityLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n\n  const defined = React.useMemo(() => dedupeColumns(columns), [columns])\n  const options = React.useMemo(() => ({ minVisible }), [minVisible])\n  const resolved = React.useMemo(\n    () => resolveColumnPreference(defined, preference, options),\n    [defined, preference, options]\n  )\n\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  // Cleared again shortly after. The region lives inside the panel, which is where the controls\n  // are, so a sentence left in it becomes part of what the next row reads out — \"Email, checkbox,\n  // checked, Name hidden, 3 of 4 columns shown\".\n  React.useEffect(() => {\n    if (!announcement) return\n    const timer = setTimeout(() => setAnnouncement(\"\"), 1200)\n    return () => clearTimeout(timer)\n  }, [announcement])\n\n  const say = React.useCallback(\n    (message: string) => {\n      if (!announce) return\n      // A repeat of the same sentence is not a change to the region's content and is not read out,\n      // so the second refusal in a row would be silent. The zero-width space makes it different\n      // text without making it different words.\n      setAnnouncement((current) => (current === message ? `${message}​` : message))\n    },\n    [announce]\n  )\n\n  const labelOf = React.useCallback(\n    (key: string) => defined.find((column) => column.key === key)?.label ?? key,\n    [defined]\n  )\n\n  const total = defined.length\n  const floor = Math.max(0, Math.min(Math.floor(minVisible) || 0, total))\n\n  const hideBlocked = React.useCallback(\n    (key: string) => hideBlockedReason(defined, preference, key, options),\n    [defined, preference, options]\n  )\n\n  // Derived from the state already resolved above, through the same scan the press uses, rather\n  // than by calling `moveColumn` — which would re-resolve the whole preference twice for every row\n  // on every render just to find out whether an arrow should look pressable.\n  const lockedKeys = React.useMemo(\n    () => new Set(defined.filter((column) => column.locked).map((column) => column.key)),\n    [defined]\n  )\n  const hiddenKeys = React.useMemo(() => new Set(resolved.hidden), [resolved.hidden])\n\n  const canMove = React.useCallback(\n    (key: string, direction: -1 | 1) => {\n      if (lockedKeys.has(key)) return false\n      const from = resolved.order.indexOf(key)\n      if (from === -1) return false\n      return swapTarget(resolved.order, hiddenKeys, lockedKeys, from, direction) !== -1\n    },\n    [resolved.order, hiddenKeys, lockedKeys]\n  )\n\n  const setVisible = React.useCallback(\n    (key: string, visible: boolean) => {\n      const next = setColumnVisible(defined, preference, key, visible, options)\n      if (!next) {\n        if (!visible && hideBlockedReason(defined, preference, key, options) === \"minimum\") {\n          say(labels.minimum(floor))\n        }\n        return\n      }\n      onPreferenceChange(next)\n      const count = next.order.length - next.hidden.length\n      say(\n        visible\n          ? labels.shown(labelOf(key), count, next.order.length)\n          : labels.concealed(labelOf(key), count, next.order.length)\n      )\n    },\n    [defined, preference, options, onPreferenceChange, say, labels, labelOf, floor]\n  )\n\n  const toggle = React.useCallback(\n    (key: string) => setVisible(key, resolved.hidden.includes(key)),\n    [setVisible, resolved.hidden]\n  )\n\n  const move = React.useCallback(\n    (key: string, direction: -1 | 1) => {\n      const next = moveColumn(defined, preference, key, direction, options)\n      if (!next) return\n      onPreferenceChange(next)\n      say(labels.moved(labelOf(key), next.order.indexOf(key) + 1, next.order.length))\n    },\n    [defined, preference, options, onPreferenceChange, say, labels, labelOf]\n  )\n\n  const showAll = React.useCallback(() => {\n    const next = showAllColumns(defined, preference, options)\n    if (!next) return\n    onPreferenceChange(next)\n    say(labels.shownAll(next.order.length))\n  }, [defined, preference, options, onPreferenceChange, say, labels])\n\n  const reset = React.useCallback(() => {\n    if (isDefaultColumnPreference(defined, preference, options)) return\n    onPreferenceChange(defaultColumnPreference())\n    say(labels.didReset)\n  }, [defined, preference, options, onPreferenceChange, say, labels])\n\n  return {\n    ...resolved,\n    columns: defined,\n    visibleCount: resolved.visible.length,\n    total,\n    isDefault: isDefaultColumnPreference(defined, preference, options),\n    // `floor > 0` is load-bearing rather than defensive. `atMinimum` means \"hiding anything more is\n    // refused\", and with a floor of zero nothing is ever refused — but a bare `length <= floor` is\n    // also true when a caller who opted out of the floor has hidden every column, and the panel\n    // then explains a restriction that does not exist, in the sentence \"At least 0 columns have to\n    // stay visible.\"\n    atMinimum: floor > 0 && resolved.visible.length <= floor,\n    minVisible: floor,\n    announcement,\n    hideBlocked,\n    canMove,\n    setVisible,\n    toggle,\n    move,\n    showAll,\n    reset,\n  }\n}\n\nexport interface ColumnVisibilityProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onChange\">,\n    ResolveColumnOptions {\n  columns: readonly ColumnDef[]\n  preference?: Partial<ColumnPreference> | null\n  onPreferenceChange: (next: ColumnPreference) => void\n  /** Show the move controls. Defaults to `true`. */\n  reorderable?: boolean\n  labels?: Partial<ColumnVisibilityLabels>\n  announce?: boolean\n  /** Which edge the panel hangs from. Defaults to `\"end\"`, where a toolbar button usually sits. */\n  align?: \"start\" | \"end\"\n  disabled?: boolean\n  triggerClassName?: string\n  panelClassName?: string\n}\n\n/**\n * The \"Columns\" control for a table you already have: a button that opens a short panel of\n * checkboxes, one per column, with move controls to reorder them.\n *\n * ```tsx\n * const [preference, setPreference] = React.useState<ColumnPreference>(() =>\n *   parseColumnPreference(localStorage.getItem(\"orders.columns\"))\n * )\n * const { visible } = resolveColumnPreference(COLUMNS, preference)\n *\n * <ColumnVisibility\n *   columns={COLUMNS}\n *   preference={preference}\n *   onPreferenceChange={(next) => {\n *     setPreference(next)\n *     localStorage.setItem(\"orders.columns\", serializeColumnPreference(next))\n *   }}\n * />\n *\n * <table>\n *   <thead>\n *     <tr>{visible.map((column) => <th key={column.key}>{column.label}</th>)}</tr>\n *   </thead>\n *   <tbody>\n *     {rows.map((row) => (\n *       <tr key={row.id}>{visible.map((column) => <td key={column.key}>{row[column.key]}</td>)}</tr>\n *     ))}\n *   </tbody>\n * </table>\n * ```\n *\n * **Where the preference is kept is not this component's business.** It takes a value and emits the\n * next one; `localStorage`, a query parameter, a column on the user row — all of them are the same\n * two lines above, and the one that is right depends on whether the choice should follow the person\n * to another browser or follow the link to a colleague. A component that picked for you would have\n * picked `localStorage`, which is the one that cannot do either.\n *\n * **It is a disclosure, not a menu.** `role=\"menu\"` is the reflex for a button that opens a list,\n * and it is wrong twice here: a menu item is one actionable thing, while every row in this panel\n * holds a checkbox and two move buttons, and a menu owns the arrow keys that those buttons need to\n * be reachable with. So the panel is a plain group of real checkboxes, Tab walks it, and nothing\n * has to be re-implemented with `aria-activedescendant` to behave like what it already is.\n *\n * **A column it will not let you hide stays a checkbox you can press.** Setting `disabled` on it is\n * tidier to look at and takes the column out of the tab order, out of the accessibility tree's\n * reach for most screen-reader cursors, and leaves no way to find out *why* — which matters because\n * the usual reason is the floor, and the floor lifts as soon as another column comes back. Here the\n * checkbox keeps its focus and its name, the press is refused, and the refusal is said out loud.\n */\nexport function ColumnVisibility({\n  columns,\n  preference,\n  onPreferenceChange,\n  minVisible = 1,\n  reorderable = true,\n  labels: labelOverrides,\n  announce = true,\n  align = \"end\",\n  disabled = false,\n  className,\n  triggerClassName,\n  panelClassName,\n  ...props\n}: ColumnVisibilityProps) {\n  const state = useColumnVisibility({\n    columns,\n    preference,\n    onPreferenceChange,\n    minVisible,\n    labels: labelOverrides,\n    announce,\n  })\n  const labels = { ...defaultColumnVisibilityLabels, ...labelOverrides }\n\n  const [open, setOpen] = React.useState(false)\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const baseId = React.useId()\n  const panelId = `${baseId}-panel`\n  const headingId = `${baseId}-heading`\n  const minimumId = `${baseId}-minimum`\n\n  const close = React.useCallback((refocus: boolean) => {\n    setOpen(false)\n    if (refocus) triggerRef.current?.focus()\n  }, [])\n\n  // Capture, so an outside press closes the panel before the thing underneath takes focus.\n  React.useEffect(() => {\n    if (!open) return\n    function onPointerDown(event: PointerEvent) {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false)\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown, true)\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true)\n  }, [open])\n\n  const byKey = React.useMemo(\n    () => new Map(state.columns.map((column) => [column.key, column])),\n    [state.columns]\n  )\n  const hiddenSet = React.useMemo(() => new Set(state.hidden), [state.hidden])\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative inline-block\", className)}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\" && open) {\n          event.preventDefault()\n          close(true)\n        }\n      }}\n      onBlur={(event) => {\n        // Only when focus actually landed somewhere else on the page. `relatedTarget` is null when\n        // it went to the browser's own UI or the window lost focus, and closing then would shut the\n        // panel every time somebody switched to another window to check a value.\n        const next = event.relatedTarget as Node | null\n        if (open && next && !rootRef.current?.contains(next)) setOpen(false)\n      }}\n      {...props}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-expanded={open}\n        aria-controls={open ? panelId : undefined}\n        aria-label={labels.trigger(state.visibleCount, state.total)}\n        aria-disabled={disabled || undefined}\n        onClick={() => {\n          if (disabled) return\n          setOpen((current) => !current)\n        }}\n        className={cn(\n          \"inline-flex h-9 items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm font-medium shadow-sm transition-colors\",\n          \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n          disabled ? \"cursor-default opacity-50\" : \"hover:bg-accent hover:text-accent-foreground\",\n          triggerClassName\n        )}\n      >\n        <Columns3 aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n        <span>{labels.triggerText}</span>\n        {/* Decorative: the trigger's accessible name already carries both numbers as words, and\n            reading the badge as well gives \"3 of 5 shown, 3/5\". */}\n        <span aria-hidden=\"true\" className=\"text-xs tabular-nums text-muted-foreground\">\n          {state.visibleCount}/{state.total}\n        </span>\n      </button>\n\n      {open && (\n        <div\n          id={panelId}\n          role=\"group\"\n          aria-labelledby={headingId}\n          className={cn(\n            \"absolute z-50 mt-1 w-60 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md\",\n            align === \"end\" ? \"right-0\" : \"left-0\",\n            panelClassName\n          )}\n        >\n          <p id={headingId} className=\"px-2 py-1.5 text-xs font-medium text-muted-foreground\">\n            {labels.heading}\n          </p>\n          <ul className=\"max-h-72 overflow-y-auto\">\n            {state.order.map((key) => {\n              const column = byKey.get(key)\n              if (!column) return null\n\n              if (column.locked) {\n                return (\n                  <li\n                    key={key}\n                    className=\"flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-muted-foreground\"\n                  >\n                    <Check aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n                    <span className=\"flex-1 truncate\">{column.label}</span>\n                    <Lock aria-hidden=\"true\" className=\"h-3.5 w-3.5 shrink-0\" />\n                    <span className=\"sr-only\">{labels.locked}</span>\n                  </li>\n                )\n              }\n\n              const isHidden = hiddenSet.has(key)\n              const blocked = state.hideBlocked(key)\n\n              return (\n                // Keyed by the column, never by the index: a reorder then moves this row's existing\n                // node rather than rewriting the row in place, so the move button keeps the focus\n                // that is on it and a second press lands on the same column. Keyed by index, focus\n                // stays at that position and the next press moves whatever column arrived there.\n                <li key={key} className=\"flex items-center gap-1 rounded-sm px-1 hover:bg-accent/50\">\n                  <label className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-1 py-1.5 text-sm\">\n                    <input\n                      type=\"checkbox\"\n                      checked={!isHidden}\n                      // Not `disabled`: see the note on the component. The press is refused in the\n                      // handler instead, and the reason is announced.\n                      aria-disabled={blocked === \"minimum\" || undefined}\n                      aria-describedby={blocked === \"minimum\" ? minimumId : undefined}\n                      onChange={(event) => state.setVisible(key, event.target.checked)}\n                      className=\"size-4 shrink-0 cursor-pointer rounded border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                    />\n                    <span className=\"truncate\">{column.label}</span>\n                  </label>\n                  {reorderable && (\n                    <span className=\"flex shrink-0 items-center\">\n                      {([-1, 1] as const).map((direction) => {\n                        const Icon = direction === -1 ? ArrowUp : ArrowDown\n                        const can = state.canMove(key, direction)\n                        return (\n                          <button\n                            key={direction}\n                            type=\"button\"\n                            aria-label={\n                              direction === -1\n                                ? labels.moveUp(column.label)\n                                : labels.moveDown(column.label)\n                            }\n                            // `aria-disabled` again, for the same reason the checkbox uses it: the\n                            // top row's \"earlier\" button becoming unfocusable means tabbing through\n                            // the panel lands on a different control for every row, and the one\n                            // under your finger a moment ago has moved.\n                            aria-disabled={!can || undefined}\n                            onClick={() => state.move(key, direction)}\n                            className={cn(\n                              \"rounded-sm p-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n                              can\n                                ? \"text-muted-foreground hover:bg-accent hover:text-accent-foreground\"\n                                : \"cursor-default text-muted-foreground/30\"\n                            )}\n                          >\n                            <Icon aria-hidden=\"true\" className=\"h-3.5 w-3.5\" />\n                          </button>\n                        )\n                      })}\n                    </span>\n                  )}\n                </li>\n              )\n            })}\n          </ul>\n\n          {state.atMinimum && (\n            <p id={minimumId} className=\"px-2 py-1.5 text-xs text-muted-foreground\">\n              {labels.minimum(state.minVisible)}\n            </p>\n          )}\n\n          {(state.hidden.length > 0 || !state.isDefault) && (\n            <div className=\"mt-1 flex items-center gap-1 border-t border-border pt-1\">\n              {state.hidden.length > 0 && (\n                <button\n                  type=\"button\"\n                  onClick={state.showAll}\n                  className=\"flex-1 rounded-sm px-2 py-1.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n                >\n                  {labels.showAll}\n                </button>\n              )}\n              {!state.isDefault && (\n                <button\n                  type=\"button\"\n                  onClick={state.reset}\n                  className=\"flex items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n                >\n                  <RotateCcw aria-hidden=\"true\" className=\"h-3 w-3\" />\n                  {labels.reset}\n                </button>\n              )}\n            </div>\n          )}\n\n          {/* Polite, and emptied again a moment later — see useColumnVisibility. Hiding a column\n              replaces part of the table behind the panel and moves no focus, so without this the\n              only feedback is a checkbox that changed shape. */}\n          <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n            {state.announcement}\n          </span>\n        </div>\n      )}\n    </div>\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"
}
