# Table

Level: organism.

## Purpose and use

Compose a desktop record table with filters, typed cells, selection, actions and
pagination. Supply complete fixture/data inputs; Table does not call backend APIs.

## Anatomy, behavior, and accessibility

- Optional Filter Row and bulk-action bar surround a horizontally scrollable
  table with Header/Cell components; optional Pagination follows it. Column IDs
  address each row's cells; row IDs must be stable and unique.
- Table forwards Filter Row's controlled `filterOpenRequest` and semantic
  `filterAction` without changing their payloads. The enclosing screen owns
  filter values, overlay state, and local or remote data filtering.
- With `containedScroll=true`, Table fills its available height and makes only
  the scroll frame vertically and horizontally scrollable. The Filter Row stays
  outside that viewport and the column header remains sticky at its top edge.
  The viewport exposes `role="region"`, `tabindex="0"`, and `tableAriaLabel`.
- Sort requests emit column/direction and a local flag. Rows are sorted locally
  when pagination is hidden or totalItems does not exceed the supplied rows;
  otherwise the parent provides ordered pages. Pagination emits changes without
  slicing rows. Replacing the columns array resets internal sort override.
- Enabled checkbox cells update internal selection and aggregate header state.
  Replacing the rows array resets internal selection overrides. Bulk buttons emit
  selectedRows then clear selection; bulkActions=false removes checkbox columns.
- Fixed edge columns receive a `fixedOffset` calculated from the widths of
  preceding fixed columns and are anchored natively through
  `inset-inline-start`/`inset-inline-end`. No scroll-driven transform or signal
  updates their position. Table passes the boundary side through Header's
  `sticky` input; Header owns its bottom and boundary separators. Loading marks
  busy, overlays a progress indicator and disables
  selection/action paths. filteredEmpty with zero rows shows filter-result copy
  only when loading is false.
- `fixedColumn: true` on the first data column and on consecutive following
  columns creates one leading fixed group. The group ends at the first column
  without explicit `true`; Table derives cumulative left offsets and applies the
  boundary separator to its final column.
- `WblTableColumn.fixedColumn: false` explicitly removes that
  column from automatic edge fixation. A first data column or actions column
  with this value receives no fixed side, inset offset, fixed divider, or
  Table Cell fixed boundary. Omitting `fixedColumn` preserves the current
  automatic first-data/actions behavior for existing consumers.
- When Table is projected into `wbl-main-container`, its Filter Row, any
  rendered Action Bar, and scroll frame/grid remain inside the same
  page-layout-selected rail. Main Container never creates a Table breakout; the
  viewport, including any native scrollbar gutter, uses exactly the rail's
  inline edges. `containedScroll` and pagination never widen it to reserve that
  gutter. The grid keeps ownership of horizontal scrolling and fixed edges.
  This is a Main Container composition rule, not a Table input, variant, or
  public API.
- Table, rows, headers and cells expose structural roles and accessible names;
  interactive descendants provide native buttons/links/checkboxes. Use column,
  row-selection and action labels meaningful without surrounding visual context.

- With `adaptive=true`, Table measures its own inline size (not the window).
  Below 1024px it replaces the grid with a list of cards, one per row; at 1024px
  and wider it stays a table. `adaptive` defaults to `false`, so existing
  consumers are unchanged. Each card starts with a header row: the selection checkbox (when bulk
  actions are on) at the start and a «more» (three dots) menu at the end. Below
  it come «label — value» pairs for the first `cardPrimaryColumns` data columns
  (default 5); labels use `--color-text-icon-primary-gray`. The menu holds the row actions; selecting one
  emits the usual `actionSelect`. When there are more data columns, a
  «Показать ещё» button at the bottom of the card reveals them inside the same
  card and becomes «Свернуть». The block expands and collapses smoothly
  (height and opacity); with `prefers-reduced-motion: reduce` it switches
  instantly. Collapsed fields stay in the DOM but are `inert`. In card mode the action bar stays on one line with
  8px block padding: «Выбрано» at the start, bulk buttons at the end, rendered as
  primary flat buttons. Once rows are selected, «Выбрать все N строк» takes a
  second line under «Выбрано» so it never overlaps the buttons.
  Cards stack in one full-width column with no border or radius and 12px inline
  padding, matching the action bar;
  a divider separates neighbouring cards. Number cells are left-aligned in cards; fixed columns and horizontal
  scroll do not apply. Cards keep `role="table"`/`row`, and each label is a
  `rowheader`.

