// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useTableSelection.tsx * @input React, types, CheckboxInput, theme tokens * @output Exports useTableSelection hook and UseTableSelectionConfig type * @position Selection plugin; consumed by Table via plugins prop * * ## Architecture (v2 — transformColumns + ref-based row styling) * * Selection checkboxes are implemented as a synthetic column prepended via * `transformColumns`. The checkbox column flows through the normal cell * component pipeline, so it automatically respects component overrides * (components prop on BaseTable). * * Selection state is managed via an external store (SelectionStore) for * fine-grained row subscriptions. Each row's checkbox subscribes * independently — when selection changes, only that row's checkbox * re-renders, not the entire table body. * * Row-level styling (aria-selected, background) uses imperative DOM * updates via a ref callback on each . The ref subscribes to the * store and applies/removes styles when selection changes — no extra * DOM elements and no central element tracking. Each subscription * self-cleans when the row disconnects. * * Because the background is an inline style, it outranks anything * userland can layer on with StyleX. `hasRowHighlight: false` is the * supported way to reclaim the row background; `aria-selected` is * unaffected by it. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Table/useTableSelection.doc.mjs (config prop table) * - /packages/core/src/Table/Table.doc.mjs (selection documentation) * - /packages/core/src/Table/index.ts (exports) */ import { createContext, use, useCallback, useEffect, useRef, useMemo, useSyncExternalStore, type ReactNode } from 'react'; import * as stylex from "../../../style-runtime.js"; import { colorVars } from "../../../theme/tokens.stylex.js"; import { CheckboxInput } from "../../../CheckboxInput/index.js"; import { mergeRefs } from "../../../utils/index.js"; import type { TablePlugin, TableColumn, BodyRowRenderProps } from "../../types.js"; import { pixel } from "../../columnUtils.js"; import { useTranslator } from "../../../i18n/index.js"; // ============================================================================= // Config Type // ============================================================================= export interface UseTableSelectionConfig> { /** Is this item currently selected? */ getIsItemSelected: (item: T) => boolean; /** Called when a row checkbox is toggled. isSelected = new desired state. */ onSelectItem: (event: { item: T; isSelected: boolean; }) => void; /** Called when select-all checkbox is toggled. */ onSelectAll: (event: { isAllSelected: boolean; }) => void; /** Are all selectable items currently selected? */ getIsAllSelected: () => boolean; /** Is the selection partial (some but not all)? Shows indeterminate checkbox. */ getIsIndeterminate?: () => boolean; /** Should this row show a checkbox? Non-selectable rows render nothing. @default () => true */ getIsItemSelectable?: (item: T) => boolean; /** Is this row's checkbox interactive? Disabled rows show disabled checkbox. @default () => true */ getIsItemEnabled?: (item: T) => boolean; /** * Derive a human-readable identity for a row, used in the row checkbox's * hidden label as `Select ${getRowLabel(item)}`. Without it, every row * checkbox announces an undifferentiated "Select row" to screen readers. * With `getRowLabel: item => item.name`, checkbox accessible names become * "Select Alice", "Select Bob", and so on. * * @example * ``` * getRowLabel: item => item.name * ``` */ getRowLabel?: (item: T) => string; /** * Paint checked rows with the accent wash. Set false when the surrounding * UI already uses row background to mean something else — a row that is * open in a detail panel, for instance. The wash is an inline style, so * it cannot be overridden from userland; this flag is the way off it. * * Only the background is dropped. `aria-selected` is still set on checked * rows either way, so the selection stays legible to screen readers. * * @default true * @example * ``` * useTableSelection({...config, hasRowHighlight: false}) * ``` */ hasRowHighlight?: boolean; } // ============================================================================= // Selection Store (external store for fine-grained row subscriptions) // ============================================================================= /** * Lightweight external store that lets each row subscribe to selection * changes independently. Row styling is handled by per-row ref callbacks * that subscribe to this store — the store itself holds no DOM references. */ interface SelectionStore> { subscribe: (listener: () => void) => () => void; notify: () => void; getConfig: () => UseTableSelectionConfig; } function createSelectionStore>(configRef: React.RefObject>): SelectionStore { const listeners = new Set<() => void>(); return { subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, notify() { for (const listener of listeners) { listener(); } }, getConfig() { return configRef.current; } }; } /** * Apply or remove selection styling on a element. * * `aria-selected` tracks selection on its own: it is the semantic half of * the state and stays correct whether or not the row is painted. */ function applyRowSelectionStyle(el: HTMLTableRowElement, isSelected: boolean, hasRowHighlight: boolean): void { if (isSelected) { el.setAttribute('aria-selected', 'true'); } else { el.removeAttribute('aria-selected'); } // Written on every pass, not just when painting: the flag can flip while a // row is already selected, and the wash has to come back off. const wash = isSelected && hasRowHighlight ? selectedBgColor : ''; if (el.style.backgroundColor !== wash) { el.style.backgroundColor = wash; } // A pinned column paints its own opaque background over the row, so the // wash alone stops at the freeze line. Publishing it as the row overlay is // what lets useTableStickyColumns replay it on those cells — the same // contract TableRow honours for striping and hover. const currentOverlay = el.style.getPropertyValue(ROW_OVERLAY_VAR); if (wash === '') { if (currentOverlay !== '') { el.style.removeProperty(ROW_OVERLAY_VAR); } } else { if (currentOverlay !== wash) { el.style.setProperty(ROW_OVERLAY_VAR, wash); } } } // ============================================================================= // Selection Context // ============================================================================= // eslint-disable-next-line @typescript-eslint/no-explicit-any const SelectionStoreContext = createContext | null>(null); SelectionStoreContext.displayName = 'SelectionStoreContext'; // ============================================================================= // Hooks for subscribing to selection state // ============================================================================= function useIsItemSelected>(store: SelectionStore, item: T): boolean { const getSnapshot = useCallback(() => store.getConfig().getIsItemSelected(item), [store, item]); return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); } // ============================================================================= // Checkbox Components // ============================================================================= function SelectAllCheckbox() { const store = use(SelectionStoreContext); if (!store) { return null; } return ; } /** * Encode select-all state as a number for useSyncExternalStore. * Avoids string encoding + splitting — the snapshot is a cheap numeric * comparison. Values: 0 = none, 1 = indeterminate, 2 = all selected. */ const SELECT_NONE = 0; const SELECT_INDETERMINATE = 1; const SELECT_ALL = 2; /** * Inner component that subscribes to all-selected/indeterminate state. * Separated so the useCallback/useSyncExternalStore hooks are not * called conditionally (after the null guard). */ function SelectAllCheckboxInner>({ store }: { store: SelectionStore; }) { const t = useTranslator(); const getSnapshot = useCallback(() => { const config = store.getConfig(); const allSelected = config.getIsAllSelected(); if (allSelected) { return SELECT_ALL; } const indeterminate = config.getIsIndeterminate?.() ?? false; return indeterminate ? SELECT_INDETERMINATE : SELECT_NONE; }, [store]); const state = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); const allSelected = state === SELECT_ALL; const indeterminate = state === SELECT_INDETERMINATE; return store.getConfig().onSelectAll({ isAllSelected: !allSelected })} size="sm" />; } /** * Row checkbox component — used as renderCell for the synthetic selection column. * Subscribes to this item's selection state independently. */ function SelectionCellContent>({ item }: { item: T; }) { const store = use(SelectionStoreContext); if (!store) { return null; } return ; } function SelectionCellContentInner>({ store, item }: { store: SelectionStore; item: T; }) { const t = useTranslator(); const config = store.getConfig(); const isSelected = useIsItemSelected(store, item); const selectable = config.getIsItemSelectable?.(item) ?? true; const enabled = config.getIsItemEnabled?.(item) ?? true; const rowLabel = config.getRowLabel?.(item); if (!selectable) { return null; } return store.getConfig().onSelectItem({ item, isSelected: !isSelected })} isDisabled={!enabled} size="sm" />; } // ============================================================================= // Styles // ============================================================================= const selectedBgColor = colorVars['--color-accent-muted']; /** * The row's current fill, republished for pinned cells to read. Declared as a * literal rather than imported because StyleX needs the same name as a static * key on the reading side, so there is no one constant both ends can share. */ const ROW_OVERLAY_VAR = '--table-row-overlay'; /** Selection column key — prefixed to avoid collisions with user columns. */ const SELECTION_COLUMN_KEY = '__xds_selection'; /** Fixed width for the selection column. Content is centered with no horizontal padding. */ const SELECTION_COLUMN_WIDTH = pixel(36); // ============================================================================= // Hook // ============================================================================= export function useTableSelection>(config: UseTableSelectionConfig): TablePlugin { const configRef = useRef(config); configRef.current = config; const storeRef = useRef | null>(null); if (storeRef.current == null) { storeRef.current = createSelectionStore(configRef); } const store = storeRef.current; // Notify subscribers on every render — useSyncExternalStore will only // re-render components whose snapshot actually changed. Row ref // subscribers apply imperative styling independently. useEffect(() => { store.notify(); }); // Synthetic selection column — uses renderCell with a subscribing // component so each row's checkbox re-renders independently. const selectionColumn = useMemo((): TableColumn => ({ key: SELECTION_COLUMN_KEY, header:
, width: SELECTION_COLUMN_WIDTH, resizable: false, renderCell: (item: T) =>
}), []); return useMemo((): TablePlugin => ({ transformTableContext(children: ReactNode) { return {children} ; }, transformColumns(columns: TableColumn[]) { return [selectionColumn, ...columns]; }, transformBodyRow(props: BodyRowRenderProps, item: T) { // Attach a ref to the that subscribes to the store for // imperative row styling. Each row manages its own subscription // and self-cleans when disconnected — no central element tracking. const selectionRef: React.RefCallback = el => { if (!el) { return; } const applyStyle = () => { const config = store.getConfig(); applyRowSelectionStyle(el, config.getIsItemSelected(item), config.hasRowHighlight ?? true); }; // Apply initial style applyStyle(); // Subscribe for future changes const unsub = store.subscribe(() => { if (!el.isConnected) { unsub(); return; } applyStyle(); }); return () => { unsub(); }; }; return { ...props, ref: props.ref ? mergeRefs(props.ref, selectionRef) : selectionRef }; } }), [store, selectionColumn]); }