diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index ea9b3486..3c6b9fb5 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -193,6 +193,52 @@ const [date, setDate] = useState(); /> ``` +### Table +TanStack Table v8 wrapper. Sub-components: `Table.HeaderButton`. Sortable / filterable / selectable / pinnable / expandable. Built-in pagination announces page changes via `aria-live` so JAWS reports state changes automatically. + +```tsx + id="people" data={rows} columns={columns} pagination={pagination} /> +``` + +**Props (selection):** `id`, `data`, `columns` (TanStack `ColumnDef[]`), `pagination`, `sorting`, `rowSelection`, `columnPinning`, `expandedRows`, `activeRowId`, `rowHover`, `verticalBorders`, `striped`, `size: 'default' | 'small'`, `caption`, `placeholder`, `placeholderRole`. + +#### Accessibility — required for column headers with non-text content + +- **`Table.HeaderButton` requires `aria-label`** (TS-enforced). Always include the column name in the label — JAWS otherwise reads only "Sorteeri kasvavalt, button" with no indication of *what* you're sorting: + ```tsx + + + ``` +- **For columns with a function `header` (custom JSX containing sort / filter buttons, info icons, etc.), set `meta.label`**. The Table puts `aria-label={meta.label}` on the `` so screen readers use the clean column name as the column header announcement for every cell. Without it, JAWS reads the full visible header text — including the button labels — for *every* data cell: + ```tsx + { + id: 'teenus', + accessorKey: 'teenus', + header: ({ column }) => ( + + Teenus + + + ), + meta: { label: 'Teenus' }, // ← required when `header` is a function + } + ``` + String headers (`header: 'Teenus'`) don't need `meta.label` — the string is used automatically. +- **Filter popovers with validation must use `TextField`'s `invalid` + `helper` props**, not a custom red-bordered div. The Table doesn't ship built-in filter validation today, but if you add min-length / format checks, the only WCAG 3.3.1-compliant path is to wire the error through `TextField`. `invalid` sets `aria-invalid`; `helper` with `type: 'error'` renders the message via `` and auto-wires it into the input's `aria-describedby`. A red border + red helper text alone (the Angular bug) fails error identification because screen readers can't see colour: + ```tsx + + ``` + The labels `table.filter.validation.min-length` / `table.filter.validation.no-spaces` already exist in `labels-map.ts` — use them as-is for parity with Angular. **Max length / pattern / any other validation rule** belongs on `TextField` directly — pass `maxLength={40}` and let the native HTML attribute enforce it, plus mirror the rule in `invalid` + `helper` if you want a visible error before submit. Don't invent a Table-level `validation: { minLength, maxLength }` config — the primitives already cover it. +- **For "no results after filter" announcements, set `placeholderRole="status"` on the Table.** The Table wraps the empty-state placeholder in `
` (an ARIA live region), so screen readers announce the empty state when a filter empties the rows. `'status'` is polite (recommended); `'alert'` is assertive (interrupts the current SR utterance). Leave the prop undefined for tables that are empty on first mount and never change — otherwise the live region announces on every render. + ```tsx + + ``` + ## Form ### TextField diff --git a/src/community/components/table/table.stories.tsx b/src/community/components/table/table.stories.tsx index 47a6c2fe..3cb25a3a 100644 --- a/src/community/components/table/table.stories.tsx +++ b/src/community/components/table/table.stories.tsx @@ -40,6 +40,11 @@ const meta: Meta = { control: false, }, }, + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/tedi/components/buttons/collapse/collapse.spec.tsx b/src/tedi/components/buttons/collapse/collapse.spec.tsx index 0be3b34f..1fa4d3d1 100644 --- a/src/tedi/components/buttons/collapse/collapse.spec.tsx +++ b/src/tedi/components/buttons/collapse/collapse.spec.tsx @@ -150,4 +150,34 @@ describe('Collapse component with breakpoint support', () => { expect(screen.getByRole('button', { name: 'Toggle section' })).toBeInTheDocument(); }); + + it('toggles open state when Enter or Space is pressed on the trigger', () => { + const onToggle = jest.fn(); + const { getByRole } = getComponent({ id: 'collapse-keyboard', onToggle }); + const button = getByRole('button', { name: /näita rohkem/i }); + + fireEvent.keyDown(button, { key: 'Enter' }); + expect(onToggle).toHaveBeenCalledWith(true); + + fireEvent.keyDown(button, { key: ' ' }); + expect(onToggle).toHaveBeenCalledWith(false); + expect(onToggle).toHaveBeenCalledTimes(2); + }); + + it('ignores repeated key events (e.repeat is true)', () => { + const onToggle = jest.fn(); + const { getByRole } = getComponent({ id: 'collapse-repeat', onToggle }); + const button = getByRole('button', { name: /näita rohkem/i }); + fireEvent.keyDown(button, { key: 'Enter', repeat: true }); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it('ignores keys other than Enter / Space', () => { + const onToggle = jest.fn(); + const { getByRole } = getComponent({ id: 'collapse-other-key', onToggle }); + const button = getByRole('button', { name: /näita rohkem/i }); + fireEvent.keyDown(button, { key: 'a' }); + fireEvent.keyDown(button, { key: 'Tab' }); + expect(onToggle).not.toHaveBeenCalled(); + }); }); diff --git a/src/tedi/components/buttons/collapse/collapse.tsx b/src/tedi/components/buttons/collapse/collapse.tsx index 276fb205..b44c2713 100644 --- a/src/tedi/components/buttons/collapse/collapse.tsx +++ b/src/tedi/components/buttons/collapse/collapse.tsx @@ -94,6 +94,21 @@ export interface CollapseProps extends BreakpointSupport`). The consumer is responsible for rendering the target + * element with the matching `id` and an appropriate `role` (typically + * `region`). + * + * When omitted (default), Collapse renders its own `children` inside a + * built-in `role="region"` panel. + */ + controlsId?: string; } export const Collapse = (props: CollapseProps): JSX.Element => { @@ -116,9 +131,12 @@ export const Collapse = (props: CollapseProps): JSX.Element => { underline = true, toggleLabel, iconOnly = false, + controlsId, ...rest } = getCurrentBreakpointProps(props); + const isExternallyControlled = controlsId !== undefined; + const triggerId = `${id}__trigger`; const contentId = `${id}__content`; const animateId = `${id}__animate`; @@ -175,7 +193,7 @@ export const Collapse = (props: CollapseProps): JSX.Element => { className={styles['tedi-collapse__title']} aria-label={accessibleName} aria-expanded={isOpen} - aria-controls={contentId} + aria-controls={isExternallyControlled ? controlsId : contentId} onKeyDown={handleKeyDown} onClick={handleClick} > @@ -219,13 +237,17 @@ export const Collapse = (props: CollapseProps): JSX.Element => { - {isPrint ? ( - renderContent - ) : ( - - {renderContent} - - )} + {/* In externally-controlled mode the disclosed region lives outside + Collapse's DOM — render nothing here so we don't emit an orphan + `role="region"` panel pointing back at the same trigger. */} + {!isExternallyControlled && + (isPrint ? ( + renderContent + ) : ( + + {renderContent} + + ))} ); }; diff --git a/src/tedi/components/content/table/index.ts b/src/tedi/components/content/table/index.ts new file mode 100644 index 00000000..217d047c --- /dev/null +++ b/src/tedi/components/content/table/index.ts @@ -0,0 +1,6 @@ +export * from './table'; +export * from './table-context'; +export * from './use-table-persistence'; +export * from './table-columns-menu/table-columns-menu'; +export * from './table-header-button/table-header-button'; +export * from './table-toolbar/table-toolbar'; diff --git a/src/tedi/components/content/table/table-columns-menu/table-columns-menu.tsx b/src/tedi/components/content/table/table-columns-menu/table-columns-menu.tsx new file mode 100644 index 00000000..ff5868e7 --- /dev/null +++ b/src/tedi/components/content/table/table-columns-menu/table-columns-menu.tsx @@ -0,0 +1,70 @@ +import { useLabels } from '../../../../providers/label-provider'; +import { Button } from '../../../buttons/button/button'; +import { Checkbox } from '../../../form/checkbox/checkbox'; +import { Dropdown } from '../../../overlays/dropdown/dropdown'; +import { DropdownContent } from '../../../overlays/dropdown/dropdown-content/dropdown-content'; +import { DropdownItem } from '../../../overlays/dropdown/dropdown-item/dropdown-item'; +import { DropdownTrigger } from '../../../overlays/dropdown/dropdown-trigger/dropdown-trigger'; +import { useTableContext } from '../table-context'; + +export interface TableColumnsMenuProps { + /** + * Trigger label. Falls back to the localised `table.columns` label from + * `LabelProvider` when not provided. + */ + triggerLabel?: React.ReactNode; + /** + * Additional class name on the dropdown trigger button. + */ + className?: string; +} + +export const TableColumnsMenu = ({ triggerLabel, className }: TableColumnsMenuProps) => { + const { table, id } = useTableContext(); + const { getLabel } = useLabels(); + const resolvedTriggerLabel = triggerLabel ?? getLabel('table.columns'); + + const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide()); + const visibleCount = hideableColumns.filter((column) => column.getIsVisible()).length; + + const resolveHeader = (column: (typeof hideableColumns)[number]) => { + const header = column.columnDef.header; + return typeof header === 'string' ? header : column.id; + }; + + return ( + + + + + + {hideableColumns.map((column) => { + const isVisible = column.getIsVisible(); + const isLastVisible = isVisible && visibleCount === 1; + const checkboxId = `${id}-columns-menu-${column.id}`; + const headerLabel = resolveHeader(column); + + return ( + +
e.stopPropagation()}> + column.toggleVisibility()} + /> +
+
+ ); + })} +
+
+ ); +}; + +TableColumnsMenu.displayName = 'Table.ColumnsMenu'; diff --git a/src/tedi/components/content/table/table-context.tsx b/src/tedi/components/content/table/table-context.tsx new file mode 100644 index 00000000..866a4c5d --- /dev/null +++ b/src/tedi/components/content/table/table-context.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react'; + +import type { TableContextValue } from './table'; + +export const TableContext = createContext(null); + +export function useTableContext(): TableContextValue { + const ctx = useContext(TableContext); + if (!ctx) throw new Error('TableContext missing — wrap the component in
.'); + return ctx as TableContextValue; +} diff --git a/src/tedi/components/content/table/table-header-button/table-header-button.module.scss b/src/tedi/components/content/table/table-header-button/table-header-button.module.scss new file mode 100644 index 00000000..82e04fe0 --- /dev/null +++ b/src/tedi/components/content/table/table-header-button/table-header-button.module.scss @@ -0,0 +1,37 @@ +.tedi-table-header-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + color: var(--general-text-tertiary); + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--button-radius-sm); + transition: background-color 120ms ease, color 120ms ease, outline-color 120ms ease; + + &:hover:not(:disabled) { + background: var(--button-main-neutral-icon-only-background-hover); + } + + &:active:not(:disabled) { + color: var(--button-main-primary-background-default); + background: var(--button-main-neutral-icon-only-background-active); + } + + &:focus-visible { + color: var(--button-main-primary-background-default); + outline: var(--tedi-borders-02) solid var(--general-border-focus); + outline-offset: 0; + } + + &--selected { + color: var(--button-main-primary-background-default); + } + + &:disabled { + color: var(--general-text-disabled); + cursor: not-allowed; + background: transparent; + } +} diff --git a/src/tedi/components/content/table/table-header-button/table-header-button.spec.tsx b/src/tedi/components/content/table/table-header-button/table-header-button.spec.tsx new file mode 100644 index 00000000..a9eda2a6 --- /dev/null +++ b/src/tedi/components/content/table/table-header-button/table-header-button.spec.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; + +import { TableHeaderButton } from './table-header-button'; + +import '@testing-library/jest-dom'; + +describe('TableHeaderButton', () => { + it('renders an icon-only button with the required aria-label', () => { + render(); + expect(screen.getByRole('button', { name: 'Sort by name' })).toBeInTheDocument(); + }); + + it('applies the selected modifier class when selected is true', () => { + const { container } = render(); + expect(container.querySelector('button')?.className).toMatch(/--selected/); + }); + + it('fires onClick when the button is clicked', () => { + const handleClick = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('forwards arbitrary button attributes (aria-expanded, aria-controls)', () => { + render( + + ); + const button = screen.getByRole('button', { name: 'Open filter' }); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(button).toHaveAttribute('aria-controls', 'filter-panel'); + }); + + it('renders the disabled state', () => { + render(); + expect(screen.getByRole('button', { name: 'Disabled' })).toBeDisabled(); + }); + + it('forwards a ref to the underlying button', () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it('defaults to type="button" but honours an override', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'Default' })).toHaveAttribute('type', 'button'); + + rerender(); + expect(screen.getByRole('button', { name: 'Submit' })).toHaveAttribute('type', 'submit'); + }); + + it('merges custom className with the component class', () => { + const { container } = render(); + const button = container.querySelector('button'); + expect(button).toHaveClass('custom-class'); + expect(button?.className).toMatch(/tedi-table-header-button/); + }); +}); diff --git a/src/tedi/components/content/table/table-header-button/table-header-button.tsx b/src/tedi/components/content/table/table-header-button/table-header-button.tsx new file mode 100644 index 00000000..af06a198 --- /dev/null +++ b/src/tedi/components/content/table/table-header-button/table-header-button.tsx @@ -0,0 +1,70 @@ +import cn from 'classnames'; +import React, { forwardRef } from 'react'; + +import { Icon, IconSize } from '../../../base/icon/icon'; +import styles from './table-header-button.module.scss'; + +export interface TableHeaderButtonProps + extends Omit, 'children' | 'aria-label'> { + /** + * Material icon name rendered inside the button (e.g. `unfold_more`, + * `arrow_downward`, `filter_alt`). + */ + icon: string; + /** + * Render the icon's "filled" variant. Pair with `selected` for a fully + * activated look (e.g. an applied filter). + * @default false + */ + filled?: boolean; + /** + * When `true`, the icon paints in the brand colour to indicate an active + * sort or filter at rest. Hover / focus / active states are still applied + * on top. + * @default false + */ + selected?: boolean; + /** + * Required accessible name — these are icon-only buttons, so screen readers + * have nothing else to announce. + */ + 'aria-label': string; + /** Size of the icon, in pixels. @default 18 */ + iconSize?: IconSize; +} + +/** + * Compact icon-only button intended for table header cells — sort toggles, + * filter triggers, and similar inline header actions. Matches the Figma + * "Filter and sort buttons" frame: transparent at rest, light-tint on + * hover / active, brand colour when `selected` or focused, focus ring on + * keyboard focus. + * + * `forwardRef` is wired through so the component can be used directly as a + * `Popover.Trigger` child or referenced for imperative focus management. + */ +export const TableHeaderButton = forwardRef( + ( + { icon, filled = false, selected = false, disabled, onClick, className, iconSize = 18, type = 'button', ...rest }, + ref + ) => ( + + ) +); + +TableHeaderButton.displayName = 'TableHeaderButton'; + +export default TableHeaderButton; diff --git a/src/tedi/components/content/table/table-toolbar/table-toolbar.tsx b/src/tedi/components/content/table/table-toolbar/table-toolbar.tsx new file mode 100644 index 00000000..c6b7304a --- /dev/null +++ b/src/tedi/components/content/table/table-toolbar/table-toolbar.tsx @@ -0,0 +1,28 @@ +import cn from 'classnames'; +import React from 'react'; + +import styles from '../table.module.scss'; + +export interface TableToolbarProps { + /** + * Toolbar contents — typically Table sub-components like + * ``, but any node is allowed. + */ + children?: React.ReactNode; + /** + * Additional class name on the toolbar wrapper. + */ + className?: string; +} + +/** + * Optional slot rendered above the `
`. Hosts controls like + * ``. Nothing clever — it just provides consistent spacing. + */ +export const TableToolbar = ({ children, className }: TableToolbarProps) => ( +
{children}
+); + +TableToolbar.displayName = 'Table.Toolbar'; + +export default TableToolbar; diff --git a/src/tedi/components/content/table/table.module.scss b/src/tedi/components/content/table/table.module.scss new file mode 100644 index 00000000..e85c77dc --- /dev/null +++ b/src/tedi/components/content/table/table.module.scss @@ -0,0 +1,253 @@ +.tedi-table { + display: flex; + flex-direction: column; + gap: var(--tedi-dimensions-10); + width: 100%; +} + +.tedi-table__toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--tedi-dimensions-8); + align-items: center; + justify-content: flex-end; +} + +.tedi-table__scroll { + overflow-x: auto; + background: var(--table-default); + border: var(--tedi-borders-01) solid var(--table-border); + border-radius: var(--table-radius); +} + +.tedi-table__table { + width: 100%; + font-size: var(--body-regular-size); + line-height: var(--body-regular-line-height); + color: var(--general-text-primary); + border-spacing: 0; + border-collapse: collapse; + background: var(--table-default); +} + +.tedi-table__caption { + padding: var(--tedi-dimensions-10) var(--table-header-padding-x); + font-weight: var(--body-regular-weight); + color: var(--general-text-primary); + text-align: left; + caption-side: top; +} + +.tedi-table__head { + background: var(--table-default); + border-bottom: 1px solid var(--table-border-th); +} + +.tedi-table__header-cell { + padding: var(--table-header-padding-y) var(--table-header-padding-x); + font-size: var(--body-regular-size); + font-weight: var(--body-regular-weight); + color: var(--general-text-tertiary); + text-align: left; + white-space: nowrap; + background: var(--table-default); +} + +.tedi-table__row { + border-bottom: var(--tedi-borders-01) solid var(--table-border); + + &:last-child { + border-bottom: 0; + } +} + +.tedi-table__cell { + padding: var(--table-data-padding-y) var(--table-data-padding-x); + color: var(--general-text-primary); + vertical-align: middle; +} + +// Column-level alignment modifiers driven by `column.meta.align` / `meta.vAlign`. Applied +// uniformly to header / body / footer cells so a numeric column lines up at the right edge +// across all three rows without per-render wrapper spans. +.tedi-table__cell--align-left { + text-align: left; +} + +.tedi-table__cell--align-center { + text-align: center; +} + +.tedi-table__cell--align-right { + text-align: right; +} + +.tedi-table__cell--valign-top { + vertical-align: top; +} + +.tedi-table__cell--valign-middle { + vertical-align: middle; +} + +.tedi-table__cell--valign-bottom { + vertical-align: bottom; +} + +.tedi-table__cell--placeholder { + padding: var(--tedi-dimensions-14) var(--table-data-padding-x); + color: var(--general-text-secondary); + text-align: center; +} + +.tedi-table--small { + .tedi-table__header-cell { + padding: var(--table-header-padding-y-sm) var(--table-header-padding-x-sm); + } + + .tedi-table__cell { + padding: var(--table-data-padding-y-sm) var(--table-data-padding-x-sm); + } +} + +.tedi-table__foot { + font-weight: var(--heading-weight); + background: var(--table-default); + border-top: var(--tedi-borders-01) solid var(--table-border-th); +} + +.tedi-table__cell--footer { + color: var(--general-text-primary); +} + +.tedi-table__row--selected { + background: var(--table-active); +} + +.tedi-table__row--clickable { + cursor: pointer; + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--general-border-focus); + outline-offset: calc(var(--tedi-borders-02) * -1); + } +} + +.tedi-table__row--sub-component { + background: var(--table-striped); +} + +.tedi-table__row--sub-row { + background: var(--table-striped); +} + +.tedi-table__cell--sub-component { + padding: var(--table-data-padding-y) var(--table-data-padding-x); +} + +.tedi-table__row--filter { + background: var(--general-surface-primary); +} + +.tedi-table__row--filter .tedi-table__header-cell { + padding-top: var(--tedi-dimensions-8); + padding-bottom: var(--tedi-dimensions-8); + font-weight: var(--body-regular-weight); + background: var(--general-surface-primary); +} + +.tedi-table--striped .tedi-table__body .tedi-table__row:nth-of-type(even) { + background: var(--table-striped); +} + +.tedi-table.tedi-table--row-hover .tedi-table__body .tedi-table__row:hover { + background: var(--table-hover); +} + +.tedi-table .tedi-table__body .tedi-table__row.tedi-table__row--active, +.tedi-table .tedi-table__body .tedi-table__row.tedi-table__row--active:hover { + background: var(--table-hover); +} + +.tedi-table--vertical-borders { + .tedi-table__header-cell, + .tedi-table__cell { + border-right: var(--tedi-borders-01) solid var(--table-border); + } + + thead tr:first-child .tedi-table__header-cell:last-child, + .tedi-table__row > .tedi-table__cell:last-child { + border-right: 0; + } +} + +.tedi-table--borderless { + .tedi-table__scroll { + background: transparent; + border: 0; + border-radius: 0; + } +} + +.tedi-table--has-pagination { + gap: 0; + + .tedi-table__scroll { + border-bottom: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} + +.tedi-table__pagination { + border: var(--tedi-borders-01) solid var(--table-border); + border-top: 0; + border-bottom-right-radius: var(--table-radius); + border-bottom-left-radius: var(--table-radius); +} + +.tedi-table--borderless .tedi-table__pagination { + background: transparent; + border: 0; +} + +.tedi-table--sticky-first-column { + .tedi-table__row > .tedi-table__header-cell:first-child { + position: sticky; + left: 0; + z-index: 2; + background: var(--table-default); + box-shadow: inset -1px 0 0 var(--table-border); + } + + .tedi-table__row > .tedi-table__cell:first-child { + position: sticky; + left: 0; + z-index: 1; + background: var(--table-default); + box-shadow: inset -1px 0 0 var(--table-border); + } + + &.tedi-table--striped .tedi-table__body .tedi-table__row:nth-of-type(even) > .tedi-table__cell:first-child { + background: var(--table-striped); + } +} + +.tedi-table--sticky-header { + .tedi-table__head { + border-bottom: 0; + } + + .tedi-table__head .tedi-table__header-cell { + position: sticky; + top: 0; + z-index: 2; + background: var(--table-default); + box-shadow: inset 0 -1px 0 var(--table-border-th); + } + + &.tedi-table--sticky-first-column .tedi-table__head .tedi-table__header-cell:first-child { + z-index: 3; + box-shadow: inset -1px 0 0 var(--table-border), inset 0 -1px 0 var(--table-border-th); + } +} diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx new file mode 100644 index 00000000..da9eb6e2 --- /dev/null +++ b/src/tedi/components/content/table/table.spec.tsx @@ -0,0 +1,771 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useEffect, useState } from 'react'; + +import type { TableState } from './table'; +import { Table } from './table'; +import { useTableContext } from './table-context'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../providers/printing-provider/printing-provider', () => ({ + PrintingProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + usePrint: jest.fn().mockReturnValue(false), +})); + +// `Pagination` (rendered inside Table) renders different chrome below `md` +// (page-jump Select) than at `md`+ (numbered list). The hook is mocked to +// 'lg' so the existing assertions on the numbered list keep working; +// breakpoint-specific behaviour is covered by Pagination's own tests. +jest.mock('../../../helpers', () => ({ + useBreakpoint: () => 'lg', + isBreakpointBelow: () => false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useBreakpointProps: () => ({ getCurrentBreakpointProps: (props: any) => ({ ...props }) }), +})); + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string, ...args: unknown[]) => { + switch (key) { + case 'pagination.title': + return 'Pagination'; + case 'pagination.prev-page': + return 'Previous page'; + case 'pagination.next-page': + return 'Next page'; + case 'pagination.page': { + const [page, isCurrent] = args as [number, boolean]; + return isCurrent ? `Current page, page ${page}` : `Go to page ${page}`; + } + case 'pagination.results': { + const [count] = args as [number]; + return `${count} ${count === 1 ? 'result' : 'results'}`; + } + case 'pagination.page-size': + return 'Page size'; + case 'table.filter-input': { + const [columnLabel] = args as [string | undefined]; + return `Filter ${columnLabel ?? 'column'}`.trim(); + } + case 'table.row-details': + return 'Row details'; + default: + return key; + } + }, + }), +})); + +interface Person { + id: string; + name: string; + role: string; +} + +const data: Person[] = [ + { id: '1', name: 'Anna', role: 'Engineer' }, + { id: '2', name: 'Jüri', role: 'Designer' }, +]; + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, +]; + +describe('Table', () => { + it('renders the column headers and row data', () => { + render( id="t" data={data} columns={columns} />); + + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Anna' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Designer' })).toBeInTheDocument(); + }); + + it('scopes the th accessible name to the column label even when the header renders extra controls', () => { + const customColumns: ColumnDef[] = [ + { + id: 'name', + accessorKey: 'name', + meta: { label: 'Name' }, + header: () => ( + + Name + + + ), + }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + ]; + + render( id="t-aname" data={data} columns={customColumns} />); + + // The th's accessible name must be just "Name" — not "Name Sort by Name" — + // so JAWS doesn't read the sort button label when announcing body cells. + const nameHeader = screen.getByRole('columnheader', { name: 'Name' }); + expect(nameHeader).toHaveAttribute('aria-label', 'Name'); + }); + + it('renders the placeholder when data is empty', () => { + render( id="t-empty" data={[]} columns={columns} placeholder="Nothing here" />); + + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + }); + + it('wraps the placeholder in a live region when placeholderRole is set', () => { + const { rerender } = render( + + id="t-empty-status" + data={[]} + columns={columns} + placeholder="No results" + placeholderRole="status" + /> + ); + + expect(screen.getByRole('status')).toHaveTextContent('No results'); + + rerender( + id="t-empty-status" data={[]} columns={columns} placeholder="No results" placeholderRole="alert" /> + ); + + expect(screen.getByRole('alert')).toHaveTextContent('No results'); + }); + + it('omits the live-region wrapper when placeholderRole is not set', () => { + render( id="t-empty-plain" data={[]} columns={columns} placeholder="Nothing here" />); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('applies the small size class when size="small"', () => { + const { container } = render( id="t-sm" data={data} columns={columns} size="small" />); + const root = container.querySelector('[data-name="tedi-table"]'); + expect(root?.className).toMatch(/--small/); + }); + + it('renders a caption when provided', () => { + render( id="t-cap" data={data} columns={columns} caption="All people" />); + expect(screen.getByText('All people').tagName).toBe('CAPTION'); + }); + + it('hides a column via defaultState.columnVisibility', () => { + render( + id="t-hidden" data={data} columns={columns} defaultState={{ columnVisibility: { role: false } }} /> + ); + + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + }); + + describe('ColumnsMenu', () => { + it('toggles column visibility through the menu', () => { + render( + id="t-menu" data={data} columns={columns}> + + + +
+ ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + const roleCheckbox = screen.getByRole('checkbox', { name: 'Role' }) as HTMLInputElement; + + expect(roleCheckbox.checked).toBe(true); + + fireEvent.click(roleCheckbox); + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + expect(roleCheckbox.checked).toBe(false); + + fireEvent.click(roleCheckbox); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + expect(roleCheckbox.checked).toBe(true); + }); + + it('prevents hiding the last visible column', () => { + render( + + id="t-menu-last" + data={data} + columns={columns} + defaultState={{ columnVisibility: { role: false } }} + > + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + expect(screen.getByRole('checkbox', { name: 'Name' })).toBeDisabled(); + }); + + it('accepts a custom trigger label', () => { + render( + id="t-menu-label" data={data} columns={columns}> + + + + + ); + + expect(screen.getByRole('button', { name: /Manage columns/i })).toBeInTheDocument(); + }); + + it('throws when rendered outside of ', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => render()).toThrow('TableContext missing'); + spy.mockRestore(); + }); + }); + + describe('state integration', () => { + it('reports state changes through onStateChange', () => { + const onStateChange = jest.fn(); + render( + id="t-ctrl" data={data} columns={columns} onStateChange={onStateChange}> + + + +
+ ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Role' })); + + expect(onStateChange).toHaveBeenCalled(); + // The column ends up hidden; assert the last-reported state reflects that. + expect(onStateChange.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ columnVisibility: { role: false } }) + ); + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + }); + + it('respects fully controlled state', () => { + const Wrapper = () => { + const [state, setState] = useState({ columnVisibility: { role: false } }); + return ( + <> + + + id="t-fully-controlled" + data={data} + columns={columns} + state={state} + onStateChange={setState} + /> + + ); + }; + + render(); + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'reveal-role' })); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + }); + + it('reports the new pageIndex through onStateChange when pagination is controlled (server-side mode)', () => { + const ServerSideWrapper = () => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 1 }); + return ( + + id="t-server-side" + data={data.slice(pagination.pageIndex, pagination.pageIndex + pagination.pageSize)} + columns={columns} + manualPagination + pageCount={data.length} + rowCount={data.length} + state={{ pagination }} + onStateChange={(next) => { + if (next.pagination) setPagination(next.pagination); + }} + pagination={{ pageSize: 1, pageSizeOptions: [1, 2] }} + /> + ); + }; + + render(); + expect(screen.getByRole('cell', { name: 'Anna' })).toBeInTheDocument(); + expect(screen.queryByRole('cell', { name: 'Jüri' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Go to page 2/i })); + + expect(screen.queryByRole('cell', { name: 'Anna' })).not.toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Jüri' })).toBeInTheDocument(); + }); + }); + + describe('persistence', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('hydrates state from localStorage on mount', () => { + window.localStorage.setItem('persist-hydrate', JSON.stringify({ columnVisibility: { role: false } })); + + render( id="t-hyd" data={data} columns={columns} persist={{ key: 'persist-hydrate' }} />); + + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + }); + + it('writes state changes back to localStorage', () => { + render( + id="t-write" data={data} columns={columns} persist={{ key: 'persist-write' }}> + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Role' })); + + const stored = window.localStorage.getItem('persist-write'); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored as string)).toEqual({ columnVisibility: { role: false } }); + }); + + it('ignores non-included keys when include is provided', () => { + render( + + id="t-incl" + data={data} + columns={columns} + persist={{ key: 'persist-include', include: ['columnOrder'] }} + > + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Role' })); + + const stored = window.localStorage.getItem('persist-include'); + expect(JSON.parse(stored as string)).toEqual({}); + }); + + it('falls back gracefully when stored JSON is corrupt', () => { + window.localStorage.setItem('persist-corrupt', '{not json'); + expect(() => + render( id="t-corr" data={data} columns={columns} persist={{ key: 'persist-corrupt' }} />) + ).not.toThrow(); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + }); + + it('falls back gracefully when window.localStorage access throws', () => { + const original = Object.getOwnPropertyDescriptor(window, 'localStorage'); + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new Error('blocked'); + }, + }); + + try { + expect(() => + render( id="t-block" data={data} columns={columns} persist={{ key: 'persist-block' }} />) + ).not.toThrow(); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + } finally { + if (original) { + Object.defineProperty(window, 'localStorage', original); + } else { + // jsdom may define localStorage on the prototype rather than as an + // own property — in that case `getOwnPropertyDescriptor` returns + // `undefined` and we must delete our shadowing throwing getter so + // the prototype's working implementation is exposed again. + delete (window as { localStorage?: unknown }).localStorage; + } + } + }); + + it('uses the supplied storage option when provided', () => { + const fakeStorage: Storage = { + length: 0, + clear: jest.fn(), + key: jest.fn(), + getItem: jest.fn().mockReturnValue(JSON.stringify({ columnVisibility: { role: false } })), + removeItem: jest.fn(), + setItem: jest.fn(), + }; + render( + + id="t-custom-storage" + data={data} + columns={columns} + persist={{ key: 'persist-custom', storage: fakeStorage }} + /> + ); + expect(fakeStorage.getItem).toHaveBeenCalledWith('persist-custom'); + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + }); + }); + + describe('variant classes', () => { + const flags = [ + ['striped', '--striped'], + ['verticalBorders', '--vertical-borders'], + ['borderless', '--borderless'], + ['stickyFirstColumn', '--sticky-first-column'], + ['stickyHeader', '--sticky-header'], + ] as const; + + it.each(flags)('applies the %s class when the matching prop is true', (prop, fragment) => { + const { container } = render( + id={`t-${prop}`} data={data} columns={columns} {...{ [prop]: true }} /> + ); + expect(container.querySelector('[data-name="tedi-table"]')?.className).toContain(fragment); + }); + }); + + describe('clickable rows', () => { + it('fires onRowClick and marks the row role=button + tabIndex=0', () => { + const onRowClick = jest.fn(); + render( id="t-click" data={data} columns={columns} onRowClick={onRowClick} />); + + const rows = screen.getAllByRole('button'); + expect(rows).toHaveLength(data.length); + expect(rows[0]).toHaveAttribute('tabIndex', '0'); + + fireEvent.click(rows[0]); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick.mock.calls[0][0].original).toEqual(data[0]); + }); + + it('activates on Enter/Space keydown', () => { + const onRowClick = jest.fn(); + render( id="t-click-kb" data={data} columns={columns} onRowClick={onRowClick} />); + + const firstRow = screen.getAllByRole('button')[0]; + fireEvent.keyDown(firstRow, { key: 'Enter' }); + fireEvent.keyDown(firstRow, { key: ' ' }); + fireEvent.keyDown(firstRow, { key: 'Escape' }); + + expect(onRowClick).toHaveBeenCalledTimes(2); + }); + }); + + describe('row selection', () => { + it('auto-injects a select column and toggles row selection', () => { + render( id="t-sel" data={data} columns={columns} enableRowSelection />); + + const checkboxes = screen.getAllByRole('checkbox'); + // 1 header + data.length rows + expect(checkboxes).toHaveLength(data.length + 1); + + fireEvent.click(checkboxes[1]); + expect(checkboxes[1]).toBeChecked(); + }); + + it('select-all toggles every row', () => { + render( id="t-sel-all" data={data} columns={columns} enableRowSelection />); + + const [selectAll, ...rowBoxes] = screen.getAllByRole('checkbox'); + fireEvent.click(selectAll); + + rowBoxes.forEach((box) => expect(box).toBeChecked()); + }); + }); + + describe('expansion', () => { + it('renders the expand column + sub-component when renderSubComponent is provided', () => { + render( + + id="t-exp" + data={data} + columns={columns} + renderSubComponent={(row) => details for {row.original.name}} + /> + ); + + // Without a `LabelProvider` wrapping the render, `getLabel` returns the + // i18n key verbatim, which is what the accessible name resolves to. + const toggle = screen.getAllByRole('button', { name: /table\.expand-row/i })[0]; + fireEvent.click(toggle); + + expect(screen.getByText(/details for Anna/)).toBeInTheDocument(); + + const collapse = screen.getByRole('button', { name: /table\.collapse-row/i }); + fireEvent.click(collapse); + expect(screen.queryByText(/details for Anna/)).not.toBeInTheDocument(); + }); + }); + + describe('column filters', () => { + it('filters rows based on the per-column filter input', () => { + render( id="t-filter" data={data} columns={columns} enableColumnFilters />); + + expect(screen.getByRole('cell', { name: 'Anna' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Jüri' })).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Filter Name'), { target: { value: 'Anna' } }); + + expect(screen.getByRole('cell', { name: 'Anna' })).toBeInTheDocument(); + expect(screen.queryByRole('cell', { name: 'Jüri' })).not.toBeInTheDocument(); + }); + }); + + describe('footer', () => { + it('renders a tfoot when any column defines footer', () => { + const withFooter: typeof columns = [ + { id: 'name', header: 'Name', accessorKey: 'name', footer: 'Total' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + ]; + render( id="t-foot" data={data} columns={withFooter} />); + + const foot = document.querySelector('tfoot'); + expect(foot).toBeInTheDocument(); + expect(foot).toHaveTextContent('Total'); + }); + + it('omits the tfoot entirely when no column defines footer', () => { + render( id="t-nofoot" data={data} columns={columns} />); + expect(document.querySelector('tfoot')).not.toBeInTheDocument(); + }); + }); + + describe('pagination', () => { + const many: Person[] = Array.from({ length: 7 }, (_, index) => ({ + id: String(index + 1), + name: `Person ${index + 1}`, + role: 'Tester', + })); + + it('renders the pagination bar with prev/next buttons and current page marker', () => { + render( id="t-page" data={many} columns={columns} pagination={{ pageSize: 3 }} />); + + expect(screen.getByRole('navigation', { name: /Pagination/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Current page, page 1/i })).toHaveAttribute('aria-current', 'page'); + // Previous arrow stays in the DOM but is `disabled` on the first page so the strip + // doesn't jump when the user navigates back to / away from the first page. + expect(screen.getByRole('button', { name: /Previous page/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeEnabled(); + expect(screen.getByText('7 results')).toBeInTheDocument(); + }); + + it('navigates forward + backward between pages', () => { + render( id="t-page-nav" data={many} columns={columns} pagination={{ pageSize: 3 }} />); + + expect(screen.getByRole('cell', { name: 'Person 1' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Next page/i })); + expect(screen.getByRole('button', { name: /Current page, page 2/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Person 4' })).toBeInTheDocument(); + expect(screen.queryByRole('cell', { name: 'Person 1' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Previous page/i })); + expect(screen.getByRole('button', { name: /Current page, page 1/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Person 1' })).toBeInTheDocument(); + }); + + it('changes page size via the selector and keeps the new page in the viewport', async () => { + render( + + id="t-page-size" + data={many} + columns={columns} + pagination={{ pageSize: 3, pageSizeOptions: [3, 5] }} + /> + ); + + const combobox = screen.getByRole('combobox', { name: /Page size/i }); + + await act(async () => { + combobox.focus(); + fireEvent.keyDown(combobox, { key: 'ArrowDown', code: 'ArrowDown' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + fireEvent.click(screen.getByText('5')); + + expect(screen.getByRole('cell', { name: 'Person 5' })).toBeInTheDocument(); + }); + + it('hides the page-size selector when pageSizeOptions is false', () => { + const { container } = render( + + id="t-page-no-select" + data={many} + columns={columns} + pagination={{ pageSize: 3, pageSizeOptions: false }} + /> + ); + + // The page-jump Select (mobile collapse) is always in the DOM but + // hidden via CSS at desktop widths, so target the page-size select + // directly by its id prefix. + expect(container.querySelector('[id^="tedi-pagination-page-size-"]')).not.toBeInTheDocument(); + }); + + it('omits the pagination bar when pagination is not enabled', () => { + render( id="t-no-page" data={data} columns={columns} />); + expect(screen.queryByRole('navigation', { name: /Pagination/i })).not.toBeInTheDocument(); + }); + }); + + describe('server-side mode', () => { + it('uses the supplied pageCount and rowCount instead of local row math', () => { + // Two rows on the current page, server says there are 50 total — + // local math would compute 1 page / 2 results, but the manual props + // override. + render( + + id="t-manual" + data={data} + columns={columns} + pagination={{ pageSize: 10, pageSizeOptions: false }} + manualPagination + pageCount={5} + rowCount={50} + /> + ); + + // The "results" total in the pagination footer should reflect the + // server-supplied rowCount (50), not the local 2-row count. + expect(screen.getByText(/50/)).toBeInTheDocument(); + }); + + it('does not re-sort the rows locally when manualSorting is true', () => { + // Pass rows already in the order the "server" returned them (Charlie, + // Alice, Bob). With local sorting they would be re-ordered when sort + // state changes; with manualSorting they stay as-is. + const orderedData: Person[] = [ + { id: '1', name: 'Charlie', role: 'Engineer' }, + { id: '2', name: 'Alice', role: 'Engineer' }, + { id: '3', name: 'Bob', role: 'Engineer' }, + ]; + + render( + + id="t-manual-sort" + data={orderedData} + columns={[{ id: 'name', header: 'Name', accessorKey: 'name' }]} + manualSorting + state={{ sorting: [{ id: 'name', desc: false }] }} + /> + ); + + const cells = screen.getAllByRole('cell'); + expect(cells[0]).toHaveTextContent('Charlie'); + expect(cells[1]).toHaveTextContent('Alice'); + expect(cells[2]).toHaveTextContent('Bob'); + }); + }); + + describe('sorting & column-order handlers', () => { + const sortableColumns: ColumnDef[] = [ + { + id: 'name', + header: ({ column }) => ( + + ), + accessorKey: 'name', + }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + ]; + + it('reorders rows when a sortable header toggles sort (function-updater path)', () => { + const sortable: Person[] = [ + { id: '1', name: 'Charlie', role: 'Engineer' }, + { id: '2', name: 'Alice', role: 'Designer' }, + { id: '3', name: 'Bob', role: 'Manager' }, + ]; + + render( id="t-sort" data={sortable} columns={sortableColumns} />); + + // Initial order: Charlie, Alice, Bob. + let nameCells = screen.getAllByRole('cell').filter((c) => /Charlie|Alice|Bob/.test(c.textContent ?? '')); + expect(nameCells[0]).toHaveTextContent('Charlie'); + + fireEvent.click(screen.getByRole('button', { name: 'Name' })); + + // After ascending sort: Alice, Bob, Charlie. + nameCells = screen.getAllByRole('cell').filter((c) => /Charlie|Alice|Bob/.test(c.textContent ?? '')); + expect(nameCells[0]).toHaveTextContent('Alice'); + expect(nameCells[1]).toHaveTextContent('Bob'); + expect(nameCells[2]).toHaveTextContent('Charlie'); + }); + + it('fires onColumnOrderChange when the consumer reorders columns via the table instance', () => { + const onStateChange = jest.fn(); + + const ReorderTrigger = () => { + const { table } = useTableContext(); + useEffect(() => { + table.setColumnOrder(['role', 'name']); + }, [table]); + return null; + }; + + render( + id="t-col-order" data={data} columns={columns} onStateChange={onStateChange}> + + + ); + + const orders = onStateChange.mock.calls + .map((args) => (args[0] as TableState).columnOrder) + .filter((order): order is string[] => Array.isArray(order)); + expect(orders).toContainEqual(['role', 'name']); + }); + }); + + describe('expand column wrapper', () => { + it('handles Enter / Space keydown on the toggle without crashing (stopPropagation path)', () => { + render( + + id="t-exp-keyboard" + data={data} + columns={columns} + renderSubComponent={(row) => details for {row.original.name}} + /> + ); + + const toggle = screen.getAllByRole('button', { name: /table\.expand-row/i })[0]; + fireEvent.keyDown(toggle, { key: 'Enter', bubbles: true }); + fireEvent.keyDown(toggle, { key: ' ', bubbles: true }); + // Pressing a non-Enter/Space key bubbles through the span without + // hitting the stopPropagation branch — still must not throw. + fireEvent.keyDown(toggle, { key: 'Tab', bubbles: true }); + expect(toggle).toBeInTheDocument(); + }); + }); + + describe('grouped headers', () => { + it('renders a standalone column only in the top header row when grouped columns exist', () => { + const groupedColumns: ColumnDef[] = [ + { id: 'name', header: 'Standalone Name', accessorKey: 'name' }, + { + id: 'info-group', + header: 'Info', + columns: [{ id: 'role', header: 'Role', accessorKey: 'role' }], + }, + ]; + + render( id="t-grouped" data={data} columns={groupedColumns} />); + + // Top header row contains both "Standalone Name" and "Info" group label. + // Second header row contains only "Role" — the standalone "Standalone Name" + // is NOT duplicated thanks to the rowIndex > 0 short-circuit. + const standaloneHeaders = screen.getAllByRole('columnheader', { name: 'Standalone Name' }); + expect(standaloneHeaders).toHaveLength(1); + // Standalone leaf spans both header rows via rowSpan rather than being + // duplicated, so it stays visually aligned with the group label beside it. + expect(standaloneHeaders[0]).toHaveAttribute('rowspan', '2'); + + expect(screen.getByRole('columnheader', { name: 'Info' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx new file mode 100644 index 00000000..b32c603d --- /dev/null +++ b/src/tedi/components/content/table/table.stories.tsx @@ -0,0 +1,2127 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + type DragOverEvent, + DragOverlay, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ColumnDef, ColumnOrderState } from '@tanstack/react-table'; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import type { DateRange } from 'react-day-picker'; + +import { Icon } from '../../base/icon/icon'; +import { Heading } from '../../base/typography/heading/heading'; +import { Text } from '../../base/typography/text/text'; +import Button from '../../buttons/button/button'; +import { ClosingButton } from '../../buttons/closing-button/closing-button'; +import InfoButton from '../../buttons/info-button/info-button'; +import { Checkbox } from '../../form/checkbox/checkbox'; +import { DateField } from '../../form/date-field/date-field'; +import { TextField } from '../../form/textfield/textfield'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { EmptyState } from '../../misc/empty-state'; +import Separator from '../../misc/separator/separator'; +import { Alert } from '../../notifications/alert/alert'; +import { Dropdown, DropdownContent, DropdownItem, DropdownTrigger } from '../../overlays/dropdown'; +import { Popover, PopoverContent, PopoverTrigger } from '../../overlays/popover'; +import { Tooltip } from '../../overlays/tooltip'; +import { StatusBadge, type StatusBadgeColor } from '../../tags/status-badge/status-badge'; +import { Truncate } from '../truncate/truncate'; +import type { TableProps } from './table'; +import { Table } from './table'; + +/** + * @tanstack/react-table ↗
+ * Figma ↗
+ * ZeroHeight ↗ + */ +const meta: Meta = { + component: Table, + title: 'TEDI-Ready/Content/Table', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=4514-63761&m=dev', + }, + }, +}; +export default meta; + +interface Person { + id: string; + name: string; + email: string; + role: string; + location: string; + salary: number; + status: 'active' | 'inactive'; +} + +const personSeed: Omit[] = [ + { + name: 'Anna Tamm', + email: 'anna.tamm@example.ee', + role: 'Engineer', + location: 'Tallinn', + salary: 4200, + status: 'active', + }, + { + name: 'Jüri Kask', + email: 'juri.kask@example.ee', + role: 'Designer', + location: 'Tartu', + salary: 3800, + status: 'active', + }, + { + name: 'Maria Saar', + email: 'maria.saar@example.ee', + role: 'Product', + location: 'Pärnu', + salary: 4600, + status: 'active', + }, + { + name: 'Mart Mets', + email: 'mart.mets@example.ee', + role: 'Engineer', + location: 'Tallinn', + salary: 4100, + status: 'inactive', + }, + { name: 'Liis Lepp', email: 'liis.lepp@example.ee', role: 'Ops', location: 'Narva', salary: 3600, status: 'active' }, + { + name: 'Kadri Kask', + email: 'kadri.kask@example.ee', + role: 'Engineer', + location: 'Viljandi', + salary: 4000, + status: 'active', + }, + { + name: 'Rain Roos', + email: 'rain.roos@example.ee', + role: 'Designer', + location: 'Rakvere', + salary: 3900, + status: 'inactive', + }, +]; + +const people: Person[] = Array.from({ length: 28 }, (_, index) => { + const seed = personSeed[index % personSeed.length]; + const round = Math.floor(index / personSeed.length); + return { + ...seed, + id: String(index + 1), + name: round === 0 ? seed.name : `${seed.name} ${round + 1}`, + }; +}); + +const DEFAULT_PAGINATION = { pageSize: 10, pageSizeOptions: [10, 25, 50] }; +const SHOWCASE_PAGINATION_3 = { pageSize: 3, pageSizeOptions: [3, 10, 25, 50] }; +const SHOWCASE_PAGINATION_4 = { pageSize: 4, pageSizeOptions: [4, 10, 25, 50] }; + +const personColumns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'email', header: 'Email', accessorKey: 'email' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, +]; + +type Story = StoryObj>; + +interface Booking { + id: string; + dateRange: DateRange; + hour: string; + duration: string; + location: string; +} + +const bookingDateRange: DateRange = { + from: new Date(2029, 2, 22), + to: new Date(2029, 2, 29), +}; + +const bookings: Booking[] = Array.from({ length: 28 }, (_, index) => ({ + id: String(index + 1), + dateRange: bookingDateRange, + hour: '11:14', + duration: '6 min', + location: 'Harjumaa', +})); + +const dateRangeFormatter = new Intl.DateTimeFormat('et-EE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', +}); + +const formatDateRange = (range: DateRange | undefined): string => { + if (!range?.from) return ''; + const from = dateRangeFormatter.format(range.from); + return range.to ? `${from} – ${dateRangeFormatter.format(range.to)}` : from; +}; + +interface Doctor { + id: string; + name: string; + specialty: string; + experience: string; + location: string; +} + +const doctorSeed: Omit[] = [ + { name: 'Kalle Kask', specialty: 'Dermatovenereoloog', experience: '4 a', location: 'Tallinn' }, + { name: 'Mari Maasikas', specialty: 'Kopsuarst', experience: '4 a', location: 'Tallinn' }, + { name: 'Vello Vaarikas', specialty: 'Kõrva-nina-kurguarst', experience: '4 a', location: 'Tallinn' }, +]; + +const doctors: Doctor[] = Array.from({ length: 28 }, (_, index) => ({ + ...doctorSeed[index % doctorSeed.length], + id: String(index + 1), +})); + +const editRowActionsStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 8, + width: '100%', +}; + +interface EditableRows { + rows: T[]; + editingId: string | null; + draft: T | null; + setDraft: React.Dispatch>; + beginEdit: (row: T) => void; + cancelEdit: () => void; + commitEdit: () => void; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const EditableRowsContext = createContext | null>(null); + +function useEditor(): EditableRows { + const editor = useContext(EditableRowsContext); + if (!editor) throw new Error('EditableRowsContext missing — wrap the table in .'); + return editor as EditableRows; +} + +function useEditableRows(initial: T[]): EditableRows { + const [rows, setRows] = useState(initial); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(null); + + const draftRef = useRef(draft); + draftRef.current = draft; + + const beginEdit = useCallback((row: T) => { + setEditingId(row.id); + setDraft(row); + }, []); + const cancelEdit = useCallback(() => { + setEditingId(null); + setDraft(null); + }, []); + const commitEdit = useCallback(() => { + const current = draftRef.current; + if (!current) return; + setRows((existing) => existing.map((row) => (row.id === current.id ? (current as T) : row))); + setEditingId(null); + setDraft(null); + }, []); + + return { rows, editingId, draft, setDraft, beginEdit, cancelEdit, commitEdit }; +} + +function EditActionsCell({ row }: { row: T }) { + const editor = useEditor(); + if (row.id === editor.editingId) { + return ( + + + + + ); + } + return ( + + + + ); +} + +function EditableTextCell({ + row, + field, + label, + icon, +}: { + row: T; + field: keyof T & string; + label: string; + icon?: string; +}) { + const editor = useEditor(); + const isEditing = row.id === editor.editingId && editor.draft; + if (!isEditing) { + return <>{String(row[field] ?? '')}; + } + const draftValue = String((editor.draft as T)[field] ?? ''); + return ( + editor.setDraft((prev: T | null) => (prev ? { ...prev, [field]: next } : prev))} + /> + ); +} + +function EditableDateRangeCell({ + row, + field, + label, +}: { + row: T; + field: keyof T & string; + label: string; +}) { + const editor = useEditor(); + const isEditing = row.id === editor.editingId && editor.draft; + const value = row[field] as DateRange | undefined; + if (!isEditing) { + return <>{formatDateRange(value)}; + } + const draftValue = (editor.draft as T)[field] as DateRange | undefined; + return ( + + editor.setDraft((prev: T | null) => + prev ? { ...prev, [field]: (next as DateRange | undefined) ?? { from: undefined } } : prev + ) + } + /> + ); +} + +const bookingShowcaseColumns: ColumnDef[] = [ + { + id: 'dateRange', + header: 'Kuupäev', + accessorKey: 'dateRange', + cell: ({ row }) => , + }, + { + id: 'hour', + header: 'Kellaaeg', + accessorKey: 'hour', + cell: ({ row }) => , + }, + { + id: 'duration', + header: 'Kestus', + accessorKey: 'duration', + cell: ({ row }) => , + }, + { + id: 'location', + header: 'Asukoht', + accessorKey: 'location', + cell: ({ row }) => , + }, + { + id: 'actions', + header: '', + size: 1, + cell: ({ row }) => , + }, +]; + +export const Default: Story = { + render: function Default() { + const editor = useEditableRows(bookings); + return ( + + + id="tedi-table-default" + data={editor.rows} + columns={bookingShowcaseColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; + +export const Sizes: Story = { + render: function Sizes() { + const defaultEditor = useEditableRows(bookings); + const smallEditor = useEditableRows(bookings); + return ( + + Default + + + id="tedi-table-sizes-default" + data={defaultEditor.rows} + columns={bookingShowcaseColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + Small + + + id="tedi-table-sizes-small" + data={smallEditor.rows} + columns={bookingShowcaseColumns} + size="small" + pagination={SHOWCASE_PAGINATION_3} + /> + + + ); + }, +}; + +const simplePeopleColumns: ColumnDef[] = [ + { + id: 'name', + header: 'Isik', + accessorKey: 'name', + cell: ({ row }) => , + }, + { id: 'age', header: 'Vanus', accessorKey: 'age' }, + { id: 'visits', header: 'Külastuste arv', accessorKey: 'visits' }, + { + id: 'status', + header: 'Tõendi staatus', + accessorKey: 'status', + cell: ({ row }) => {row.original.status}, + }, +]; + +const simpleDoctorColumns: ColumnDef[] = [ + { + id: 'name', + header: 'Arst', + cell: ({ row }) => ( +
+
{row.original.name}
+
{row.original.specialty}
+
+ ), + }, + { + id: 'experience', + header: 'Tööstaaž', + accessorKey: 'experience', + cell: ({ row }) => , + }, + { + id: 'location', + header: 'Asukoht', + accessorKey: 'location', + cell: ({ row }) => , + }, + { + id: 'actions', + header: '', + size: 1, + cell: ({ row }) => , + }, +]; + +export const Simple: Story = { + render: function Simple() { + const bookingEditor = useEditableRows(bookings); + const doctorEditor = useEditableRows(doctors); + + return ( + + + + id="tedi-table-simple-bookings" + data={bookingEditor.rows} + columns={bookingShowcaseColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + + id="tedi-table-simple-people" + data={filterablePeople} + columns={simplePeopleColumns} + pagination={SHOWCASE_PAGINATION_4} + /> + + + id="tedi-table-simple-doctors" + data={doctorEditor.rows} + columns={simpleDoctorColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + + ); + }, +}; + +const LONG_DESCRIPTION = + 'Pellentesque mattis augue at mi tristique dignissim. Aliquam lobortis hendrerit ' + + 'augue, sit amet pellentesque nibh ultricies eu. Nullam ut nibh non lectus pulvinar ' + + 'volutpat.'; + +const LONG_TEXT_MAX_LENGTH = 70; + +const baseDoctorWithDescriptionColumns: ColumnDef[] = [ + { + id: 'name', + header: 'Arst', + cell: ({ row }) => ( +
+
{row.original.name}
+
{row.original.specialty}
+
+ ), + }, + { + id: 'location', + header: 'Asukoht', + accessorKey: 'location', + cell: ({ row }) => , + }, + { + id: 'actions', + header: '', + size: 1, + cell: ({ row }) => , + }, +]; + +const baseDoctorActionsColumns = (): ColumnDef[] => [ + { + id: 'name', + header: 'Arst', + cell: ({ row }) => ( +
+
{row.original.name}
+
{row.original.specialty}
+
+ ), + }, + { id: 'experience', header: 'Tööstaaž', accessorKey: 'experience' }, + { id: 'location', header: 'Asukoht', accessorKey: 'location' }, +]; + +const rowActionsCellStyle: React.CSSProperties = { + display: 'inline-flex', + gap: 8, + justifyContent: 'flex-end', + width: '100%', +}; + +type CustomNoteColor = 'warning' | 'danger' | undefined; + +interface CustomDoctor extends Doctor { + note?: string; + noteColor?: CustomNoteColor; +} + +const customDoctorSeed: Omit[] = [ + { + name: 'Kalle Kask', + specialty: 'Dermatovenereoloog', + experience: '4 a', + location: 'Tallinn', + note: 'Esineb maksehäireid', + noteColor: 'warning', + }, + { + name: 'Mari Maasikas', + specialty: 'Kopsuarst', + experience: '4 a', + location: 'Tallinn', + }, + { + name: 'Vello Vaarikas', + specialty: 'Kõrva-nina-kurguarst', + experience: '4 a', + location: 'Tallinn', + note: 'Arve tasumata', + noteColor: 'danger', + }, +]; + +const customDoctors: CustomDoctor[] = Array.from({ length: 28 }, (_, index) => ({ + ...customDoctorSeed[index % customDoctorSeed.length], + id: String(index + 1), +})); + +const avatarStyle: React.CSSProperties = { + width: 40, + height: 40, + borderRadius: '50%', + background: 'var(--general-surface-secondary)', + color: 'var(--general-text-secondary)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 'var(--heading-weight)', + fontSize: 'var(--body-small-regular-size)', + flexShrink: 0, +}; + +const initialsOf = (name: string) => + name + .split(' ') + .map((part) => part[0]) + .filter(Boolean) + .slice(0, 2) + .join(''); + +const mergedCellsColumns: ColumnDef[] = [ + { + id: 'dateRange', + accessorKey: 'dateRange', + size: 240, + header: ({ column }) => { + const sorted = column.getIsSorted(); + const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; + return ( + + Kuupäev + + + ); + }, + cell: ({ row }) => , + }, + { + id: 'aeg', + header: 'Aeg', + columns: [ + { + id: 'hour', + header: 'Kellaaeg', + accessorKey: 'hour', + cell: ({ row }) => , + }, + { + id: 'duration', + header: 'Kestus', + accessorKey: 'duration', + cell: ({ row }) => , + }, + ], + }, + { + id: 'location', + header: 'Asukoht', + accessorKey: 'location', + cell: ({ row }) => , + }, + { + id: 'actions', + header: '', + size: 1, + cell: ({ row }) => , + }, +]; + +export const MergedCells: Story = { + render: function MergedCells() { + const editor = useEditableRows(bookings); + return ( + + + id="tedi-table-merged" + verticalBorders + data={editor.rows} + columns={mergedCellsColumns} + pagination={DEFAULT_PAGINATION} + /> + + ); + }, +}; + +const HeaderWithInfo = ({ label, info, align }: { label: string; info: string; align?: 'left' | 'right' }) => ( + + {label} + + + + + {info} + + +); + +interface Service { + id: string; + service: string; + doctor: string; + price: number; + location: string; +} + +const serviceSeed: Omit[] = [ + { service: 'Vaimse tervise nõustamisteenus', doctor: 'Pille Paunküla', price: 45.5, location: 'Tallinn' }, + { service: 'Hematoloogia', doctor: 'Kalle Kuusik', price: 89.99, location: 'Tallinn' }, + { service: 'Ortopeedia', doctor: 'Märt Männimets', price: 110, location: 'Tallinn' }, + { service: 'Dermatoloogia', doctor: 'Anna Tamm', price: 75, location: 'Tartu' }, + { service: 'Kardioloogia', doctor: 'Mati Saar', price: 120.5, location: 'Pärnu' }, + { service: 'Neuroloogia', doctor: 'Liis Põld', price: 95.25, location: 'Tallinn' }, + { service: 'Pediaatria', doctor: 'Jaan Lepp', price: 60, location: 'Tartu' }, +]; +const services: Service[] = Array.from({ length: 28 }, (_, index) => ({ + id: String(index + 1), + ...serviceSeed[index % serviceSeed.length], +})); + +const priceFormatter = new Intl.NumberFormat('et-EE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +/** + * Column separator lines via `verticalBorders`. Combine with `borderless` if the outer border + * should be removed at the same time. + */ +export const VerticalBorders: Story = { + render: () => { + const columns: ColumnDef[] = [ + { + id: 'service', + accessorKey: 'service', + header: ({ column }) => { + const sorted = column.getIsSorted(); + const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; + return ( + + Teenus + + + ); + }, + }, + { + id: 'doctor', + accessorKey: 'doctor', + header: () => , + }, + { + id: 'price', + accessorKey: 'price', + header: 'Maksumus', + meta: { align: 'right' }, + cell: ({ row }) => `${priceFormatter.format(row.original.price)} €/h`, + }, + { + id: 'location', + accessorKey: 'location', + header: () => , + }, + ]; + return ( + + id="tedi-table-vb" + data={services} + columns={columns} + verticalBorders + pagination={DEFAULT_PAGINATION} + /> + ); + }, +}; + +/** + * Removes the outer table border with `borderless`. Useful when the table sits inside an + * already-bordered card or panel and a double border would look wrong. + */ +export const NoOutsideBorder: Story = { + render: () => ( + + id="tedi-table-borderless" + data={people} + columns={personColumns} + borderless + pagination={DEFAULT_PAGINATION} + /> + ), +}; + +/** + * Built on the shared `useEditableRows` hook + `EditableRowsContext` and the + * module-level `bookingShowcaseColumns` array: the editor state lives in the + * hook, flows through context, and is read by `EditableTextCell` / + * `EditActionsCell` inside each cell. Keeping the columns array stable across + * renders is what prevents TanStack from rebuilding column instances on every + * keystroke (which would otherwise unmount the inner `` and lose + * focus after each character). + */ +export const EditableValues: Story = { + render: function EditableValues() { + const editor = useEditableRows(bookings); + return ( + + + id="tedi-table-editable" + data={editor.rows} + columns={bookingShowcaseColumns} + pagination={DEFAULT_PAGINATION} + /> + + ); + }, +}; + +/** + * Client-side sorting via `Table.HeaderButton` in the header renderer. Each click cycles + * `unfold_more → arrow_upward → arrow_downward → unfold_more`. TanStack Table handles the + * sort state internally; no external state needed for client-side use. + */ +export const Sortable: Story = { + render: function Sortable() { + const columns = useMemo[]>( + () => + [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + { id: 'salary', header: 'Salary', accessorKey: 'salary' }, + ].map((col) => ({ + ...col, + header: ({ column }) => { + const sorted = column.getIsSorted(); + const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; + return ( + + {col.header} + + + ); + }, + })), + [] + ); + + return id="tedi-table-sortable" data={people} columns={columns} pagination={DEFAULT_PAGINATION} />; + }, +}; + +type CertStatus = 'Kehtiv' | 'Kehtetu' | 'Aegumas' | 'Aegunud'; + +const certStatusColor: Record = { + Kehtiv: 'success', + Aegumas: 'warning', + Kehtetu: 'danger', + Aegunud: 'neutral', +}; + +interface PersonRecord { + id: string; + name: string; + jobStart: string; + age: number; + visits: number; + status: CertStatus; +} + +const CERT_STATUSES: CertStatus[] = ['Kehtiv', 'Kehtetu', 'Aegumas', 'Aegunud']; + +const filterablePeopleSeed: Omit[] = [ + { name: 'Mari Maasikas', jobStart: '21.08.2019', age: 25, visits: 6, status: 'Kehtiv' }, + { name: 'Kalle Kapsapea', jobStart: '14.03.2020', age: 35, visits: 13, status: 'Kehtiv' }, + { name: 'Mart Mägi', jobStart: '02.01.2018', age: 43, visits: 26, status: 'Kehtiv' }, + { name: 'Meelis Mets', jobStart: '10.07.2021', age: 64, visits: 26, status: 'Kehtetu' }, + { name: 'Kadri Kask', jobStart: '30.11.2022', age: 32, visits: 4, status: 'Aegumas' }, + { name: 'Liis Linn', jobStart: '21.08.2019', age: 21, visits: 13, status: 'Aegunud' }, +]; + +const filterablePeople: PersonRecord[] = Array.from({ length: 28 }, (_, index) => { + const seed = filterablePeopleSeed[index % filterablePeopleSeed.length]; + const round = Math.floor(index / filterablePeopleSeed.length); + return { + ...seed, + id: String(index + 1), + name: round === 0 ? seed.name : `${seed.name} ${round + 1}`, + }; +}); + +const SortLabel = ({ + column, + children, + ariaLabel, +}: { + column: { + getIsSorted: () => false | 'asc' | 'desc'; + getToggleSortingHandler: () => ((e: unknown) => void) | undefined; + }; + children: React.ReactNode; + ariaLabel: string; +}) => { + const sorted = column.getIsSorted(); + const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; + return ( + + {children} + + + ); +}; + +const TextFilterPopover = ({ + value, + onApply, + label, +}: { + value: string; + onApply: (next: string | undefined) => void; + label: string; +}) => { + const [draft, setDraft] = useState(value); + return ( + + + + + + + +
+ + +
+
+
+
+ ); +}; + +type DateRangeValue = { from?: string; to?: string }; + +const DateRangeFilterPopover = ({ + value, + onApply, + label, +}: { + value: DateRangeValue | undefined; + onApply: (next: DateRangeValue | undefined) => void; + label: string; +}) => { + const [from, setFrom] = useState(value?.from ?? ''); + const [to, setTo] = useState(value?.to ?? ''); + const active = Boolean(value?.from || value?.to); + return ( + + + + + + + + +
+ + +
+
+
+
+ ); +}; + +const MultiSelectFilterPopover = ({ + value, + onApply, + label, +}: { + value: CertStatus[] | undefined; + onApply: (next: CertStatus[] | undefined) => void; + label: string; +}) => { + const [draft, setDraft] = useState(value ?? []); + const active = (value?.length ?? 0) > 0; + return ( + + + + + + + {CERT_STATUSES.map((option) => ( + + setDraft((prev) => (checked ? [...prev, option] : prev.filter((v) => v !== option))) + } + /> + ))} +
+ + +
+
+
+
+ ); +}; + +const parseDate = (value: string): number | null => { + const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(value); + if (!match) return null; + const [, dd, mm, yyyy] = match; + return Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd)); +}; + +/** + * Column filter popovers via `Table.HeaderButton` + `Popover`. Each column provides a `filterFn` + * and stores its filter value in TanStack Table's `columnFilters` state. Filter UI varies per column: + * text input for names, date-range fields for dates, and a checkbox list for enum values. + */ +export const Filters: Story = { + render: function Filters() { + const columns = useMemo[]>( + () => [ + { + id: 'name', + accessorKey: 'name', + filterFn: 'includesString', + header: ({ column }) => ( + + + Nimi + + column.setFilterValue(next)} + label="Nimi" + /> + + ), + }, + { + id: 'jobStart', + accessorKey: 'jobStart', + filterFn: (row, id, value: DateRangeValue) => { + if (!value?.from && !value?.to) return true; + const cell = parseDate(row.getValue(id) as string); + if (cell === null) return false; + const fromTs = value.from ? parseDate(value.from) : null; + const toTs = value.to ? parseDate(value.to) : null; + if (fromTs !== null && cell < fromTs) return false; + if (toTs !== null && cell > toTs) return false; + return true; + }, + header: ({ column }) => ( + + + Töö algus + + column.setFilterValue(next)} + label="Töö algus" + /> + + ), + }, + { + id: 'age', + accessorKey: 'age', + header: ({ column }) => ( + + Vanus + + ), + }, + { + id: 'visits', + accessorKey: 'visits', + header: ({ column }) => ( + + Külastuste arv + + ), + }, + { + id: 'status', + accessorKey: 'status', + filterFn: (row, id, value: CertStatus[]) => !value?.length || value.includes(row.getValue(id) as CertStatus), + header: ({ column }) => ( + + + Tõendi staatus + + column.setFilterValue(next)} + label="Tõendi staatus" + /> + + ), + cell: ({ row }) => ( + {row.original.status} + ), + }, + ], + [] + ); + + return ( + + id="tedi-table-filters" + data={filterablePeople} + columns={columns} + pagination={DEFAULT_PAGINATION} + /> + ); + }, +}; + +interface CollapsibleRecord { + id: string; + name: string; + age: number; + visits: number; + status: CertStatus; + subRows?: CollapsibleRecord[]; +} + +const collapsibleSeed: Omit[] = [ + { name: 'Mari Maasikas', age: 25, visits: 6, status: 'Kehtiv' }, + { name: 'Kalle Kapsapea', age: 35, visits: 13, status: 'Kehtiv' }, + { name: 'Mart Mägi', age: 43, visits: 26, status: 'Kehtiv' }, + { name: 'Meelis Mets', age: 64, visits: 26, status: 'Kehtetu' }, + { name: 'Kadri Kask', age: 32, visits: 4, status: 'Aegumas' }, + { name: 'Liis Linn', age: 21, visits: 13, status: 'Aegunud' }, +]; + +const collapsiblePeople: CollapsibleRecord[] = Array.from({ length: 28 }, (_, index) => { + const seed = collapsibleSeed[index % collapsibleSeed.length]; + const round = Math.floor(index / collapsibleSeed.length); + const name = round === 0 ? seed.name : `${seed.name} ${round + 1}`; + const id = String(index + 1); + const subRows: CollapsibleRecord[] | undefined = + index % 2 === 0 + ? [ + { id: `${id}-1`, name, age: seed.age, visits: Math.floor(seed.visits / 2), status: 'Kehtiv' }, + { id: `${id}-2`, name, age: seed.age, visits: seed.visits - Math.floor(seed.visits / 2), status: 'Kehtetu' }, + ] + : undefined; + return { ...seed, id, name, ...(subRows ? { subRows } : {}) }; +}); + +/** + * Nested rows using TanStack Table's sub-rows feature. Pass `getSubRows={(row) => row.subRows}` and + * include a `subRows` array on any data item. Rows with children get an expand/collapse toggle + * automatically; child rows are indented by depth level. + */ +export const CollapsibleRows: Story = { + render: () => { + const columns: ColumnDef[] = [ + { id: 'name', header: 'Isik', accessorKey: 'name' }, + { id: 'age', header: 'Vanus', accessorKey: 'age' }, + { id: 'visits', header: 'Külastuste arv', accessorKey: 'visits' }, + { + id: 'status', + header: 'Tõendi staatus', + accessorKey: 'status', + cell: ({ row }) => ( + {row.original.status} + ), + }, + ]; + return ( + + id="tedi-table-collapse" + data={collapsiblePeople} + columns={columns} + getSubRows={(row) => row.subRows} + pagination={DEFAULT_PAGINATION} + /> + ); + }, +}; + +/** + * Row checkboxes via `enableRowSelection`. A header checkbox selects/deselects all rows on the + * current page. Read selected rows with `table.getSelectedRowModel().rows` in `onStateChange`. + */ +export const SelectableRows: Story = { + render: () => ( + + id="tedi-table-selectable" + data={people} + columns={personColumns} + enableRowSelection + pagination={DEFAULT_PAGINATION} + /> + ), +}; + +/** + * Whole-row click via `onRowClick={(row) => ...}`. The table adds pointer cursor and hover highlight + * automatically. Use instead of (or alongside) `enableRowSelection` when a click should navigate + * or open a detail panel rather than toggle a checkbox. + * + * Pair the click handler with `activeRowId` to pin the clicked row visually — useful for + * master-detail layouts where the row should stay highlighted while a side pane shows its + * content. The active row paints with the same `--table-hover` surface as `:hover` but + * survives cursor movement, and announces itself to screen readers via `aria-current="true"`. + */ +export const ClickableRows: Story = { + render: function ClickableRows() { + const [active, setActive] = useState<{ id: string; name: string } | null>(null); + return ( + <> + {active ? `You clicked ${active.name}` : 'Click a row to select it.'} + + id="tedi-table-clickable" + data={people} + columns={personColumns} + onRowClick={(row) => setActive({ id: row.id, name: row.original.name })} + activeRowId={active?.id} + pagination={DEFAULT_PAGINATION} + /> + + ); + }, +}; + +/** + * Alternating row background color via `striped`. Helps readability in wide or dense tables. + */ +export const Striped: Story = { + render: () => ( + + id="tedi-table-striped" + data={people} + columns={personColumns} + striped + pagination={DEFAULT_PAGINATION} + /> + ), +}; + +interface StickyDoctor extends Doctor { + personalId: string; +} + +const stickyDoctorSeed: Omit[] = [ + { + name: 'Kalle Kask', + personalId: '49504080456', + specialty: 'Dermatovenereoloog', + experience: '4 a', + location: 'Tallinn', + }, + { + name: 'Mari Maasikas', + personalId: '39404080456', + specialty: 'Kopsuarst', + experience: '4 a', + location: 'Tallinn', + }, + { + name: 'Vello Vaarikas', + personalId: '39403080865', + specialty: 'Kõrva-nina-kurguarst', + experience: '4 a', + location: 'Tallinn', + }, +]; + +const stickyDoctors: StickyDoctor[] = Array.from({ length: 28 }, (_, index) => ({ + ...stickyDoctorSeed[index % stickyDoctorSeed.length], + id: String(index + 1), +})); + +const stickyDoctorColumns: ColumnDef[] = [ + { + id: 'name', + header: 'Arst', + accessorKey: 'name', + size: 280, + cell: ({ row }) => ( + + {row.original.name} + {row.original.personalId} + + ), + }, + { id: 'specialty', header: 'Eriala', accessorKey: 'specialty', size: 240 }, + { id: 'experience', header: 'Tööstaaž', accessorKey: 'experience', size: 200 }, + { id: 'location', header: 'Asukoht', accessorKey: 'location', size: 200 }, + { + id: 'actions', + header: '', + size: 1, + cell: () => ( + + + + ), + }, +]; + +/** + * First column stays fixed during horizontal scroll via `stickyFirstColumn`. Constrain the + * container width so the table overflows — the sticky effect is invisible when the table fits + * without scroll. + */ +export const StickyFirstColumn: Story = { + render: () => ( +
+ + id="tedi-table-sticky" + data={stickyDoctors} + columns={stickyDoctorColumns} + stickyFirstColumn + pagination={DEFAULT_PAGINATION} + /> +
+ ), +}; + +/** + * Header row stays pinned during vertical scroll via `stickyHeader` + `maxHeight`. The Table's + * internal `.tedi-table__scroll` div is the sticky anchor — wrapping the Table in an external + * scrollable container will NOT work, because `position: sticky` always resolves against the + * nearest scrolling ancestor (always the internal div). Use the `maxHeight` prop so Table + * applies the height constraint to that internal div instead. + * + * Combines with `stickyFirstColumn`: when both are on, the top-left header cell stacks above + * both axes automatically. + */ +export const StickyHeader: Story = { + render: () => ( + id="tedi-table-sticky-header" data={people} columns={personColumns} stickyHeader maxHeight={240} /> + ), +}; + +/** + * Both axes pinned: first column locked horizontally, header row locked vertically. The + * intersection cell (top-left header) stacks above both sticky planes so it stays visible + * regardless of scroll direction. + */ +export const StickyHeaderAndFirstColumn: Story = { + render: () => ( +
+ + id="tedi-table-sticky-both" + data={stickyDoctors} + columns={stickyDoctorColumns} + stickyHeader + stickyFirstColumn + maxHeight={280} + /> +
+ ), +}; + +/** + * Empty-state rendering: when `data` is empty, Table falls back to the + * `placeholder` prop. Passing an `` node produces the richer + * zero-data layout (icon + heading + description + actions) inside the table + * body. + */ +export const WithEmptyState: Story = { + render: () => ( + + id="tedi-table-empty-state" + data={[]} + columns={personColumns} + placeholder={ + + No results found + + } + /> + ), +}; + +/** + * Long-text columns use `` so the description cell collapses to a + * single line with a "Show more" toggle. By default the toggle renders inline + * at the end of the truncated text; pass `button={{ style: { display: 'block' } }}` + * to drop it onto its own line below the paragraph instead. + */ +const longTextsColumns: ColumnDef[] = [ + baseDoctorWithDescriptionColumns[0], + { + id: 'description', + header: 'Kirjeldus', + size: 480, + cell: () => {LONG_DESCRIPTION}, + }, + baseDoctorWithDescriptionColumns[1], + baseDoctorWithDescriptionColumns[2], +]; + +export const LongTexts: Story = { + render: function LongTexts() { + const editor = useEditableRows(doctors); + + return ( + + + id="tedi-table-long-texts" + data={editor.rows} + columns={longTextsColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; + +export const Actions: Story = { + render: function Actions() { + const columns = useMemo[]>( + () => [ + ...baseDoctorActionsColumns(), + { + id: 'actions', + header: '', + size: 1, + cell: ({ row }) => ( + + + + + + + undefined}>Muuda + undefined}>Dubleeri + undefined}>Saada e-mail + undefined}>Kustuta + + + + ), + }, + ], + [] + ); + + return ( + id="tedi-table-actions" data={doctors} columns={columns} pagination={SHOWCASE_PAGINATION_3} /> + ); + }, +}; + +export const Custom: Story = { + render: function Custom() { + const columns = useMemo[]>( + () => [ + { + id: 'name', + header: 'Arst', + cell: ({ row }) => ( +
+ +
+
{row.original.name}
+
{row.original.specialty}
+
+
+ ), + }, + { + id: 'note', + header: '', + cell: ({ row }) => + row.original.note && row.original.noteColor ? ( + + {row.original.note} + + ) : null, + }, + { id: 'location', header: 'Asukoht', accessorKey: 'location' }, + { + id: 'actions', + header: '', + size: 1, + cell: ({ row }) => ( + + + + + + + +
{row.original.name}
+
+ {row.original.specialty} · {row.original.location} +
+ +
+ + +
+
+
+
+
+ ), + }, + ], + [] + ); + + return ( + + + id="tedi-table-custom" + data={customDoctors} + columns={columns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; + +/** + * Footer row showing per-column aggregates (e.g. salary total, headcount). + * Columns opt in by providing a `footer` value/function on `columnDef`. + */ +export const WithFooter: Story = { + render: () => { + const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name', footer: `${people.length} people` }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + { + id: 'salary', + accessorKey: 'salary', + header: 'Salary (€)', + meta: { align: 'right' }, + cell: (info) => (info.getValue() as number).toLocaleString('et-EE'), + footer: (info) => { + const total = info.table.getFilteredRowModel().rows.reduce((sum, row) => sum + row.original.salary, 0); + return `Total €${total.toLocaleString('et-EE')}`; + }, + }, + ]; + return id="tedi-table-footer" data={people} columns={columns} pagination={DEFAULT_PAGINATION} />; + }, +}; + +/** + * Column-visibility toolbar example. Reuses the dropdown from the base + * story file so consumers can see how `` plugs into + * ``. + */ +export const WithColumnsMenu: Story = { + render: () => ( + id="tedi-table-visibility" data={people} columns={personColumns} pagination={DEFAULT_PAGINATION}> + + + + + ), +}; + +// --------------------------------------------------------------------------- +// Drag-and-drop reordering — uses `@dnd-kit` for the drag mechanics. +// +// Pattern (works for both rows and columns): +// 1. Wrap the Table in `` + ``. +// 2. Render a tiny "drag handle" element that calls `useSortable({ id })` +// and attaches its `attributes` / `listeners` to a grip button. +// 3. In `onDragEnd`, compute the new order with `arrayMove` and either +// reorder the data array (rows) or set `state.columnOrder` (columns). +// --------------------------------------------------------------------------- + +const dragOverlayTableStyle: React.CSSProperties = { + borderCollapse: 'collapse', + background: 'var(--card-background-primary)', + border: 'var(--tedi-borders-01) solid var(--card-border-primary)', + borderRadius: 'var(--table-radius)', + boxShadow: '0 6px 16px var(--tedi-alpha-20)', + cursor: 'grabbing', +}; +const dragOverlayRowStyle: React.CSSProperties = { + background: 'var(--table-hover)', +}; +const dragOverlayCellStyle: React.CSSProperties = { + padding: 'var(--table-data-padding-y) var(--table-data-padding-x)', + color: 'var(--general-text-primary)', + whiteSpace: 'nowrap', +}; + +const DragOverColumnContext = createContext(null); + +const SortableColumnHeader = ({ id, label }: { id: string; label: string }) => { + const overId = useContext(DragOverColumnContext); + const isOver = overId === id; + return ( + + + {label} + + ); +}; + +const dragOverlayHeaderChipStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 8, + padding: '8px 14px', + fontFamily: 'var(--family-default)', + fontWeight: 'var(--body-bold-weight)', + color: 'var(--general-text-secondary)', + background: 'var(--card-background-primary)', + border: 'var(--tedi-borders-01) solid var(--card-border-primary)', + borderRadius: 'var(--table-radius)', + boxShadow: '0 6px 16px var(--tedi-alpha-20)', + cursor: 'grabbing', +}; + +/** + * Drag handle cell shared by both stories. The `id` is whatever sortable + * identifier the parent context expects (row id or column id). Listeners + * sit on a real ` + ); +}; + +/** + * Drag rows by the grip handle to reorder them. The story owns the data array + * and applies `arrayMove` on drag end — Table itself doesn't need to know. + * + * **Persistence (not applied in this story).** The order lives in the + * component's local `useState`, so refreshing the page resets it. TanStack + * has no native rowOrder, so Table can't auto-persist the visual order on + * your behalf — you have two options: + * + * 1. **Persist the data array yourself**: serialise the reordered `rows` + * array (or just the ids) into `localStorage` on every drag-end and + * hydrate the `useState` initial value from there on mount. + * 2. **Use Table's `rowOrder` state slice**: `state.rowOrder` is in Table's + * `DEFAULT_PERSISTED_KEYS`, so passing `persist={{ key: '…' }}` writes + * the list of ids to `localStorage` automatically. On mount, read it + * back (via `defaultState.rowOrder`) and physically reorder your `data` + * array before passing it to ``. Table itself only stores the + * list — your component still owns the data shape. + * + * For server-backed data the same applies: persist the new order ids on the + * server (or in `rowOrder`) and re-derive `data` from the response on the + * next fetch. + */ +export const DraggableRows: Story = { + render: function DraggableRows() { + // Story owns its own reorderable copy of `people` so drag-end can mutate it. + const [rows, setRows] = useState(() => people.slice(0, 8)); + const [activeRowId, setActiveRowId] = useState(null); + const [overRowId, setOverRowId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const columns = useMemo[]>( + () => [ + { + id: 'drag', + header: '', + size: 40, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => , + }, + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + ], + [] + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveRowId(String(event.active.id)); + setOverRowId(String(event.active.id)); + }; + const handleDragOver = (event: DragOverEvent) => { + setOverRowId(event.over ? String(event.over.id) : null); + }; + const handleDragEnd = (event: DragEndEvent) => { + setActiveRowId(null); + setOverRowId(null); + const { active, over } = event; + if (!over || active.id === over.id) return; + setRows((current) => { + const oldIndex = current.findIndex((r) => r.id === active.id); + const newIndex = current.findIndex((r) => r.id === over.id); + if (oldIndex < 0 || newIndex < 0) return current; + return arrayMove(current, oldIndex, newIndex); + }); + }; + const handleDragCancel = () => { + setActiveRowId(null); + setOverRowId(null); + }; + const activeRow = activeRowId ? rows.find((r) => r.id === activeRowId) : null; + + return ( + + + + Grab the handle on any row to reorder. Keyboard users: focus a handle and press + Space to lift, arrows to move, Space to drop. The parent component owns the data order and updates it on{' '} + onDragEnd. + + + + r.id)} strategy={verticalListSortingStrategy}> + + id="tedi-table-row-drag" + data={rows} + columns={columns} + activeRowId={overRowId ?? undefined} + /> + + + {activeRow ? ( +
+ + + + + + + + +
{activeRow.name}{activeRow.role}{activeRow.location}
+ ) : null} + +
+ + ); + }, +}; + +/** + * Drag a column header's grip to reorder columns. The story owns + * `state.columnOrder`; Table forwards it to TanStack's `columnOrder` state so + * cells reshuffle without re-creating the column definitions. + * + * **Persistence (not applied in this story).** The order lives in local + * `useState`, so refreshing the page resets it. To make column order survive + * a refresh, drop the local state + `state` / `onStateChange` wiring and add + * a single prop: + * + * ```tsx + * + * ``` + * + * `columnOrder` is in Table's `DEFAULT_PERSISTED_KEYS`, so the persist + * adapter writes it to `localStorage` on every change and hydrates it on + * mount — no extra wiring needed. The same prop covers `columnVisibility`, + * `rowOrder` (ids only — see DraggableRows for the caveat), and + * `columnSizing`. Use `persist.include` to opt in / out of specific slices. + */ +export const DraggableColumns: Story = { + render: function DraggableColumns() { + const baseColumns = useMemo[]>( + () => [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'email', header: 'Email', accessorKey: 'email' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + ], + [] + ); + + const [columnOrder, setColumnOrder] = useState(() => + baseColumns.map((column) => column.id as string) + ); + const [activeColumnId, setActiveColumnId] = useState(null); + const [overColumnId, setOverColumnId] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const columns = useMemo[]>( + () => + baseColumns.map((column) => ({ + ...column, + header: ({ column: ctxColumn }) => , + })) as ColumnDef[], + [baseColumns] + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveColumnId(String(event.active.id)); + setOverColumnId(String(event.active.id)); + }; + const handleDragOver = (event: DragOverEvent) => { + setOverColumnId(event.over ? String(event.over.id) : null); + }; + const handleDragEnd = (event: DragEndEvent) => { + setActiveColumnId(null); + setOverColumnId(null); + const { active, over } = event; + if (!over || active.id === over.id) return; + setColumnOrder((current) => { + const oldIndex = current.indexOf(active.id as string); + const newIndex = current.indexOf(over.id as string); + if (oldIndex < 0 || newIndex < 0) return current; + return arrayMove(current, oldIndex, newIndex); + }); + }; + const handleDragCancel = () => { + setActiveColumnId(null); + setOverColumnId(null); + }; + const activeColumnHeader = activeColumnId + ? (baseColumns.find((c) => c.id === activeColumnId)?.header as string | undefined) + : undefined; + + return ( + + + + Drag a column header by its handle to reorder. Column order lives on{' '} + state.columnOrder; Table forwards it to TanStack so the cells re-render in the + new order automatically. + + + + + + + id="tedi-table-column-drag" + data={people.slice(0, 6)} + columns={columns} + state={{ columnOrder }} + onStateChange={(next) => { + if (next.columnOrder) setColumnOrder(next.columnOrder); + }} + /> + + + + {activeColumnHeader ? ( + + + {activeColumnHeader} + + ) : null} + + + + ); + }, +}; + +/** + * Server-side pagination + sorting demo. `manualPagination` / `manualSorting` + * tell the Table not to slice or re-order `data` locally; the parent owns + * the current page slice and the sort, and re-derives them when state + * changes. In a real app the `onStateChange` callback would dispatch a + * fetch with the new page / sort and pass the response back as `data`. + * + * Key props: + * - `manualPagination` / `manualSorting` — disables in-memory work + * - `pageCount` / `rowCount` — server-known totals (the local row count is + * wrong because `data` only holds the current page) + * - controlled `state` + `onStateChange` — observe page / sort changes and + * refetch + */ +export const ServerSide: Story = { + render: function ServerSide() { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 }); + const [sorting, setSorting] = useState<{ id: string; desc: boolean }[]>([]); + + const sortedData = useMemo(() => { + if (sorting.length === 0) return people; + const { id, desc } = sorting[0]; + const direction = desc ? -1 : 1; + return [...people].sort((a, b) => { + const av = a[id as keyof Person]; + const bv = b[id as keyof Person]; + if (av === bv) return 0; + return av > bv ? direction : -direction; + }); + }, [sorting]); + + const pageRows = useMemo( + () => + sortedData.slice(pagination.pageIndex * pagination.pageSize, (pagination.pageIndex + 1) * pagination.pageSize), + [sortedData, pagination] + ); + + const sortableColumns = useMemo[]>( + () => + personColumns.map((col) => ({ + ...col, + header: ({ column }) => { + const sorted = column.getIsSorted(); + const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; + return ( + + {col.header as string} + + + ); + }, + })) as ColumnDef[], + [] + ); + + return ( + + + + This story simulates a server-paginated, server-sorted table. The parent owns the current page slice and the + sort state; the Table is told manualPagination +{' '} + manualSorting so it does not re-slice or re-sort its{' '} + data locally. + + Wiring it up in your app +
+            {`const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
+const [sorting, setSorting] = useState([]);
+
+// Refetch from the server whenever pagination / sort changes.
+const { data: page, total } = useServerQuery({ pagination, sorting });
+
+
{ + if (next.pagination) setPagination(next.pagination); + if (next.sorting) setSorting(next.sorting); + }} + pagination +/>`} + + + + Tip: manualFiltering works the same way for column filters when you have them. + + + + id="tedi-table-server-side" + data={pageRows} + columns={sortableColumns} + manualPagination + manualSorting + pageCount={Math.ceil(people.length / pagination.pageSize)} + rowCount={people.length} + state={{ pagination, sorting }} + onStateChange={(next) => { + if (next.pagination) setPagination(next.pagination); + if (next.sorting !== undefined) setSorting(next.sorting); + }} + pagination={{ pageSize: 5, pageSizeOptions: [5, 10, 25] }} + /> + + ); + }, +}; diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx new file mode 100644 index 00000000..933e2aac --- /dev/null +++ b/src/tedi/components/content/table/table.tsx @@ -0,0 +1,912 @@ +import { + type ColumnDef, + type ColumnFiltersState, + type ColumnOrderState, + type ColumnSizingState, + type ExpandedState, + type FilterFn, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type OnChangeFn, + type PaginationState, + type Row, + type RowSelectionState, + type SortingState, + type Table as ReactTable, + useReactTable, + type VisibilityState, +} from '@tanstack/react-table'; +import cn from 'classnames'; +import { Fragment, type KeyboardEvent, type ReactNode, useCallback, useId, useMemo, useRef } from 'react'; + +import { useLabels } from '../../../providers/label-provider'; +import { Collapse } from '../../buttons/collapse/collapse'; +import { Checkbox } from '../../form/checkbox/checkbox'; +import { TextField } from '../../form/textfield/textfield'; +import { Pagination } from '../../navigation/pagination'; +import styles from './table.module.scss'; +import { TableColumnsMenu } from './table-columns-menu/table-columns-menu'; +import { TableContext } from './table-context'; +import { TableHeaderButton } from './table-header-button/table-header-button'; +import { TableToolbar } from './table-toolbar/table-toolbar'; +import { useTablePersistence } from './use-table-persistence'; + +/** + * Optional shape that columns can put in `columnDef.meta` to: + * + * - drive the column-filter aria-label when the header is non-textual (`label`), + * - align the column's `` row(s) to the top during vertical scroll. Requires + * `maxHeight` so the table's internal scroll container becomes the sticky + * anchor — wrapping the Table in an external scrollable div will NOT work, + * because the `` sticks to its nearest scrolling ancestor (which is + * always the internal `.tedi-table__scroll` div). Combines safely with + * `stickyFirstColumn`. + * @default false + */ + stickyHeader?: boolean; + /** + * Constrains the height of the table's internal scroll container. Accepts + * any CSS length value (`number` is treated as pixels). Pair with + * `stickyHeader` for a fixed-height table whose header stays pinned during + * vertical scroll. + */ + maxHeight?: number | string; + /** + * Fires when a data row is clicked. Adds `role="button"`, a pointer cursor + * and Enter/Space keyboard activation to every row. + */ + onRowClick?: (row: Row) => void; + /** + * Highlights the row whose `id` matches as the currently-active row. + * + * Useful in master-detail layouts where the click handler navigates or opens a side + * panel and the table should stay visibly anchored to that row. Distinct from row + * selection (which is checkbox-driven via `enableRowSelection`) and from the + * transient `:hover` state. Renders with `aria-current="true"` for screen readers. + */ + activeRowId?: string; + /** + * Paint a hover background on data rows when the cursor is over them. Off by default — + * a hover highlight implies the row is interactive, which is misleading when there's + * nothing to click. Pass `true` for read-only tables that still want the affordance, + * or omit and the table will turn hover on automatically whenever `onRowClick` is set. + */ + rowHover?: boolean; + /** + * Enables row selection. When true, Table prepends a selection column with + * checkboxes bound to `rowSelection` state. + */ + enableRowSelection?: boolean | ((row: Row) => boolean); + /** + * Enables per-column filter inputs rendered below the header row. + * Only columns whose `columnDef.enableColumnFilter !== false` render an input. + */ + enableColumnFilters?: boolean; + /** + * Render function for the expanded content of a row. When provided, Table + * prepends an expand/collapse toggle column and renders this node in a full- + * width row below every expanded parent row. + */ + renderSubComponent?: (row: Row) => ReactNode; + /** + * Predicate controlling which rows can be expanded. Defaults to "all rows" + * when `renderSubComponent` is provided, otherwise "none". + */ + getRowCanExpand?: (row: Row) => boolean; + /** + * Returns the sub-rows of a data row. Enables nested hierarchical data. + */ + getSubRows?: (row: TData) => TData[] | undefined; + /** + * Enables client-side pagination and renders a built-in page-switcher footer. + * Pass `true` for default settings or an options object to customise. + * Page state lives on `TableState.pagination` so it is fully controllable and + * persistable. + */ + pagination?: boolean | TablePaginationOptions; + /** + * Switches pagination to server-side mode. When `true`, Table stops slicing + * `data` locally — `data` is treated as the rows for the current page only. + * Pair with `pageCount` (or `rowCount`) and a controlled `state.pagination` + * + `onStateChange` to fetch the right page from the server on each change. + * @default false + */ + manualPagination?: boolean; + /** + * Switches sorting to server-side mode. When `true`, Table no longer sorts + * `data` locally — sort state still updates and fires `onStateChange` so + * the parent can refetch in the new order, but the rows are rendered in + * the order they arrive in `data`. + * @default false + */ + manualSorting?: boolean; + /** + * Switches filtering to server-side mode. When `true`, Table stops applying + * `columnFilters` locally; the parent is expected to translate filter state + * (visible via `onStateChange`) into a server query. + * @default false + */ + manualFiltering?: boolean; + /** + * Total number of pages on the server. Required when `manualPagination` is + * `true` so the pagination footer can render the right page count — local + * row-count math is otherwise wrong, since `data` only holds the current + * page's rows. + */ + pageCount?: number; + /** + * Total number of rows on the server (across all pages). Used as the + * "X tulemust" / "X results" counter in the pagination footer when + * `manualPagination` is on. Falls back to the locally filtered row count + * when omitted. + */ + rowCount?: number; + /** + * Controlled state. Pair with `onStateChange`. Any key left undefined falls + * back to the corresponding default or internal state. + */ + state?: Partial; + /** + * Initial state for uncontrolled usage. Ignored when `state` is provided. + */ + defaultState?: Partial; + /** + * Callback fired whenever any state slice changes. + */ + onStateChange?: (state: TableState) => void; + /** + * When set, Table wires a localStorage adapter for the named key. Acts as a + * default state provider and persists subsequent changes. Consumers can still + * supply `state`/`onStateChange` to layer extra behavior on top. + */ + persist?: TablePersistOptions; + /** + * Rendered inside `` when `data` is empty. + * @default "No data" + */ + placeholder?: ReactNode; + /** + * ARIA live-region role wrapping the empty-state placeholder. Use `'status'` + * for polite announcements (recommended for "no results" feedback when the + * user changes a filter) or `'alert'` for assertive announcements that + * interrupt the current SR utterance. Omit when the placeholder should not + * announce changes — e.g. when the table is empty on first render and the + * content never changes. + */ + placeholderRole?: 'alert' | 'status'; + /** + * Additional class name on the root element. + */ + className?: string; + /** + * Toolbar + Table subcomponents such as ``. + */ + children?: ReactNode; +} + +/** + * Value exposed through `TableContext`. Subcomponents like ColumnsMenu use it + * to read and mutate the table state without prop-drilling. + */ +export interface TableContextValue { + table: ReactTable; + size: TableSize; + id?: string; + /** + * Snapshot of the current Table state. Included so the context value + * identity changes whenever state updates — context consumers re-render + * even when they're passed as referentially-stable children. + */ + state: Partial; +} + +const SELECT_COLUMN_ID = '__select__'; +const EXPAND_COLUMN_ID = '__expand__'; + +// Satisfy the community-side `declare module '@tanstack/table-core'` FilterFns +// augmentation so the typed `useReactTable` signature accepts our options. The +// community Table uses richer implementations; the TEDI-Ready Table's stories +// drive filtering via built-ins (`includesString`) or per-column `filterFn` +// overrides, so these stubs are never invoked in practice. +const passthroughFilter: FilterFn = () => true; +const DEFAULT_FILTER_FNS = { + text: passthroughFilter, + select: passthroughFilter, + 'multi-select': passthroughFilter, + 'date-range': passthroughFilter, + 'date-range-period': passthroughFilter, +} as const; + +function hasChildColumns( + columnDef: ColumnDef +): columnDef is ColumnDef & { columns?: ColumnDef[] } { + return Array.isArray((columnDef as { columns?: ColumnDef[] }).columns); +} + +function TableBase(props: TableProps): JSX.Element { + const { + id, + data, + columns, + size = 'medium', + caption, + state, + defaultState, + onStateChange, + persist, + placeholder, + placeholderRole, + className, + children, + striped = false, + verticalBorders = false, + borderless = false, + stickyFirstColumn = false, + stickyHeader = false, + maxHeight, + onRowClick, + activeRowId, + rowHover, + enableRowSelection, + enableColumnFilters = false, + renderSubComponent, + getRowCanExpand, + getSubRows, + pagination: paginationProp, + manualPagination = false, + manualSorting = false, + manualFiltering = false, + pageCount, + rowCount, + } = props; + + const { getLabel } = useLabels(); + const resolvedPlaceholder = placeholder ?? getLabel('table.no-data'); + const getLabelRef = useRef(getLabel); + getLabelRef.current = getLabel; + + // Stable fallback id so two unidentified `
` / `` content horizontally (`align`) or vertically + * (`vAlign`) without wrapping every cell render in a styled span. + * + * Wrapper spans still work for the "I want this *one* cell to be different" case; + * `meta` covers the common "every cell in the column lines up the same way". + */ +export interface TableColumnMeta { + /** Accessible label used when the column header isn't a plain string. */ + label?: string; + /** + * Horizontal alignment applied to every header / body / footer cell in the column. + * Maps directly to `text-align`. Defaults to `left` (the table's CSS default). + */ + align?: 'left' | 'center' | 'right'; + /** + * Vertical alignment applied to every header / body / footer cell in the column. + * Maps directly to `vertical-align`. Defaults to `middle` for body cells via the + * table's stylesheet. + */ + vAlign?: 'top' | 'middle' | 'bottom'; +} + +/** + * Persistable state slices owned by Table. Each slice can be controlled via + * `state`/`onStateChange`, defaulted via `defaultState`, or persisted via + * `persist`. + */ +export interface TableState { + columnVisibility?: VisibilityState; + columnOrder?: ColumnOrderState; + rowOrder?: string[]; + columnSizing?: ColumnSizingState; + rowSelection?: RowSelectionState; + expanded?: ExpandedState; + columnFilters?: ColumnFiltersState; + sorting?: SortingState; + pagination?: PaginationState; +} + +export type TableSize = 'medium' | 'small'; + +export interface TablePersistOptions { + /** + * Storage key used to read/write persisted state. Must be stable per table. + */ + key: string; + /** + * Storage backend. Defaults to `window.localStorage` when available. + */ + storage?: Storage; + /** + * Subset of state slices to persist. Defaults to user-preference slices + * only: `columnVisibility`, `columnOrder`, `rowOrder`, `columnSizing`. + * Task-scoped slices (`rowSelection`, `expanded`, `columnFilters`, `sorting`, + * `pagination`) are deliberately excluded by default — pass them explicitly + * via `include` if you want them to survive across sessions. + */ + include?: (keyof TableState)[]; +} + +export interface TablePaginationOptions { + /** + * Rows per page. + * @default 10 + */ + pageSize?: number; + /** + * Options rendered in the built-in page-size selector. Pass `false` to hide + * the selector entirely. + * @default [10, 25, 50] + */ + pageSizeOptions?: number[] | false; +} + +export interface TableProps { + /** + * Unique identifier for the table. Used for accessibility and as the default + * persistence key namespace. + */ + id?: string; + /** + * Row data. Render order mirrors array order unless `rowOrder` is applied. + */ + data: TData[]; + /** + * Column definitions. Must include a stable `id` on every column when + * column visibility / reorder / persistence are used. + */ + columns: ColumnDef[]; + /** + * Visual size of the table. Matches Figma: `medium` = 49px rows, `small` = 41px rows. + * @default medium + */ + size?: TableSize; + /** + * Caption rendered above the table. Announced to assistive technology. + */ + caption?: ReactNode; + /** + * Alternating row backgrounds. + * @default false + */ + striped?: boolean; + /** + * Renders vertical separators between columns. + * @default false + */ + verticalBorders?: boolean; + /** + * Removes the outer border + radius around the table, keeping only internal + * row dividers. + * @default false + */ + borderless?: boolean; + /** + * Freezes the first column during horizontal scroll. + * @default false + */ + stickyFirstColumn?: boolean; + /** + * Pins the `
`s on the same page don't + // collide on the synthetic checkbox / expand / filter input ids. + const generatedId = useId(); + const resolvedId = id ?? generatedId; + + const paginationOptions = useMemo(() => { + if (!paginationProp) return null; + if (paginationProp === true) return { pageSize: 10, pageSizeOptions: [10, 25, 50] as number[] | false }; + return { + pageSize: paginationProp.pageSize ?? 10, + pageSizeOptions: + paginationProp.pageSizeOptions === undefined ? ([10, 25, 50] as number[]) : paginationProp.pageSizeOptions, + }; + }, [paginationProp]); + const paginationEnabled = paginationOptions !== null; + + const paginationPageSizeOptions = useMemo(() => { + const opts = paginationOptions?.pageSizeOptions; + return Array.isArray(opts) && opts.length > 0 ? opts : undefined; + }, [paginationOptions]); + + const [tableState, setTableState] = useTablePersistence({ + persist, + controlled: state, + defaultState, + onStateChange, + }); + + const handleVisibilityChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.columnVisibility ?? {}; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { columnVisibility: next }; + }); + }, + [setTableState] + ); + + const handleRowSelectionChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.rowSelection ?? {}; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { rowSelection: next }; + }); + }, + [setTableState] + ); + + const handleExpandedChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.expanded ?? {}; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { expanded: next }; + }); + }, + [setTableState] + ); + + const handleColumnFiltersChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.columnFilters ?? []; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { columnFilters: next }; + }); + }, + [setTableState] + ); + + const handleColumnOrderChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.columnOrder ?? []; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { columnOrder: next }; + }); + }, + [setTableState] + ); + + const handleSortingChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.sorting ?? []; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { sorting: next }; + }); + }, + [setTableState] + ); + + const handlePaginationChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous: PaginationState = prev.pagination ?? { + pageIndex: 0, + pageSize: paginationOptions?.pageSize ?? 10, + }; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { pagination: next }; + }); + }, + [setTableState, paginationOptions] + ); + + const hasExpansion = Boolean(renderSubComponent || getSubRows); + const hasSelection = Boolean(enableRowSelection); + + const coreRowModel = useMemo(() => getCoreRowModel(), []); + const filteredRowModel = useMemo(() => (manualFiltering ? undefined : getFilteredRowModel()), [manualFiltering]); + const sortedRowModel = useMemo(() => (manualSorting ? undefined : getSortedRowModel()), [manualSorting]); + const expandedRowModel = useMemo(() => (hasExpansion ? getExpandedRowModel() : undefined), [hasExpansion]); + const paginationRowModel = useMemo( + () => (paginationEnabled && !manualPagination ? getPaginationRowModel() : undefined), + [paginationEnabled, manualPagination] + ); + + const augmentedColumns = useMemo[]>(() => { + const leading: ColumnDef[] = []; + + if (hasSelection) { + leading.push({ + id: SELECT_COLUMN_ID, + enableSorting: false, + enableHiding: false, + enableColumnFilter: false, + size: 40, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(checked)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(checked)} + /> + ), + }); + } + + if (hasExpansion) { + leading.push({ + id: EXPAND_COLUMN_ID, + enableSorting: false, + enableHiding: false, + enableColumnFilter: false, + size: 40, + header: '', + cell: ({ row }) => { + if (!row.getCanExpand()) return null; + const subRowId = `${resolvedId}-sub-${row.id}`; + + return ( + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); + }} + > + row.toggleExpanded()} + > + {null} + + + ); + }, + }); + } + + return [...leading, ...columns]; + }, [columns, hasSelection, hasExpansion, resolvedId]); + + const fallbackRowSelection = useMemo(() => ({}), []); + const fallbackExpanded = useMemo(() => ({}), []); + const fallbackColumnFilters = useMemo(() => [], []); + const fallbackColumnOrder = useMemo(() => [], []); + const fallbackSorting = useMemo(() => [], []); + const fallbackPagination = useMemo( + () => ({ pageIndex: 0, pageSize: paginationOptions?.pageSize ?? 10 }), + [paginationOptions] + ); + + const table = useReactTable({ + data, + columns: augmentedColumns, + state: { + columnVisibility: tableState.columnVisibility, + columnOrder: tableState.columnOrder ?? fallbackColumnOrder, + rowSelection: tableState.rowSelection ?? fallbackRowSelection, + expanded: tableState.expanded ?? fallbackExpanded, + columnFilters: tableState.columnFilters ?? fallbackColumnFilters, + sorting: tableState.sorting ?? fallbackSorting, + pagination: paginationEnabled ? tableState.pagination ?? fallbackPagination : undefined, + }, + enableRowSelection, + enableColumnFilters, + manualPagination, + manualSorting, + manualFiltering, + // `pageCount` only matters in manual mode; TanStack ignores it otherwise. + pageCount: manualPagination && pageCount !== undefined ? pageCount : undefined, + rowCount: manualPagination && rowCount !== undefined ? rowCount : undefined, + getRowCanExpand: renderSubComponent ? getRowCanExpand ?? (() => true) : getRowCanExpand, + getSubRows, + onColumnVisibilityChange: handleVisibilityChange, + onColumnOrderChange: handleColumnOrderChange, + onRowSelectionChange: handleRowSelectionChange, + onExpandedChange: handleExpandedChange, + onColumnFiltersChange: handleColumnFiltersChange, + onSortingChange: handleSortingChange, + onPaginationChange: paginationEnabled ? handlePaginationChange : undefined, + filterFns: DEFAULT_FILTER_FNS, + getCoreRowModel: coreRowModel, + getFilteredRowModel: filteredRowModel, + getExpandedRowModel: expandedRowModel, + getSortedRowModel: sortedRowModel, + getPaginationRowModel: paginationRowModel, + }); + + const contextValue = useMemo>( + () => ({ table, size, id: resolvedId, state: tableState }), + [table, size, resolvedId, tableState] + ); + + const handlePaginationPageChange = useCallback((nextPage: number) => table.setPageIndex(nextPage - 1), [table]); + const handlePaginationPageSizeChange = useCallback((nextSize: number) => table.setPageSize(nextSize), [table]); + + const hasGroupedHeaders = table.getHeaderGroups().length > 1; + // Hover affordance follows interactivity by default — clickable rows always get it, + // read-only tables stay flat unless `rowHover` is explicitly opted into. + const hoverEnabled = rowHover ?? Boolean(onRowClick); + + const rootClassName = cn( + styles['tedi-table'], + styles[`tedi-table--${size}`], + { + [styles['tedi-table--striped']]: striped, + [styles['tedi-table--vertical-borders']]: verticalBorders, + [styles['tedi-table--borderless']]: borderless, + [styles['tedi-table--sticky-first-column']]: stickyFirstColumn, + [styles['tedi-table--sticky-header']]: stickyHeader, + [styles['tedi-table--clickable-rows']]: Boolean(onRowClick), + [styles['tedi-table--row-hover']]: hoverEnabled, + [styles['tedi-table--has-pagination']]: paginationEnabled, + [styles['tedi-table--grouped-headers']]: hasGroupedHeaders, + }, + className + ); + + const rows = table.getRowModel().rows; + const headerGroups = table.getHeaderGroups(); + const footerGroups = table.getFooterGroups(); + const leafColumns = table.getVisibleLeafColumns(); + const leafColumnCount = leafColumns.length; + const hasFooter = footerGroups.some((group) => + group.headers.some((header) => header.column.columnDef.footer !== undefined) + ); + + const handleRowKeyDown = (row: Row) => (event: KeyboardEvent) => { + if (!onRowClick) return; + if (event.target !== event.currentTarget) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onRowClick(row); + } + }; + + const headerRowCount = headerGroups.length + (enableColumnFilters ? 1 : 0); + const paginationState = table.getState().pagination; + const rowIndexOffset = paginationEnabled ? paginationState.pageIndex * paginationState.pageSize : 0; + const totalDataRowCount = paginationEnabled ? rowCount ?? table.getFilteredRowModel().rows.length : rows.length; + const ariaRowCount = paginationEnabled ? headerRowCount + totalDataRowCount : undefined; + + return ( + +
+ {children} +
+
0 ? leafColumnCount : undefined} + > + {caption && } + + {headerGroups.map((headerGroup, rowIndex) => ( + + {headerGroup.headers.map((header) => { + const isGroup = header.subHeaders.length > 0; + const hasParentGroup = Boolean(header.column.parent); + const columnHasChildren = hasChildColumns(header.column.columnDef); + const isStandaloneLeaf = !columnHasChildren && !hasParentGroup; + if (header.isPlaceholder && !isStandaloneLeaf) { + return null; + } + if (!header.isPlaceholder && isStandaloneLeaf && rowIndex > 0) { + return null; + } + const rowSpanCount = isStandaloneLeaf ? headerGroups.length - rowIndex : 1; + const sortDirection = header.column.getIsSorted(); + const ariaSort: 'ascending' | 'descending' | 'none' | undefined = header.column.getCanSort() + ? sortDirection === 'asc' + ? 'ascending' + : sortDirection === 'desc' + ? 'descending' + : 'none' + : undefined; + const headerMeta = header.column.columnDef.meta as TableColumnMeta | undefined; + const headerLabel = + headerMeta?.label ?? + (typeof header.column.columnDef.header === 'string' ? header.column.columnDef.header : undefined); + return ( + + ); + })} + + ))} + {enableColumnFilters && ( + + {leafColumns.map((column) => { + const meta = column.columnDef.meta as TableColumnMeta | undefined; + const headerLabel = + meta?.label ?? + (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id); + const filterId = `${resolvedId}-filter-${column.id}`; + + return ( + + ); + })} + + )} + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row, visibleIndex) => { + const clickable = Boolean(onRowClick); + const isActiveRow = activeRowId !== undefined && row.id === activeRowId; + const rowClassName = cn(styles['tedi-table__row'], { + [styles['tedi-table__row--selected']]: row.getIsSelected(), + [styles['tedi-table__row--active']]: isActiveRow, + [styles['tedi-table__row--clickable']]: clickable, + [styles['tedi-table__row--sub-row']]: row.depth > 0, + }); + const ariaRowIndex = paginationEnabled + ? headerRowCount + rowIndexOffset + visibleIndex + 1 + : undefined; + const subRowId = `${resolvedId}-sub-${row.id}`; + return ( + + onRowClick?.(row) : undefined} + onKeyDown={clickable ? handleRowKeyDown(row) : undefined} + tabIndex={clickable ? 0 : undefined} + role={clickable ? 'button' : undefined} + aria-rowindex={ariaRowIndex} + aria-current={isActiveRow ? 'true' : undefined} + > + {row.getVisibleCells().map((cell) => { + const cellMeta = cell.column.columnDef.meta as TableColumnMeta | undefined; + return ( + + ); + })} + + {renderSubComponent && row.getIsExpanded() && ( + + + + )} + + ); + }) + )} + + {hasFooter && ( + + {footerGroups.map((group) => ( + + {group.headers.map((header) => { + const footerMeta = header.column.columnDef.meta as TableColumnMeta | undefined; + return ( + + ); + })} + + ))} + + )} +
{caption}
1 ? rowSpanCount : undefined} + className={cn(styles['tedi-table__header-cell'], { + [styles['tedi-table__header-cell--group']]: isGroup, + [styles[`tedi-table__cell--align-${headerMeta?.align}`]]: headerMeta?.align, + [styles[`tedi-table__cell--valign-${headerMeta?.vAlign}`]]: headerMeta?.vAlign, + })} + scope="col" + aria-sort={ariaSort} + aria-label={headerLabel} + style={header.column.getSize() ? { width: header.column.getSize() } : undefined} + > + {flexRender(header.column.columnDef.header, header.getContext())} +
+ {column.getCanFilter() && ( + column.setFilterValue(next || undefined)} + /> + )} +
+ {placeholderRole ?
{resolvedPlaceholder}
: resolvedPlaceholder} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {renderSubComponent(row)} +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.footer, header.getContext())} +
+
+ {paginationEnabled && ( +
+ +
+ )} + + + ); +} + +TableBase.displayName = 'Table'; + +export const Table = Object.assign(TableBase, { + Toolbar: TableToolbar, + ColumnsMenu: TableColumnsMenu, + HeaderButton: TableHeaderButton, +}); + +export default Table; diff --git a/src/tedi/components/content/table/use-table-persistence.ts b/src/tedi/components/content/table/use-table-persistence.ts new file mode 100644 index 00000000..e2759e15 --- /dev/null +++ b/src/tedi/components/content/table/use-table-persistence.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; + +import type { TablePersistOptions, TableState } from './table'; + +/** + * State slices persisted by default when `persist` is configured without a + * custom `include` list. Limited to user-preference slices (column visibility + * / order / sizing). Task-scoped slices (selection, expanded, filters, sort, + * pagination) are intentionally excluded — they should reset between sessions. + */ +const DEFAULT_PERSISTED_KEYS: (keyof TableState)[] = ['columnVisibility', 'columnOrder', 'rowOrder', 'columnSizing']; + +function getStorage(options?: TablePersistOptions): Storage | null { + if (!options) return null; + if (options.storage) return options.storage; + if (typeof window === 'undefined') return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function readInitialState( + options: TablePersistOptions | undefined, + fallback: Partial +): Partial { + const storage = getStorage(options); + if (!storage || !options) return fallback; + try { + const raw = storage.getItem(options.key); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as Partial; + const include = options.include ?? DEFAULT_PERSISTED_KEYS; + const filtered: Partial = {}; + for (const key of include) { + if (parsed[key] !== undefined) filtered[key] = parsed[key] as never; + } + return { ...fallback, ...filtered }; + } catch { + return fallback; + } +} + +/** + * Owns the Table's internal state and (optionally) syncs it to a Storage backend. + * The hook always returns a fully-merged TableState so callers never have to + * reason about `undefined` slices. + */ +export type TableStatePatch = Partial | ((prev: TableState) => Partial); + +export function useTablePersistence(options: { + persist?: TablePersistOptions; + controlled?: Partial; + defaultState?: Partial; + onStateChange?: (state: TableState) => void; +}): [TableState, (next: TableStatePatch) => void] { + const { persist, controlled, defaultState, onStateChange } = options; + + const [internal, setInternal] = useState(() => readInitialState(persist, defaultState ?? {})); + + const state = useMemo(() => ({ ...internal, ...controlled }), [internal, controlled]); + + // `controlled` and `onStateChange` typically come from fresh object/closure + // identities on every parent render (consumers write `state={{ pagination, + // sorting }}` which rebuilds the object each time). Reading them through + // refs keeps `setState` a stable function across the table's lifetime — + // otherwise every downstream `useCallback` that depends on it churns, and + // TanStack sees fresh `onXxxChange` props every render. + const controlledRef = useRef(controlled); + controlledRef.current = controlled; + const onStateChangeRef = useRef(onStateChange); + onStateChangeRef.current = onStateChange; + const persistRef = useRef(persist); + persistRef.current = persist; + + const setState = useCallback((patchOrFn: TableStatePatch) => { + setInternal((prev) => { + const controlledNow = controlledRef.current; + const mergedPrev: TableState = { ...prev, ...controlledNow }; + const patch = typeof patchOrFn === 'function' ? patchOrFn(mergedPrev) : patchOrFn; + const next: TableState = { ...prev, ...patch }; + // `patch` must win over `controlled` so that the parent hears the new + // value when a controlled key (pagination, sorting, …) just changed. + // `controlled` then re-asserts ownership for keys the parent owns but + // didn't change in this update, while `prev` provides internal-only keys. + const merged: TableState = { ...prev, ...controlledNow, ...patch }; + + const current = persistRef.current; + const storage = getStorage(current); + if (storage && current) { + try { + const include = current.include ?? DEFAULT_PERSISTED_KEYS; + const persisted: Partial = {}; + for (const key of include) { + if (merged[key] !== undefined) persisted[key] = merged[key] as never; + } + storage.setItem(current.key, JSON.stringify(persisted)); + } catch { + // silently ignore quota / serialization errors + } + } + + onStateChangeRef.current?.(merged); + return next; + }); + }, []); + + return [state, setState]; +} diff --git a/src/tedi/components/form/select/select.module.scss b/src/tedi/components/form/select/select.module.scss index 011b51a4..1e4f5b99 100644 --- a/src/tedi/components/form/select/select.module.scss +++ b/src/tedi/components/form/select/select.module.scss @@ -143,13 +143,6 @@ div .tedi-select__menu { min-height: $input-height-small; padding: calc(var(--form-field-padding-y-sm) - 1px) var(--form-field-padding-x-md-default); font-size: var(--body-small-regular-size); - - @include breakpoints.media-breakpoint-down(md) { - min-height: $input-height; - padding-top: calc(var(--form-field-padding-y-md-default-top) - 1px); - padding-bottom: calc(var(--form-field-padding-y-md-default-bottom) - 1px); - font-size: var(--body-regular-size); - } } .tedi-select__input, diff --git a/src/tedi/components/navigation/pagination/pagination.module.scss b/src/tedi/components/navigation/pagination/pagination.module.scss index 84a4e94d..fb33a049 100644 --- a/src/tedi/components/navigation/pagination/pagination.module.scss +++ b/src/tedi/components/navigation/pagination/pagination.module.scss @@ -1,3 +1,5 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + .tedi-pagination { display: grid; grid-template-columns: 1fr auto 1fr; @@ -6,6 +8,11 @@ width: 100%; padding: var(--tedi-dimensions-07) var(--tedi-dimensions-09); border-top: 1px solid var(--general-border-secondary); + + @include breakpoints.media-breakpoint-down(md) { + grid-template-columns: 1fr; + gap: var(--layout-grid-gutters-08); + } } .tedi-pagination__slot-start { @@ -13,6 +20,11 @@ align-items: center; justify-self: start; min-width: 0; + + @include breakpoints.media-breakpoint-down(md) { + justify-content: center; + justify-self: center; + } } .tedi-pagination__slot-center { @@ -35,8 +47,15 @@ .tedi-pagination__nav { display: flex; + gap: var(--layout-grid-gutters-08); align-items: center; justify-content: center; + + @include breakpoints.media-breakpoint-down(md) { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--layout-grid-gutters-16); + } } .tedi-pagination__list { @@ -49,6 +68,16 @@ list-style: none; } +.tedi-pagination__page-jump { + display: inline-flex; + grid-column: 2; + justify-self: center; +} + +.tedi-pagination__page-jump-select { + min-width: var(--tedi-dimensions-18); +} + .tedi-pagination__item { display: inline-flex; align-items: center; @@ -56,7 +85,8 @@ } .tedi-pagination__item--ellipsis { - min-width: var(--tedi-dimensions-13); + width: var(--pagination-button-size); + height: var(--pagination-button-size); color: var(--general-text-secondary); user-select: none; } @@ -73,7 +103,6 @@ background: var(--button-main-neutral-icon-only-background-default); border: 1px solid var(--tedi-neutral-100); border-radius: 100%; - transition: all 250ms ease; &:hover:not(:disabled, .tedi-pagination__button--selected) { color: var(--button-main-neutral-text-hover); @@ -117,6 +146,30 @@ .tedi-pagination__button--nav { padding: 0; + + @include breakpoints.media-breakpoint-down(md) { + background: transparent; + border: 0; + + &:hover:not(:disabled, .tedi-pagination__button--selected), + &:active:not(:disabled, .tedi-pagination__button--selected) { + background: transparent; + } + } +} + +.tedi-pagination__button--nav-previous { + @include breakpoints.media-breakpoint-down(md) { + grid-column: 1; + justify-self: end; + } +} + +.tedi-pagination__button--nav-next { + @include breakpoints.media-breakpoint-down(md) { + grid-column: 3; + justify-self: start; + } } .tedi-pagination__page-size { @@ -133,3 +186,18 @@ .tedi-pagination__select { min-width: var(--tedi-dimensions-18); } + +@keyframes tedi-pagination-label-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.tedi-pagination__button-label { + display: inline-block; + animation: tedi-pagination-label-in 0s ease; +} diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx index 904c3c4a..8bee4063 100644 --- a/src/tedi/components/navigation/pagination/pagination.spec.tsx +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -6,6 +6,23 @@ import { usePagination } from './use-pagination'; import '@testing-library/jest-dom'; +let mockBreakpoint: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' = 'lg'; +const setMockBreakpoint = (next: typeof mockBreakpoint) => { + mockBreakpoint = next; +}; + +jest.mock('../../../helpers', () => { + const order = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + return { + useBreakpoint: () => mockBreakpoint, + isBreakpointBelow: (current: string, target: string) => order.indexOf(current) < order.indexOf(target), + useBreakpointProps: () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getCurrentBreakpointProps: (props: any) => ({ ...props }), + }), + }; +}); + jest.mock('../../../providers/label-provider', () => ({ useLabels: () => ({ getLabel: (key: string, ...args: unknown[]) => { @@ -33,6 +50,10 @@ jest.mock('../../../providers/label-provider', () => ({ }), })); +beforeEach(() => { + setMockBreakpoint('lg'); +}); + describe('usePagination', () => { it('returns an empty list when pageCount is 0', () => { expect(usePagination({ page: 1, pageCount: 0 })).toEqual([]); @@ -151,14 +172,14 @@ describe('Pagination component', () => { expect(screen.getByRole('button', { name: /Current page, page 4/i })).toBeInTheDocument(); }); - it('hides Previous on the first page and Next on the last', () => { + it('disables Previous on the first page and Next on the last so the layout footprint is stable', () => { const { rerender } = render(); - expect(screen.queryByRole('button', { name: /Previous page/i })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Next page/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Previous page/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeEnabled(); rerender(); - expect(screen.getByRole('button', { name: /Previous page/i })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Next page/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Previous page/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeDisabled(); }); it('Previous / Next move the current page by one', () => { @@ -230,8 +251,11 @@ describe('Pagination component', () => { }); it('omits the page-size selector when pageSizeOptions is empty', () => { - render(); - expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + const { container } = render(); + // The page-jump Select (mobile collapse) is always in the DOM but hidden + // via CSS at desktop widths — so we target the page-size select directly + // by its id prefix rather than asserting "no combobox anywhere". + expect(container.querySelector('[id^="tedi-pagination-page-size-"]')).not.toBeInTheDocument(); }); it('does not render the nav when pageCount <= 1', () => { @@ -267,4 +291,61 @@ describe('Pagination component', () => { expect(ref.current).toBeInstanceOf(HTMLDivElement); expect(ref.current).toHaveAttribute('data-name', 'tedi-pagination'); }); + + it('announces the current page to screen readers via a live region', () => { + const { container, rerender } = render(); + + const status = container.querySelector('[role="status"]'); + expect(status).not.toBeNull(); + expect(status).toHaveAttribute('aria-live', 'polite'); + expect(status?.textContent).toMatch(/(Page 1 of 10|pagination\.page-status)/); + + rerender(); + expect(container.querySelector('[role="status"]')?.textContent).toMatch(/(Page 4 of 10|pagination\.page-status)/); + }); + + describe('mobile (< md) layout', () => { + beforeEach(() => { + setMockBreakpoint('xs'); + }); + + it('renders the page-jump Select instead of the numbered list', () => { + const { container } = render(); + expect(screen.queryByRole('button', { name: /Go to page 5/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Current page, page 3/i })).not.toBeInTheDocument(); + expect(container.querySelector('[id^="tedi-pagination-jump-"]')).toBeInTheDocument(); + }); + + it('hides the page-size selector even when pageSizeOptions are provided', () => { + const { container } = render( + + ); + + expect(container.querySelector('[id^="tedi-pagination-page-size-"]')).not.toBeInTheDocument(); + }); + + it('still renders Previous / Next nav arrows when applicable', () => { + render(); + + expect(screen.getByRole('button', { name: /Previous page/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeInTheDocument(); + }); + + it('changes page when a new value is picked from the page-jump Select', async () => { + const onPageChange = jest.fn(); + render(); + + const combobox = screen.getByRole('combobox', { name: /Pagination/i }); + await act(async () => { + combobox.focus(); + fireEvent.keyDown(combobox, { key: 'ArrowDown', code: 'ArrowDown' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Dropdown options show just the page number. The trigger shows "1 / 5" (current/total), + // but the open menu lists plain "1", "2", "3", "4", "5". Match the option, not the trigger. + fireEvent.click(screen.getByRole('option', { name: '4' })); + expect(onPageChange).toHaveBeenCalledWith(4); + }); + }); }); diff --git a/src/tedi/components/navigation/pagination/pagination.tsx b/src/tedi/components/navigation/pagination/pagination.tsx index 05ebdc23..2e5cf99b 100644 --- a/src/tedi/components/navigation/pagination/pagination.tsx +++ b/src/tedi/components/navigation/pagination/pagination.tsx @@ -1,6 +1,7 @@ import cn from 'classnames'; import { forwardRef, useCallback, useId, useMemo, useState } from 'react'; +import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; import { Icon } from '../../base/icon/icon'; import { Text } from '../../base/typography/text/text'; @@ -52,6 +53,13 @@ export interface PaginationLabels { * @default 'Show per page' */ pageSize: string; + /** + * Builds the message announced to screen readers (via a visually-hidden + * `aria-live="polite"` region) whenever the current page changes. Lets SR + * users know that the page transition happened without sighted feedback. + * @default (page, pageCount) => `Page ${page} of ${pageCount}` + */ + pageStatus: (page: number, pageCount: number) => string; } export interface PaginationProps { /** @@ -137,6 +145,7 @@ export const Pagination = forwardRef((props, re currentPageAriaLabel: (pageNumber) => getLabel('pagination.page', pageNumber, true), results: (count) => getLabel('pagination.results', count), pageSize: getLabel('pagination.page-size'), + pageStatus: (pageNumber, total) => getLabel('pagination.page-status', pageNumber, total), ...labels, }), [getLabel, labels] @@ -186,11 +195,73 @@ export const Pagination = forwardRef((props, re const rootClassName = cn(styles['tedi-pagination'], className); + const breakpoint = useBreakpoint(); + const isMobile = isBreakpointBelow(breakpoint, 'md'); + const showResults = totalItems !== undefined; - const showPageSizeSelect = Array.isArray(pageSizeOptions) && pageSizeOptions.length > 0; + const showPageSizeSelect = !isMobile && Array.isArray(pageSizeOptions) && pageSizeOptions.length > 0; + + const previousItem = items[0]; + const nextItem = items[items.length - 1]; + const pageItems = items.slice(1, -1); + + const renderArrow = (item: PaginationItem) => { + const label = item.type === 'previous' ? mergedLabels.previous : mergedLabels.next; + const iconName = item.type === 'previous' ? 'arrow_back' : 'arrow_forward'; + + return ( + + ); + }; + + // Dropdown options carry plain page numbers — "1", "2", "3" — so the open menu reads as + // a clean list of jump targets rather than repeating the total on every row. + const pageJumpOptions = useMemo( + () => + Array.from({ length: pageCount }, (_, idx) => { + const pageNumber = idx + 1; + return { value: String(pageNumber), label: String(pageNumber) }; + }), + [pageCount] + ); + + // The trigger (closed-state value) shows the current page in context — "1 / 10" — so the + // user can see at a glance where they are and how many pages exist. react-select picks + // the active option in the dropdown by `value` equality, so the divergent label is OK. + const currentPageJumpOption = useMemo( + () => ({ value: String(currentPage), label: `${currentPage} / ${pageCount}` }), + [currentPage, pageCount] + ); + + const handlePageJumpChange = useCallback( + (value: TSelectValue) => { + const option = Array.isArray(value) ? value[0] : value; + if (option && 'value' in option) { + handlePageChange(Number(option.value)); + } + }, + [handlePageChange] + ); return (
+ + {pageCount > 1 ? mergedLabels.pageStatus(currentPage, pageCount) : ''} + +
{showResults && ( @@ -202,63 +273,78 @@ export const Pagination = forwardRef((props, re
{pageCount > 1 && (