## Open Questions

Adaptive cards (A-011, prototype under review): there is no column header, so
sorting and the header «select all» checkbox are unavailable in card mode
(selecting all remains possible from the action bar after the first selection).
Chevron columns are omitted from cards. The 1024px threshold is a component
constant, not a token: a shared breakpoint scale (390 / 780 / 1024 / 1280 /
1440 / 1920) is proposed but not yet recorded in `specs/ux/foundations` or
`tokens.scss`. Filter Row and Pagination have no adaptive behaviour yet. Row
action variants are not distinguished inside the card menu. No Figma evidence exists
for the card layout.

There is no virtual scrolling, backend sorting/paging or full grid keyboard
navigation. A chevron event does not itself render nested rows. Empty rows without
filteredEmpty have no dedicated empty illustration; screen-level state selection
and data-error messaging remain caller responsibilities.

## Local component contract

This is the active owned contract for `component.table`. The API below
is verified against the current local implementation, focused test and story.
The Source commit `0112cba0779a2d2a4b6c7a17243201694344d70a` remains
migration provenance, not the runtime owner.

The Main Container composition rule above does not add a Table input, output,
model, selector, type, or standalone variant.

The `fixedColumn: false` behavior described above is the current M-002 semantic
extension of the existing `WblTableColumn.fixedColumn` field. Focused tests
verify the opt-out while omitted values preserve automatic edge fixation.

### Classes and selectors

- `WblTableComponent` — `wbl-table` (src/app/design-system/organisms/table/table.component.ts).

### Inputs

- `WblTableComponent.columns = input<WblTableColumn[]>(DEFAULT_COLUMNS)`
- `WblTableComponent.rows = input<WblTableRow[]>(DEFAULT_ROWS)`
- `WblTableComponent.ariaLabel = input('Таблица')`
- `WblTableComponent.tableAriaLabel = input('Данные таблицы')`
- `WblTableComponent.bulkActions = input(true)`
- `WblTableComponent.loading = input(false)`
- `WblTableComponent.containedScroll = input(false)`
- `WblTableComponent.adaptive = input(false)`
- `WblTableComponent.cardPrimaryColumns = input(5)`
- `WblTableComponent.cardExpandLabel = input('Показать ещё')`
- `WblTableComponent.cardCollapseLabel = input('Свернуть')`
- `WblTableComponent.cardMenuLabel = input('Действия')`
- `WblTableComponent.filteredEmpty = input(false)`
- `WblTableComponent.filteredEmptyEntityName = input('Записи')`
- `WblTableComponent.actionBarButton2 = input(true)`
- `WblTableComponent.actionBarButton3 = input(true)`
- `WblTableComponent.actionBarButton1Label = input('Отменить')`
- `WblTableComponent.actionBarButton2Label = input('Скачать')`
- `WblTableComponent.actionBarButton3Label = input('Подтвердить')`
- `WblTableComponent.showFilterRow = input(true)`
- `WblTableComponent.filters = input<WblFilterRowFilter[]>([])`
- `WblTableComponent.addFilterItems = input<WblDropdownItem[]>([])`
- `WblTableComponent.showSearch = input(true)`
- `WblTableComponent.searchLabel = input('Поиск')`
- `WblTableComponent.searchPlaceholder = input('Поиск')`
- `WblTableComponent.searchValue = input('')`
- `WblTableComponent.showAddFilter = input(true)`
- `WblTableComponent.addFilterLabel = input('Фильтр')`
- `WblTableComponent.showReset = input(true)`
- `WblTableComponent.resetDisabled = input<boolean | null>(null)`
- `WblTableComponent.showSwitchColumn = input(true)`
- `WblTableComponent.showSecondSwitch = input(false)`
- `WblTableComponent.switches = input<WblFilterRowSwitch[]>([])`
- `WblTableComponent.showColumns = input(true)`
- `WblTableComponent.columnsLabel = input('Настроить колонки')`
- `WblTableComponent.showActions = input(true)`
- `WblTableComponent.showMoreAction = input(true)`
- `WblTableComponent.downloadLabel = input('Скачать')`
- `WblTableComponent.moreLabel = input('Ещё')`
- `WblTableComponent.moreActionItems = input<WblDropdownItem[]>([])`
- `WblTableComponent.downloadDisabled = input(false)`
- `WblTableComponent.moreDisabled = input(false)`
- `WblTableComponent.filterAriaLabel = input('Фильтры таблицы')`
- `WblTableComponent.showPagination = input(true)`
- `WblTableComponent.page = input(1)`
- `WblTableComponent.pageSize = input(25)`
- `WblTableComponent.totalItems = input(0)`
- `WblTableComponent.pageSizeOptions = input<number[]>([25, 50, 100])`
- `WblTableComponent.rowsPerPageLabel = input('Строк на странице')`
- `WblTableComponent.paginationAriaLabel = input('Пагинация таблицы')`
- `WblTableComponent.paginationDisabled = input(false)`

- `WblTableComponent.filteredEmptyTitleText = input<string | null>(null)`
- `WblTableComponent.filteredEmptyCaptionText = input<string | null>(null)`

Each nullable input overrides only the matching filtered-empty line. `null`
preserves the current copy derived from `filteredEmptyEntityName` and
`showSearch`. Transport Requests passes a tab-specific title and the shared
caption «Попробуйте изменить фильтры». Focused tests and the `FilteredEmpty`
Storybook state are current evidence.

### Public types and allowed values

```ts
type WblTableColumn = {
  id: string;
  label: string;
  width?: string;
  alignment?: WblTableHeaderAlignment;
  variant?: WblTableHeaderVariant;
  fixedColumn?: boolean;
  infoIcon?: boolean;
  sortable?: boolean;
  sortDirection?: WblTableHeaderSortDirection;
  checked?: boolean;
  intermediate?: boolean;
  ariaLabel?: string;
  checkboxLabel?: string;
};
type WblTableRow = {
  id: string;
  cells: Record<string, WblTableCell>;
  ariaLabel?: string;
};
type WblFilterRowFilter = {
  id: string;
  label: string;
  value?: string;
  filled?: boolean;
  multiple?: boolean;
  count?: string | number;
  lifecycle: WblFilterLifecycle;
  opened: boolean;
  invalid?: boolean;
  errorText?: string;
  disabled?: boolean;
};
type WblFilterLifecycle = 'required' | 'pinned' | 'optional';
type WblFilterAction = 'clear' | 'reset' | 'remove';
type WblFilterRowOpenRequest = {
  filter: WblFilterRowFilter;
  opened: boolean;
};
type WblFilterRowFilterAction = {
  filter: WblFilterRowFilter;
  action: WblFilterAction;
};
type WblDropdownItem = {
  id: string;
  label: string;
  iconName?: WblIconName;
  imageSrc?: string;
  imageAlt?: string;
  badge?: string;
  rightText?: string;
  selected?: boolean;
  disabled?: boolean;
  separatorBefore?: boolean;
};
type WblFilterRowSwitch = {
  id: string;
  label: string;
  selected?: boolean;
  disabled?: boolean;
};
type WblTableSortChange = {
  column: WblTableColumn;
  direction: WblTableHeaderSortDirection;
  local: boolean;
};
type WblTableHeaderCheckedChange = {
  column: WblTableColumn;
  checked: boolean;
};
type WblTableCellCheckedChange = {
  row: WblTableRow;
  column: WblTableColumn;
  checked: boolean;
};
type WblTableRowOpenedChange = {
  row: WblTableRow;
  column: WblTableColumn;
  opened: boolean;
};
type WblTableActionSelect = {
  row: WblTableRow;
  column: WblTableColumn;
  action: WblTableCellAction;
};
type WblTableActionBarButtonClick = {
  button: WblTableActionBarButton;
  selectedRows: WblTableRow[];
};
type WblFilterRowSwitchChange = {
  switchItem: WblFilterRowSwitch;
  selected: boolean;
};
interface WblPaginationChange {
  page: number;
  pageSize: number;
}
type WblTableHeaderAlignment = 'left' | 'right';
type WblTableHeaderVariant = 'text' | 'checkbox';
type WblTableHeaderSortDirection = 'none' | 'ascending' | 'descending';
type WblTableCell = {
  type?: WblTableCellType;
  fixed?: WblTableCellFixed;
  link?: WblTableCellLink;
  alignment?: WblTableCellAlignment;
  href?: string;
  leftIcon?: boolean;
  leftIconName?: WblIconName;
  text?: string;
  description?: boolean;
  descriptionText?: string;
  rightIcon?: boolean;
  rightIconName?: WblIconName;
  rightIconPlacement?: WblTableCellRightIconPlacement;
  hint?: boolean;
  hintLabel?: string;
  badgeText?: string;
  badgeAppearance?: WblBadgeAppearance;
  checked?: boolean;
  intermediate?: boolean;
  disable?: boolean;
  checkboxLabel?: string;
  opened?: boolean;
  expandLabel?: string;
  collapseLabel?: string;
  actions?: WblTableCellAction[];
  moreActionLabel?: string;
};
type WblTableCellAction = {
  id: string;
  label: string;
  variant?: WblTableCellActionButtonVariant;
  iconName?: WblIconName;
  showLabel?: boolean;
  tooltipText?: string;
  disabled?: boolean;
  overflow?: boolean;
  separatorBefore?: boolean;
};
type WblTableActionBarButton = 'button1' | 'button2' | 'button3';
type WblTableCellType =
  | 'none'
  | 'text'
  | 'number'
  | 'link'
  | 'badge'
  | 'checkbox'
  | 'chevron'
  | 'slot'
  | 'actions';
type WblTableCellFixed = 'none' | 'left' | 'right';
type WblTableCellLink = 'accent' | 'secondary';
type WblTableCellAlignment = 'left' | 'right';
type WblBadgeAppearance =
  | 'default'
  | 'red'
  | 'orange'
  | 'green'
  | 'success'
  | 'warning'
  | 'error'
  | 'clear'
  | 'disabled'
  | 'info';
type WblTableCellActionButtonVariant = WblButtonVariant | `flat-${WblButtonFlatVariant}`;
type WblButtonVariant = 'primary' | 'secondary' | 'danger' | 'inverted';
type WblButtonFlatVariant = 'primary' | 'secondary' | 'warning' | 'success' | 'danger';
```

Icon-name inputs use `WblIconName` from the [shared icon contract](../atoms/icon.md);
choose a key present in the owned icon pack, not an arbitrary external icon name.

### Contained rows viewport

Transport Requests passes `[containedScroll]="true"` to constrain the rows
region to the remaining vertical space. Filter Row stays outside that vertical
scrollport, the column header remains sticky at its top edge, and the screen's
infinite-scroll listener uses this viewport. Existing Table consumers keep the
unbounded vertical behavior through the `false` default. The current
implementation, focused tests, and `ContainedScroll` Storybook story verify the
current contract.

### Models

- None.

### Outputs

- `WblTableComponent.sortChange = output<WblTableSortChange>()`
- `WblTableComponent.headerCheckedChange = output<WblTableHeaderCheckedChange>()`
- `WblTableComponent.cellCheckedChange = output<WblTableCellCheckedChange>()`
- `WblTableComponent.rowOpenedChange = output<WblTableRowOpenedChange>()`
- `WblTableComponent.actionSelect = output<WblTableActionSelect>()`
- `WblTableComponent.actionBarButtonClick = output<WblTableActionBarButtonClick>()`
- `WblTableComponent.searchValueChange = output<string>()`
- `WblTableComponent.filterOpenRequest = output<WblFilterRowOpenRequest>()`
- `WblTableComponent.filterAction = output<WblFilterRowFilterAction>()`
- `WblTableComponent.addFilterSelect = output<WblDropdownItem>()`
- `WblTableComponent.resetClick = output<void>()`
- `WblTableComponent.switchChange = output<WblFilterRowSwitchChange>()`
- `WblTableComponent.columnsClick = output<void>()`
- `WblTableComponent.downloadClick = output<void>()`
- `WblTableComponent.moreActionSelect = output<WblDropdownItem>()`
- `WblTableComponent.pageChange = output<WblPaginationChange>()`
- `WblTableComponent.pageSizeChange = output<number>()`

### Slots and projection markers

- `WblTableComponent: <ng-content select="[wblFilterRowControl]">`

### Local evidence

- Code: `src/app/design-system/organisms/table/table.component.ts`.
- Focused test: `src/app/design-system/organisms/table/table.component.spec.ts`.
- Public export: `export * from './organisms/table/table.component';` in `src/app/design-system/index.ts`.
- Storybook: `storybook/stories/table.stories.ts`
  (`Design System/Organisms/wbl-table`, including `ContainedScroll`).
- Adaptive story: `storybook/stories/adaptive-table.stories.ts`
  (`Адаптивность/Таблица`), a container-width switcher for 390–1920px. 390,
  780 and 1024 render inside a device mock-up taken from UI Kit node
  `12101:104965`: status bar, logo with menu icon, and a 60px system area at
  the bottom. Desktop widths show a 48px menu placeholder. Every width shows a
  adaptive Header Widget and a non-interactive «Фильтры» button above the Table.

## Figma status

This runtime contract does not infer a Figma owner. Assembly evidence, when
verified, is maintained separately through the inventory-linked sidecar.

## Provenance

The archived source-document copy is historical evidence only. The active
resolver is local code/test/export and the exact Storybook story above.

## M-002 scrollbar refinement

The Table scroll frame uses the owned custom scrollbar treatment evidenced
by UI Kit node
[`9036:58781`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=9036-58781)
for both contained vertical overflow and horizontal overflow. The native
scrollbar is 4px, uses tertiary `#c4c4d4` for the pill-radius thumb, keeps the
track transparent, and replaces the former decorative rail. Styling does not
change the viewport's accessible region, focusability, sticky header, column
widths, or scroll ownership. The public API is unchanged.

## M-002 contained scrollbar within the rail

In contained-scroll mode, the scroll frame is a flex container with
`overflow: visible` and no inline-end padding. The viewport uses
`inline-size: 100%`, `overflow: auto`, and native `scrollbar-gutter: stable`.
Any browser-reserved gutter stays inside that rail-bounded viewport; it never
widens the Table or creates outside layout area. The existing 4px thumb
treatment, native scroll ownership, public API, focused tests, and
`ContainedScroll` story remain current evidence.

## M-002 per-tab fixed mapping and content transition

Transport Requests passes a separate column definition for each tab. The
«Предварительные» columns opt out of fixed edges. The other four tabs mark their
first two columns (`ID / статус` and `Тип заявки`) `fixedColumn: true`, producing
a consecutive leading fixed group, and mark their final actions column
`fixedColumn: true` for the right edge. Table derives the sides, cumulative
offsets and boundary separators from that configuration and does not contain
screen-specific column IDs or defaults.

The screen-owned wrapper, rather than Table, animates content after a selected
tab changes: `opacity: 0.9` transitions to the normal state over
`--duration-fast` with `--ease-standard`. It does not animate Filter Row or the
sticky column header, change Table geometry, or block input; the screen resets
horizontal scroll to the start on each tab change. With
`prefers-reduced-motion: reduce`, the animation is disabled and content is
replaced immediately. The Table public API is unchanged.

## M-002 sorting and contained scrollbar

Transport Requests marks ID, creation date and vehicle-submission date sortable
in its four non-preliminary working/archive tabs. ID keeps the existing
`localeCompare` path with `numeric: true`. Table recognizes
`DD.MM.YYYY HH:mm` cell values and compares their timestamps chronologically;
other values retain the existing numeric/string fallbacks. Creation date starts
descending. This behavior adds no public input.

In contained-scroll and paginated layouts, `.wbl-table__scroll-frame` uses flex,
`overflow: visible`, and no inline-end padding. The viewport keeps
`inline-size: 100%`, `overflow: auto`, and `scrollbar-gutter: stable`. The
native vertical scrollbar is reserved inside the rail-bounded viewport, so its
outer inline edges still match the Table, Filter Row, and any Action Bar.
Vertical and horizontal scrolling remain on the same accessible viewport. The
public API is unchanged.

## M-002 fixed columns and stable state geometry

Configured leading and trailing columns remain visually stationary while the
middle data region scrolls horizontally. Table calculates each `fixedOffset`
from the widths of preceding fixed columns and anchors the group with native
inline-start/inline-end insets. It does not subscribe to `scrollLeft` or drive
transforms/signals. Fixed surfaces retain opaque backgrounds and one boundary
separator without covering scrolling cells. Browser verification at
`scrollLeft` 0 and 1200 keeps the left columns at x=92–228 and x=228–452 and the
right column at x=1177–1249. This behavior uses the existing `fixedColumn`
contract and adds no public API.

`.wbl-table__inner`, the header grid and row grids retain their actual
`max-content` width. A feature-level minimum-width override is not required;
Transport Requests removed its former 1316px override. Its screen-owned
Preliminary class sets Table inner, header and rows to
`inline-size`/`min-inline-size: 100%`; with a `minmax(376px, 1fr)` route and 68px
action column, the table fills the 1156px rail without an empty area or
unnecessary horizontal overflow. Extended tabs retain their real `max-content`
width.

Changing tabs, roles, screen state or row-action availability must preserve the
Table viewport size, fixed-column positions, column widths and action-column
footprint. In `filtered-empty`, the viewport uses hidden overflow, renders no
scrollbar or divider, and keeps its title/caption. Per-tab loading, empty, error
and filtered-empty selection remains a screen responsibility; Table does not
infer authorization or role state.
