From 56cfdbc2833fb112306dff11317b9796401e4c08 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:42:55 +0300 Subject: [PATCH 01/32] feat(pagination): new TEDI-Ready Pagination component #20 --- .../components/navigation/pagination/index.ts | 4 + .../pagination/pagination.module.scss | 148 +++++++++++ .../navigation/pagination/pagination.spec.tsx | 239 ++++++++++++++++++ .../pagination/pagination.stories.tsx | 167 ++++++++++++ .../navigation/pagination/pagination.tsx | 196 ++++++++++++++ .../navigation/pagination/pagination.types.ts | 108 ++++++++ .../pagination/usage-with-router.mdx | 71 ++++++ .../navigation/pagination/use-pagination.ts | 106 ++++++++ src/tedi/index.ts | 1 + 9 files changed, 1040 insertions(+) create mode 100644 src/tedi/components/navigation/pagination/index.ts create mode 100644 src/tedi/components/navigation/pagination/pagination.module.scss create mode 100644 src/tedi/components/navigation/pagination/pagination.spec.tsx create mode 100644 src/tedi/components/navigation/pagination/pagination.stories.tsx create mode 100644 src/tedi/components/navigation/pagination/pagination.tsx create mode 100644 src/tedi/components/navigation/pagination/pagination.types.ts create mode 100644 src/tedi/components/navigation/pagination/usage-with-router.mdx create mode 100644 src/tedi/components/navigation/pagination/use-pagination.ts diff --git a/src/tedi/components/navigation/pagination/index.ts b/src/tedi/components/navigation/pagination/index.ts new file mode 100644 index 00000000..76c62326 --- /dev/null +++ b/src/tedi/components/navigation/pagination/index.ts @@ -0,0 +1,4 @@ +export { Pagination } from './pagination'; +export type { PaginationItem, PaginationItemType, PaginationLabels, PaginationProps } from './pagination.types'; +export { usePagination } from './use-pagination'; +export type { UsePaginationArgs } from './use-pagination'; diff --git a/src/tedi/components/navigation/pagination/pagination.module.scss b/src/tedi/components/navigation/pagination/pagination.module.scss new file mode 100644 index 00000000..9b3fa98c --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.module.scss @@ -0,0 +1,148 @@ +.tedi-pagination { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--tedi-dimensions-10); + align-items: center; + width: 100%; + padding: var(--tedi-dimensions-07) var(--tedi-dimensions-09); + border-top: 1px solid var(--general-border-secondary); +} + +.tedi-pagination__slot-start { + display: inline-flex; + align-items: center; + justify-self: start; + min-width: 0; +} + +.tedi-pagination__slot-center { + display: inline-flex; + align-items: center; + justify-self: center; +} + +.tedi-pagination__slot-end { + display: inline-flex; + align-items: center; + justify-self: end; + min-width: 0; +} + +.tedi-pagination__results { + flex: 0 0 auto; + white-space: nowrap; +} + +.tedi-pagination__nav { + display: flex; + align-items: center; + justify-content: center; +} + +.tedi-pagination__list { + display: flex; + flex-wrap: wrap; + gap: var(--tedi-dimensions-03); + align-items: center; + padding: 0; + margin: 0; + list-style: none; +} + +.tedi-pagination__item { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.tedi-pagination__item--ellipsis { + min-width: var(--tedi-dimensions-13); + font-size: var(--body-small-size); + line-height: var(--body-small-line-height); + color: var(--general-text-secondary); + user-select: none; +} + +.tedi-pagination__button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--tedi-dimensions-13); + height: var(--tedi-dimensions-13); + padding: 0 var(--tedi-dimensions-04); + font-family: inherit; + font-size: var(--body-small-size); + font-weight: var(--body-regular-weight); + line-height: var(--body-small-line-height); + color: var(--general-text-secondary); + cursor: pointer; + background: var(--button-main-neutral-icon-only-background-default); + border: 1px solid transparent; + border-radius: 360px; + transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease; + + &:hover:not(:disabled, .tedi-pagination__button--selected) { + color: var(--button-main-neutral-text-hover); + background: var(--button-main-neutral-icon-only-background-hover); + } + + &:active:not(:disabled, .tedi-pagination__button--selected) { + color: var(--button-main-neutral-text-active); + background: var(--button-main-neutral-icon-only-background-active); + } + + &:focus-visible:not(:disabled, .tedi-pagination__button--selected) { + color: var(--button-main-neutral-text-focus); + background: var(--button-main-neutral-icon-only-background-focus); + outline: none; + box-shadow: 0 0 0 var(--tedi-borders-01) var(--tedi-neutral-100), + 0 0 0 calc(var(--tedi-borders-01) + var(--tedi-borders-03)) var(--button-main-primary-background-focus); + } + + &:disabled { + color: var(--general-text-disabled); + cursor: not-allowed; + } +} + +.tedi-pagination__button--selected { + color: var(--general-text-white); + background: var(--general-surface-brand-secondary); + border-color: var(--general-surface-brand-secondary); + + &:hover:not(:disabled) { + background: var(--general-surface-brand-secondary); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--tedi-borders-01) var(--tedi-neutral-100), + 0 0 0 calc(var(--tedi-borders-01) + var(--tedi-borders-03)) var(--button-main-primary-background-focus); + } +} + +.tedi-pagination__button--nav { + padding: 0; +} + +.tedi-pagination--medium { + .tedi-pagination__button { + min-width: var(--tedi-dimensions-14); + height: var(--tedi-dimensions-14); + } +} + +.tedi-pagination__page-size { + display: inline-flex; + flex: 0 0 auto; + gap: var(--tedi-dimensions-05); + align-items: center; +} + +.tedi-pagination__page-size-label { + white-space: nowrap; +} + +.tedi-pagination__select { + min-width: var(--tedi-dimensions-18); +} diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx new file mode 100644 index 00000000..04f32347 --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -0,0 +1,239 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { useState } from 'react'; + +import { Pagination } from './pagination'; +import { usePagination } from './use-pagination'; + +import '@testing-library/jest-dom'; + +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 === 1 ? 'result' : 'results'; + } + case 'pagination.page-size': + return 'Page size'; + default: + return key; + } + }, + }), +})); + +describe('usePagination', () => { + it('returns an empty list when pageCount is 0', () => { + expect(usePagination({ page: 1, pageCount: 0 })).toEqual([]); + }); + + it('renders every page when pageCount is small enough', () => { + const items = usePagination({ page: 2, pageCount: 4 }); + const pages = items.filter((item) => item.type === 'page').map((item) => item.page); + expect(pages).toEqual([1, 2, 3, 4]); + expect(items.some((item) => item.type === 'ellipsis')).toBe(false); + }); + + it('inserts an ellipsis on each side when the active page is in the middle', () => { + const items = usePagination({ page: 20, pageCount: 40, boundaryCount: 1, siblingCount: 1 }); + const ellipses = items.filter((item) => item.type === 'ellipsis'); + expect(ellipses).toHaveLength(2); + }); + + it('marks the current page as selected', () => { + const items = usePagination({ page: 3, pageCount: 10 }); + const selected = items.find((item) => item.selected); + expect(selected?.page).toBe(3); + }); + + it('clamps out-of-range page inputs', () => { + const low = usePagination({ page: -5, pageCount: 10 }); + const high = usePagination({ page: 99, pageCount: 10 }); + + expect(low.find((i) => i.selected)?.page).toBe(1); + expect(high.find((i) => i.selected)?.page).toBe(10); + }); + + it('disables the Previous button on the first page and Next on the last', () => { + const first = usePagination({ page: 1, pageCount: 10 }); + const last = usePagination({ page: 10, pageCount: 10 }); + + expect(first[0]).toEqual(expect.objectContaining({ type: 'previous', disabled: true })); + expect(last[last.length - 1]).toEqual(expect.objectContaining({ type: 'next', disabled: true })); + }); +}); + +describe('Pagination component', () => { + it('renders numeric page buttons and marks the current one with aria-current', () => { + render(); + + const current = screen.getByRole('button', { name: /Current page, page 3/i }); + expect(current).toHaveAttribute('aria-current', 'page'); + + expect(screen.getByRole('button', { name: /Go to page 1/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Go to page 5/i })).toBeInTheDocument(); + }); + + it('renders Previous + Next nav buttons', () => { + render(); + expect(screen.getByRole('button', { name: /Previous page/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeInTheDocument(); + }); + + it('fires onPageChange when a page button is clicked (uncontrolled)', () => { + const onPageChange = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Go to page 3/i })); + expect(onPageChange).toHaveBeenCalledWith(3); + + expect(screen.getByRole('button', { name: /Current page, page 3/i })).toBeInTheDocument(); + }); + + it('respects controlled page and only updates when the prop changes', () => { + const Wrapper = () => { + const [page, setPage] = useState(2); + return ( + <> + + + + ); + }; + + render(); + expect(screen.getByRole('button', { name: /Current page, page 2/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'jump' })); + expect(screen.getByRole('button', { name: /Current page, page 4/i })).toBeInTheDocument(); + }); + + it('disables Previous on the first page and Next on the last', () => { + const { rerender } = render(); + 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 })).toBeEnabled(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeDisabled(); + }); + + it('Previous / Next move the current page by one', () => { + const onPageChange = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Next page/i })); + expect(onPageChange).toHaveBeenLastCalledWith(3); + + fireEvent.click(screen.getByRole('button', { name: /Previous page/i })); + expect(onPageChange).toHaveBeenLastCalledWith(2); + }); + + it('renders ellipses for large page counts', () => { + const { container } = render(); + const ellipses = container.querySelectorAll('[aria-hidden="true"]'); + expect(ellipses.length).toBeGreaterThanOrEqual(2); + }); + + it('renders the results label when totalItems is set', () => { + render(); + expect(screen.getByText('97 results')).toBeInTheDocument(); + }); + + it('allows overriding labels for localisation', () => { + render( + `${count} tulemust`, + }} + /> + ); + + expect(screen.getByRole('button', { name: 'Eelmine' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Järgmine' })).toBeInTheDocument(); + expect(screen.getByText('28 tulemust')).toBeInTheDocument(); + }); + + it('renders the page-size selector when pageSizeOptions is provided', async () => { + const onPageSizeChange = jest.fn(); + render( + + ); + + const combobox = screen.getByRole('combobox', { name: /Page size/i }); + expect(combobox).toBeInTheDocument(); + // The current page size label is rendered inside the Select + expect(screen.getByText('25')).toBeInTheDocument(); + + await act(async () => { + combobox.focus(); + fireEvent.keyDown(combobox, { key: 'ArrowDown', code: 'ArrowDown' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + fireEvent.click(screen.getByText('50')); + expect(onPageSizeChange).toHaveBeenCalledWith(50); + }); + + it('omits the page-size selector when pageSizeOptions is empty', () => { + render(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('does not render the nav when pageCount <= 1', () => { + render(); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + // results label still renders + expect(screen.getByText('3 results')).toBeInTheDocument(); + }); + + it('applies the medium size class when size="medium"', () => { + const { container } = render(); + const nav = container.querySelector('[data-name="tedi-pagination"]'); + expect(nav?.className).toMatch(/--medium/); + }); + + it('applies a custom className', () => { + const { container } = render(); + expect(container.querySelector('[data-name="tedi-pagination"]')?.className).toContain('my-pagination'); + }); + + it('ignores clicks on the current page (no-op)', () => { + const onPageChange = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /Current page, page 3/i })); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('renders ellipsis placeholders with aria-hidden', () => { + const { container } = render(); + const ellipses = container.querySelectorAll('[aria-hidden="true"]'); + ellipses.forEach((el) => { + expect(within(el as HTMLElement).queryByRole('button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/tedi/components/navigation/pagination/pagination.stories.tsx b/src/tedi/components/navigation/pagination/pagination.stories.tsx new file mode 100644 index 00000000..b71b0b08 --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { Pagination } from './pagination'; +import type { PaginationProps } from './pagination.types'; + +/** + * Navigation between paginated sets of content. Renders a row of page buttons + * with optional results label and page-size selector. + * + * Figma ↗ + */ +const meta: Meta = { + component: Pagination, + title: 'TEDI-Ready/Components/Navigation/Pagination', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=8478-72385&m=dev', + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageCount: 10, + defaultPage: 3, + }, +}; + +export const First: Story = { + args: { + pageCount: 10, + page: 1, + }, +}; + +export const Last: Story = { + args: { + pageCount: 10, + page: 10, + }, +}; + +const AllPropertiesTemplate = () => { + const [page, setPage] = useState(3); + const [pageSize, setPageSize] = useState(10); + return ( + { + setPageSize(next); + setPage(1); + }} + /> + ); +}; +export const AllPropertiesShown: Story = { render: () => }; + +const WithoutResultsNumberTemplate = () => { + const [page, setPage] = useState(3); + const [pageSize, setPageSize] = useState(10); + return ( + { + setPageSize(next); + setPage(1); + }} + /> + ); +}; +export const WithoutResultsNumber: Story = { render: () => }; + +export const WithoutDropdown: Story = { + args: { + pageCount: 10, + defaultPage: 3, + totalItems: 97, + }, +}; + +/** + * Controlled mode — the consumer owns `page` state explicitly. + */ +const ControlledTemplate = () => { + const [page, setPage] = useState(3); + return ; +}; +export const ControlledPage: Story = { render: () => }; + +/** + * Small page count — every page number is rendered (no ellipsis). + */ +export const FewPages: Story = { + args: { + pageCount: 4, + defaultPage: 2, + }, +}; + +/** + * Large page count — ellipsis collapses the middle pages around the active one. + */ +export const ManyPagesEllipsis: Story = { + args: { + pageCount: 50, + defaultPage: 12, + }, +}; + +export const SmallSize: Story = { + args: { + pageCount: 10, + defaultPage: 5, + size: 'small', + }, +}; + +/** + * Boundary and sibling tuning — keep more neighbours visible around the active + * page. Useful for dense layouts where users rarely paginate one-at-a-time. + */ +export const WiderSiblings: Story = { + args: { + pageCount: 40, + defaultPage: 20, + siblingCount: 2, + boundaryCount: 2, + }, +}; + +/** + * Localised via the `labels` prop. Note: the default labels already come from + * the LabelProvider (`pagination.*` keys), so changing app locale is enough + * for most cases — this story only demonstrates per-instance overrides. + */ +export const CustomLabels: Story = { + args: { + pageCount: 10, + defaultPage: 1, + totalItems: 97, + pageSize: 10, + pageSizeOptions: [10, 25, 50], + labels: { + ariaLabel: 'Lehekülgede sirvimine', + previous: 'Eelmine lehekülg', + next: 'Järgmine lehekülg', + pageAriaLabel: (page) => `Mine leheküljele ${page}`, + currentPageAriaLabel: (page) => `Praegune lehekülg, lehekülg ${page}`, + results: (count) => `${count} tulemust`, + pageSize: 'Kuva korraga', + }, + }, +}; diff --git a/src/tedi/components/navigation/pagination/pagination.tsx b/src/tedi/components/navigation/pagination/pagination.tsx new file mode 100644 index 00000000..ad6b407a --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.tsx @@ -0,0 +1,196 @@ +import cn from 'classnames'; +import { useCallback, useId, useMemo, useState } from 'react'; + +import { useLabels } from '../../../providers/label-provider'; +import { Icon } from '../../base/icon/icon'; +import { Text } from '../../base/typography/text/text'; +import Button from '../../buttons/button/button'; +import { type ISelectOption, Select, type TSelectValue } from '../../form/select/select'; +import styles from './pagination.module.scss'; +import type { PaginationLabels, PaginationProps } from './pagination.types'; +import { usePagination } from './use-pagination'; + +export const Pagination = (props: PaginationProps): JSX.Element => { + const { + pageCount, + page, + defaultPage = 1, + onPageChange, + totalItems, + pageSize, + pageSizeOptions, + onPageSizeChange, + boundaryCount = 1, + siblingCount = 1, + size = 'medium', + labels, + className, + } = props; + + const { getLabel } = useLabels(); + + const mergedLabels = useMemo( + () => ({ + ariaLabel: getLabel('pagination.title'), + previous: getLabel('pagination.prev-page'), + next: getLabel('pagination.next-page'), + pageAriaLabel: (pageNumber) => getLabel('pagination.page', pageNumber, false), + currentPageAriaLabel: (pageNumber) => getLabel('pagination.page', pageNumber, true), + results: (count) => `${count} ${getLabel('pagination.results', count)}`, + pageSize: getLabel('pagination.page-size'), + ...labels, + }), + [getLabel, labels] + ); + + const [uncontrolledPage, setUncontrolledPage] = useState(defaultPage); + const currentPage = page ?? uncontrolledPage; + + const items = usePagination({ page: currentPage, pageCount, boundaryCount, siblingCount }); + + const handlePageChange = useCallback( + (nextPage: number) => { + if (nextPage === currentPage || nextPage < 1 || nextPage > pageCount) return; + if (page === undefined) setUncontrolledPage(nextPage); + onPageChange?.(nextPage); + }, + [currentPage, onPageChange, page, pageCount] + ); + + const selectId = useId(); + + const pageSizeSelectOptions = useMemo( + () => + (pageSizeOptions ?? []).map((option) => ({ + value: String(option), + label: String(option), + })), + [pageSizeOptions] + ); + + const currentPageSizeOption = useMemo(() => { + if (pageSize === undefined) return pageSizeSelectOptions[0] ?? null; + return pageSizeSelectOptions.find((option) => option.value === String(pageSize)) ?? null; + }, [pageSize, pageSizeSelectOptions]); + + const handlePageSizeChange = useCallback( + (value: TSelectValue) => { + const option = Array.isArray(value) ? value[0] : value; + if (option && 'value' in option) { + onPageSizeChange?.(Number(option.value)); + } + }, + [onPageSizeChange] + ); + + const rootClassName = cn(styles['tedi-pagination'], styles[`tedi-pagination--${size}`], className); + + const showResults = totalItems !== undefined; + const showPageSizeSelect = Array.isArray(pageSizeOptions) && pageSizeOptions.length > 0; + + return ( +
+
+ {showResults && ( + + {mergedLabels.results(totalItems)} + + )} +
+ +
+ {pageCount > 1 && ( + + )} +
+ +
+ {showPageSizeSelect && ( +
+ + {mergedLabels.pageSize} + + ` would also fire onRowClick. + if (event.target !== event.currentTarget) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onRowClick(row); } }; + // ARIA row indexing for paginated tables — lets SR users hear "row 47 of + // 200" instead of "row 7 of 10" on the current page. Includes header rows + // (each `` in ``) in the count per the ARIA spec. + 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); @@ -378,6 +404,14 @@ function TableBase(props: TableProps): JSX.Element { return null; } const rowSpanCount = header.isPlaceholder ? 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; return ( ))} {enableColumnFilters && ( - + {leafColumns.map((column) => { + // Prefer the column's `meta.label` (consumer-supplied + // friendly name) → string header → raw column id. Render-fn + // headers fall through to meta/id so the filter input + // doesn't read out a machine-id to SR users. + const meta = column.columnDef.meta as { label?: string } | undefined; const headerLabel = - typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; + meta?.label ?? + (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id); const filterId = `${resolvedId}-filter-${column.id}`; return ( @@ -408,7 +452,7 @@ function TableBase(props: TableProps): JSX.Element { (props: TableProps): JSX.Element { ) : ( - rows.map((row) => { + rows.map((row, visibleIndex) => { const clickable = Boolean(onRowClick); const rowClassName = cn(styles['tedi-table__row'], { [styles['tedi-table__row--selected']]: row.getIsSelected(), [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 ( (props: TableProps): JSX.Element { onKeyDown={clickable ? handleRowKeyDown(row) : undefined} tabIndex={clickable ? 0 : undefined} role={clickable ? 'button' : undefined} - aria-selected={row.getIsSelected() || undefined} + aria-rowindex={ariaRowIndex} > {row.getVisibleCells().map((cell) => ( ` in ``) in the count per the ARIA spec. const headerRowCount = headerGroups.length + (enableColumnFilters ? 1 : 0); const paginationState = table.getState().pagination; const rowIndexOffset = paginationEnabled ? paginationState.pageIndex * paginationState.pageSize : 0; @@ -451,10 +440,6 @@ function TableBase(props: TableProps): JSX.Element { aria-rowindex={paginationEnabled ? headerGroups.length + 1 : undefined} > {leafColumns.map((column) => { - // Prefer the column's `meta.label` (consumer-supplied - // friendly name) → string header → raw column id. Render-fn - // headers fall through to meta/id so the filter input - // doesn't read out a machine-id to SR users. const meta = column.columnDef.meta as { label?: string } | undefined; const headerLabel = meta?.label ?? diff --git a/src/tedi/components/content/table/table.types.ts b/src/tedi/components/content/table/table.types.ts index 4ad29cc6..79b66464 100644 --- a/src/tedi/components/content/table/table.types.ts +++ b/src/tedi/components/content/table/table.types.ts @@ -82,7 +82,7 @@ export interface TableProps { columns: ColumnDef[]; /** * Visual size of the table. Matches Figma: `medium` = 49px rows, `small` = 41px rows. - * @default 'medium' + * @default medium */ size?: TableSize; /** diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx index 85b6aa4d..3aabf5ab 100644 --- a/src/tedi/components/navigation/pagination/pagination.spec.tsx +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -322,5 +322,20 @@ describe('Pagination component', () => { 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)); + }); + + fireEvent.click(screen.getByText('4 / 5')); + expect(onPageChange).toHaveBeenCalledWith(4); + }); }); }); From d777e429d4ffa1be69a177c39dc58213818d9771 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 12 May 2026 12:29:10 +0300 Subject: [PATCH 22/32] fix(table): cr fixes #122 --- src/tedi/components/content/table/table.spec.tsx | 10 +++++++++- src/tedi/components/content/table/table.stories.tsx | 2 -- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx index a5e08c0e..d6ba57ae 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -319,7 +319,15 @@ describe('Table', () => { ).not.toThrow(); expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); } finally { - if (original) Object.defineProperty(window, 'localStorage', original); + 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; + } } }); diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index bb8b9aa1..09b88692 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -1872,8 +1872,6 @@ export const ServerSide: Story = { margin: 0, padding: 'var(--tedi-dimensions-12)', background: 'var(--general-surface-secondary)', - borderRadius: 'var(--tedi-borders-radius-default, 4px)', - fontFamily: 'var(--family-mono, monospace)', fontSize: 'var(--body-small-regular-size)', whiteSpace: 'pre-wrap', }} From da529554103cf2ec491f93c587ce31556efe71f3 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 12 May 2026 14:23:44 +0300 Subject: [PATCH 23/32] feat(table): improve screenreader problems, align component more with the others #122 --- src/tedi/components/content/table/index.ts | 2 +- .../content/table/table-context.tsx | 2 +- .../table/table-toolbar/table-toolbar.tsx | 28 ++ .../content/table/table.module.scss | 19 ++ .../components/content/table/table.spec.tsx | 56 +++- .../content/table/table.stories.tsx | 38 ++- src/tedi/components/content/table/table.tsx | 272 +++++++++++++++++- .../components/content/table/table.types.ts | 229 --------------- .../content/table/use-table-persistence.ts | 2 +- .../navigation/pagination/pagination.spec.tsx | 16 +- .../navigation/pagination/pagination.tsx | 12 + .../providers/label-provider/labels-map.ts | 8 + 12 files changed, 433 insertions(+), 251 deletions(-) create mode 100644 src/tedi/components/content/table/table-toolbar/table-toolbar.tsx delete mode 100644 src/tedi/components/content/table/table.types.ts diff --git a/src/tedi/components/content/table/index.ts b/src/tedi/components/content/table/index.ts index 75220fe7..217d047c 100644 --- a/src/tedi/components/content/table/index.ts +++ b/src/tedi/components/content/table/index.ts @@ -1,6 +1,6 @@ export * from './table'; -export * from './table.types'; 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-context.tsx b/src/tedi/components/content/table/table-context.tsx index b00b7b9f..866a4c5d 100644 --- a/src/tedi/components/content/table/table-context.tsx +++ b/src/tedi/components/content/table/table-context.tsx @@ -1,6 +1,6 @@ import { createContext, useContext } from 'react'; -import type { TableContextValue } from './table.types'; +import type { TableContextValue } from './table'; export const TableContext = createContext(null); 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 `
{caption}
(props: TableProps): JSX.Element { [styles['tedi-table__header-cell--group']]: isGroup, })} scope="col" + aria-sort={ariaSort} style={header.column.getSize() ? { width: header.column.getSize() } : undefined} > {flexRender(header.column.columnDef.header, header.getContext())} @@ -396,10 +431,19 @@ function TableBase(props: TableProps): JSX.Element {
@@ -459,6 +507,9 @@ function TableBase(props: TableProps): JSX.Element { {renderSubComponent && row.getIsExpanded() && (
diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index b4d16265..7d8abaf2 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -344,6 +344,20 @@ export const labelsMap = validateDefaultLabels({ en: 'Filter…', ru: 'Фильтр…', }, + 'table.filter-input': { + description: 'Accessible label for the per-column filter input. Receives the column label.', + components: ['Table'], + et: (columnLabel?: string) => `Filtreeri veergu ${columnLabel ?? ''}`.trim(), + en: (columnLabel?: string) => `Filter ${columnLabel ?? 'column'}`.trim(), + ru: (columnLabel?: string) => `Фильтр ${columnLabel ?? ''}`.trim(), + }, + 'table.row-details': { + description: 'Accessible label for the sub-component / disclosure panel of an expanded row.', + components: ['Table'], + et: 'Rea üksikasjad', + en: 'Row details', + ru: 'Сведения о строке', + }, 'table.columns': { description: 'Default label on the `Table.ColumnsMenu` trigger (column-visibility menu).', components: ['TableColumnsMenu'], From 4f3109df40d7e89014818d5ff19e54058559f5d3 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 12 May 2026 09:09:33 +0300 Subject: [PATCH 18/32] fix(table): add draggable rows/columns examples #122 --- .../content/table/table.stories.tsx | 206 +++++++++++++++++- src/tedi/components/content/table/table.tsx | 15 ++ 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index b480e81a..7b4aab5d 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -1,5 +1,23 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + 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 } from '@tanstack/react-table'; +import type { ColumnDef, ColumnOrderState } from '@tanstack/react-table'; import { useMemo, useState } from 'react'; import { Icon } from '../../base/icon/icon'; @@ -1560,6 +1578,192 @@ export const WithColumnsMenu: Story = { ), }; +// --------------------------------------------------------------------------- +// 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). +// --------------------------------------------------------------------------- + +/** + * 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 ` + ); +}; + +const DraggableRowsTemplate = () => { + // Story owns its own reorderable copy of `people` so drag-end can mutate it. + const [rows, setRows] = useState(() => people.slice(0, 8)); + 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 handleDragEnd = (event: DragEndEvent) => { + 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); + }); + }; + + 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} /> + + + + ); +}; + +/** + * 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. + * + * For server-backed data, persist the new order ids in the `rowOrder` state + * slice and re-derive `data` from the server response on next fetch. + */ +export const DraggableRows: Story = { render: () => }; + +const DraggableColumnsTemplate = () => { + 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 sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + // Wrap each header in a drag handle so the column can be picked up from the + // header cell itself. We re-derive the columns array whenever `columnOrder` + // changes so the handle's id matches the column we're dragging. + const columns = useMemo[]>( + () => + baseColumns.map((column) => ({ + ...column, + header: ({ column: ctxColumn }) => ( + + + {column.header as string} + + ), + })), + [baseColumns] + ); + + const handleDragEnd = (event: DragEndEvent) => { + 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); + }); + }; + + 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); + }} + /> + + + + ); +}; + +/** + * 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. + */ +export const DraggableColumns: Story = { render: () => }; + /** * Server-side pagination + sorting demo. `manualPagination` / `manualSorting` * tell the Table not to slice or re-order `data` locally; the parent owns diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index 3fd2f16c..a816c888 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -1,6 +1,7 @@ import { type ColumnDef, type ColumnFiltersState, + type ColumnOrderState, type ExpandedState, type FilterFn, flexRender, @@ -164,6 +165,17 @@ function TableBase(props: TableProps): JSX.Element { [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) => { @@ -283,6 +295,7 @@ function TableBase(props: TableProps): JSX.Element { const fallbackRowSelection = useMemo(() => ({}), []); const fallbackExpanded = useMemo(() => ({}), []); const fallbackColumnFilters = useMemo(() => [], []); + const fallbackColumnOrder = useMemo(() => [], []); const fallbackSorting = useMemo(() => [], []); const fallbackPagination = useMemo( () => ({ pageIndex: 0, pageSize: paginationOptions?.pageSize ?? 10 }), @@ -294,6 +307,7 @@ function TableBase(props: TableProps): JSX.Element { columns: augmentedColumns, state: { columnVisibility: tableState.columnVisibility, + columnOrder: tableState.columnOrder ?? fallbackColumnOrder, rowSelection: tableState.rowSelection ?? fallbackRowSelection, expanded: tableState.expanded ?? fallbackExpanded, columnFilters: tableState.columnFilters ?? fallbackColumnFilters, @@ -311,6 +325,7 @@ function TableBase(props: TableProps): JSX.Element { getRowCanExpand: renderSubComponent ? getRowCanExpand ?? (() => true) : getRowCanExpand, getSubRows, onColumnVisibilityChange: handleVisibilityChange, + onColumnOrderChange: handleColumnOrderChange, onRowSelectionChange: handleRowSelectionChange, onExpandedChange: handleExpandedChange, onColumnFiltersChange: handleColumnFiltersChange, From 02f604360ec9d8422d3595bb2e091fd8e711ce40 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 12 May 2026 10:13:24 +0300 Subject: [PATCH 19/32] fix(table): improve pagination responsive #122 --- .../components/content/table/table.spec.tsx | 7 +- .../content/table/table.stories.tsx | 35 ++++++- .../components/form/select/select.module.scss | 7 -- .../pagination/pagination.module.scss | 67 ++++++++++++++ .../navigation/pagination/pagination.spec.tsx | 7 +- .../navigation/pagination/pagination.tsx | 92 ++++++++++++++----- 6 files changed, 180 insertions(+), 35 deletions(-) diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx index 87250e35..5a22c5ad 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -472,7 +472,7 @@ describe('Table', () => { }); it('hides the page-size selector when pageSizeOptions is false', () => { - render( + const { container } = render( id="t-page-no-select" data={many} @@ -481,7 +481,10 @@ describe('Table', () => { /> ); - expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + // 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', () => { diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 7b4aab5d..f2f23038 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -1679,8 +1679,24 @@ const DraggableRowsTemplate = () => { * 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. * - * For server-backed data, persist the new order ids in the `rowOrder` state - * slice and re-derive `data` from the server response on next fetch. + * **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: () => }; @@ -1761,6 +1777,21 @@ const DraggableColumnsTemplate = () => { * 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: () => }; 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..2d910ad5 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 { @@ -26,6 +38,10 @@ align-items: center; justify-self: end; min-width: 0; + + @include breakpoints.media-breakpoint-down(md) { + display: none; + } } .tedi-pagination__results { @@ -35,8 +51,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 +72,26 @@ list-style: none; } +.tedi-pagination__list--desktop { + @include breakpoints.media-breakpoint-down(md) { + display: none; + } +} + +.tedi-pagination__page-jump { + display: none; + + @include breakpoints.media-breakpoint-down(md) { + 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; @@ -117,6 +160,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 { diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx index 904c3c4a..8a061b36 100644 --- a/src/tedi/components/navigation/pagination/pagination.spec.tsx +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -230,8 +230,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', () => { diff --git a/src/tedi/components/navigation/pagination/pagination.tsx b/src/tedi/components/navigation/pagination/pagination.tsx index 05ebdc23..9a0d24bc 100644 --- a/src/tedi/components/navigation/pagination/pagination.tsx +++ b/src/tedi/components/navigation/pagination/pagination.tsx @@ -189,6 +189,55 @@ export const Pagination = forwardRef((props, re const showResults = totalItems !== undefined; const showPageSizeSelect = 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) => { + if (item.disabled) return null; + const label = item.type === 'previous' ? mergedLabels.previous : mergedLabels.next; + const iconName = item.type === 'previous' ? 'arrow_back' : 'arrow_forward'; + return ( + + ); + }; + + const pageJumpOptions = useMemo( + () => + Array.from({ length: pageCount }, (_, idx) => { + const pageNumber = idx + 1; + return { value: String(pageNumber), label: `${pageNumber} / ${pageCount}` }; + }), + [pageCount] + ); + + const currentPageJumpOption = useMemo( + () => pageJumpOptions.find((option) => option.value === String(currentPage)) ?? null, + [pageJumpOptions, currentPage] + ); + + const handlePageJumpChange = useCallback( + (value: TSelectValue) => { + const option = Array.isArray(value) ? value[0] : value; + if (option && 'value' in option) { + handlePageChange(Number(option.value)); + } + }, + [handlePageChange] + ); + return (
@@ -202,8 +251,10 @@ export const Pagination = forwardRef((props, re
{pageCount > 1 && (
` uses `border-collapse: collapse`, which drops `border-right` - // on `position: sticky` cells (the collapsed border is owned by the - // neighbouring cell, which scrolls away). Use an inset `box-shadow` instead - // so the divider is painted inside the sticky cell and travels with it. .tedi-table__row > .tedi-table__header-cell:first-child { position: sticky; left: 0; diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx index f494e4f6..a5e08c0e 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -1,9 +1,10 @@ import type { ColumnDef } from '@tanstack/react-table'; import { act, fireEvent, render, screen } from '@testing-library/react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Table } from './table'; import type { TableState } from './table.types'; +import { useTableContext } from './table-context'; import '@testing-library/jest-dom'; @@ -302,6 +303,46 @@ describe('Table', () => { ).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); + } + }); + + 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', () => { @@ -552,4 +593,109 @@ describe('Table', () => { 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); + + 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 index f0bfa16c..bb8b9aa1 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -197,111 +197,6 @@ const nameLinkStyle: React.CSSProperties = { fontWeight: 'var(--body-regular-weight)', }; -/** - * Three "simple" table layouts side-by-side, mirroring the Figma "Simple" - * frame: a bookings list with an edit action, a people list with linked - * names + status badges, and a doctor list with a multi-line first cell. - * Same chrome (borders, pagination), different content patterns. - */ -const SimpleTemplate = () => { - const bookingColumns = useMemo[]>( - () => [ - { id: 'dateRange', header: 'Kuupäev', accessorKey: 'dateRange' }, - { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, - { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, - { id: 'location', header: 'Asukoht', accessorKey: 'location' }, - { - id: 'actions', - header: '', - cell: () => ( - event.preventDefault()} style={editLinkStyle}> - - Muuda - - ), - }, - ], - [] - ); - - const peopleColumns = useMemo[]>( - () => [ - { - id: 'name', - header: 'Isik', - accessorKey: 'name', - cell: ({ row }) => ( - event.preventDefault()} style={nameLinkStyle}> - {row.original.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} - ), - }, - ], - [] - ); - - const doctorColumns = useMemo[]>( - () => [ - { - 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' }, - { - id: 'actions', - header: '', - cell: () => ( - event.preventDefault()} style={editLinkStyle}> - - Muuda - - ), - }, - ], - [] - ); - - return ( - - - id="tedi-table-simple-bookings" - data={bookings} - columns={bookingColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - id="tedi-table-simple-people" - data={filterablePeople} - columns={peopleColumns} - pagination={SHOWCASE_PAGINATION_4} - /> - - id="tedi-table-simple-doctors" - data={doctors} - columns={doctorColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - ); -}; - /** Shared columns for the Default and Sizes stories (Figma "Sizes" frame). */ const bookingShowcaseColumns: ColumnDef[] = [ { id: 'dateRange', header: 'Kuupäev', accessorKey: 'dateRange' }, @@ -340,29 +235,136 @@ export const Default: Story = { * `default` and `small` variants of the same booking columns so the * difference in row/header padding is easy to compare. */ -const SizesTemplate = () => ( - - Default - - id="tedi-table-sizes-default" - data={bookings} - columns={bookingShowcaseColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - Small - - id="tedi-table-sizes-small" - data={bookings} - columns={bookingShowcaseColumns} - size="small" - pagination={SHOWCASE_PAGINATION_3} - /> - -); +export const Sizes: Story = { + render: function Sizes() { + return ( + + Default + + id="tedi-table-sizes-default" + data={bookings} + columns={bookingShowcaseColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + Small + + id="tedi-table-sizes-small" + data={bookings} + columns={bookingShowcaseColumns} + size="small" + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; + +/** + * Three "simple" table layouts side-by-side, mirroring the Figma "Simple" + * frame: a bookings list with an edit action, a people list with linked + * names + status badges, and a doctor list with a multi-line first cell. + * Same chrome (borders, pagination), different content patterns. + */ +export const Simple: Story = { + render: function Simple() { + const bookingColumns = useMemo[]>( + () => [ + { id: 'dateRange', header: 'Kuupäev', accessorKey: 'dateRange' }, + { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, + { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, + { id: 'location', header: 'Asukoht', accessorKey: 'location' }, + { + id: 'actions', + header: '', + cell: () => ( + event.preventDefault()} style={editLinkStyle}> + + Muuda + + ), + }, + ], + [] + ); -export const Sizes: Story = { render: () => }; + const peopleColumns = useMemo[]>( + () => [ + { + id: 'name', + header: 'Isik', + accessorKey: 'name', + cell: ({ row }) => ( + event.preventDefault()} style={nameLinkStyle}> + {row.original.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} + ), + }, + ], + [] + ); + + const doctorColumns = useMemo[]>( + () => [ + { + 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' }, + { + id: 'actions', + header: '', + cell: () => ( + event.preventDefault()} style={editLinkStyle}> + + Muuda + + ), + }, + ], + [] + ); -export const Simple: Story = { render: () => }; + return ( + + + id="tedi-table-simple-bookings" + data={bookings} + columns={bookingColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + id="tedi-table-simple-people" + data={filterablePeople} + columns={peopleColumns} + pagination={SHOWCASE_PAGINATION_4} + /> + + id="tedi-table-simple-doctors" + data={doctors} + columns={doctorColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; /** * Two long-text patterns from the Figma "Long texts" frame. Top table puts @@ -402,62 +404,6 @@ const baseDoctorWithDescriptionColumns = (): ColumnDef[] => [ }, ]; -const LongTextsTemplate = () => { - // Block variant: the "Show more" Button sits on its own line below the - // truncated paragraph (matches the top table in the Figma frame). Forced - // via `style: { display: 'block' }` on the underlying Button. - const blockColumns = useMemo[]>( - () => [ - baseDoctorWithDescriptionColumns()[0], - { - id: 'description', - header: 'Kirjeldus', - cell: () => ( - - {LONG_DESCRIPTION} - - ), - }, - baseDoctorWithDescriptionColumns()[1], - baseDoctorWithDescriptionColumns()[2], - ], - [] - ); - - // Inline variant: default Truncate renders the toggle Button inline at the - // end of the truncated text (matches the bottom table in the Figma frame). - const inlineColumns = useMemo[]>( - () => [ - baseDoctorWithDescriptionColumns()[0], - { - id: 'description', - header: 'Kirjeldus', - cell: () => {LONG_DESCRIPTION}, - }, - baseDoctorWithDescriptionColumns()[1], - baseDoctorWithDescriptionColumns()[2], - ], - [] - ); - - return ( - - - id="tedi-table-long-texts-block" - data={doctors} - columns={blockColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - id="tedi-table-long-texts-inline" - data={doctors} - columns={inlineColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - ); -}; - /** * Two row-action patterns from the Figma "Actions" frame. Top table puts * separate edit + delete icon buttons on each row. Bottom table collapses @@ -486,64 +432,6 @@ const rowActionsCellStyle: React.CSSProperties = { width: '100%', }; -const ActionsTemplate = () => { - const editDeleteColumns = useMemo[]>( - () => [ - ...baseDoctorActionsColumns(), - { - id: 'actions', - header: '', - cell: () => ( - - - - - ), - }, - ], - [] - ); - - const kebabColumns = useMemo[]>( - () => [ - ...baseDoctorActionsColumns(), - { - id: 'actions', - header: '', - cell: () => ( - - - - ), - }, - ], - [] - ); - - return ( - - - id="tedi-table-actions-edit-delete" - data={doctors} - columns={editDeleteColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - id="tedi-table-actions-kebab" - data={doctors} - columns={kebabColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - ); -}; - /** * "Custom" Figma frame: a tip alert plus a table with custom-rendered cells — * avatar circle next to the name, a status note column with coloured @@ -609,137 +497,78 @@ const initialsOf = (name: string) => .slice(0, 2) .join(''); -const CustomTemplate = () => { - 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: '', - cell: () => ( - - - - - ), - }, - ], - [] - ); - - return ( - - - id="tedi-table-custom" - data={customDoctors} - columns={columns} - pagination={SHOWCASE_PAGINATION_3} - /> - - ); -}; - -const MergedCellsTemplate = () => { - const columns = useMemo[]>( - () => [ - { - id: 'dateRange', - accessorKey: 'dateRange', - header: ({ column }) => { - const sorted = column.getIsSorted(); - const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; - return ( - - Kuupäev - - - ); - }, - }, - { - id: 'aeg', - header: 'Aeg', - columns: [ - { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, - { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, - ], - }, - { id: 'location', header: 'Asukoht', accessorKey: 'location' }, - { - id: 'actions', - header: '', - cell: () => ( - event.preventDefault()} - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 4, - color: 'var(--link-primary-default)', - textDecoration: 'none', - fontWeight: 'var(--body-regular-weight)', - }} - > - - Muuda - - ), - }, - ], - [] - ); - - return ( - - id="tedi-table-merged" - verticalBorders - data={bookings} - columns={columns} - pagination={DEFAULT_PAGINATION} - /> - ); -}; - /** * Two-level header using column groups. Nest column definitions under `columns` inside a parent * `columnDef` — TanStack Table will render the parent as a merged header cell spanning its children. */ -export const MergedCells: Story = { render: () => }; +export const MergedCells: Story = { + render: function MergedCells() { + const columns = useMemo[]>( + () => [ + { + id: 'dateRange', + accessorKey: 'dateRange', + header: ({ column }) => { + const sorted = column.getIsSorted(); + const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; + return ( + + Kuupäev + + + ); + }, + }, + { + id: 'aeg', + header: 'Aeg', + columns: [ + { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, + { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, + ], + }, + { id: 'location', header: 'Asukoht', accessorKey: 'location' }, + { + id: 'actions', + header: '', + cell: () => ( + event.preventDefault()} + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: 4, + color: 'var(--link-primary-default)', + textDecoration: 'none', + fontWeight: 'var(--body-regular-weight)', + }} + > + + Muuda + + ), + }, + ], + [] + ); + + return ( + + id="tedi-table-merged" + verticalBorders + data={bookings} + columns={columns} + pagination={DEFAULT_PAGINATION} + /> + ); + }, +}; /** * Column separator lines via `verticalBorders`. Combine with `borderless` if the outer border @@ -809,149 +638,149 @@ const editActionsStyle: React.CSSProperties = { gap: 8, }; -const EditableTemplate = () => { - const [rows, setRows] = useState(editableBookingsSeed); - const [editingId, setEditingId] = useState(null); - const [draft, setDraft] = useState(null); - - const beginEdit = (row: EditableBooking) => { - setEditingId(row.id); - setDraft(row); - }; - const cancelEdit = () => { - setEditingId(null); - setDraft(null); - }; - const commitEdit = () => { - if (!draft) return; - setRows((current) => current.map((row) => (row.id === draft.id ? draft : row))); - setEditingId(null); - setDraft(null); - }; - - const columns = useMemo[]>( - () => [ - { - id: 'dateRange', - header: 'Kuupäev', - accessorKey: 'dateRange', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return row.original.dateRange; - return ( - setDraft((prev) => (prev ? { ...prev, dateRange: next } : prev))} - /> - ); +/** + * Row-level inline editing: clicking "Muuda" replaces static cells with `TextField` inputs and + * swaps the action column for confirm/cancel buttons. Track `editingId` + a `draft` copy of the row + * in local state; commit or discard on button click. Only one row edits at a time. + */ +export const EditableValues: Story = { + render: function EditableValues() { + const [rows, setRows] = useState(editableBookingsSeed); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(null); + + const beginEdit = (row: EditableBooking) => { + setEditingId(row.id); + setDraft(row); + }; + const cancelEdit = () => { + setEditingId(null); + setDraft(null); + }; + const commitEdit = () => { + if (!draft) return; + setRows((current) => current.map((row) => (row.id === draft.id ? draft : row))); + setEditingId(null); + setDraft(null); + }; + + const columns = useMemo[]>( + () => [ + { + id: 'dateRange', + header: 'Kuupäev', + accessorKey: 'dateRange', + cell: ({ row }) => { + if (row.original.id !== editingId || !draft) return row.original.dateRange; + return ( + setDraft((prev) => (prev ? { ...prev, dateRange: next } : prev))} + /> + ); + }, }, - }, - { - id: 'hour', - header: 'Kellaaeg', - accessorKey: 'hour', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return row.original.hour; - return ( - setDraft((prev) => (prev ? { ...prev, hour: next } : prev))} - /> - ); + { + id: 'hour', + header: 'Kellaaeg', + accessorKey: 'hour', + cell: ({ row }) => { + if (row.original.id !== editingId || !draft) return row.original.hour; + return ( + setDraft((prev) => (prev ? { ...prev, hour: next } : prev))} + /> + ); + }, }, - }, - { - id: 'duration', - header: 'Kestus', - accessorKey: 'duration', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return `${row.original.duration} min`; - return ( - setDraft((prev) => (prev ? { ...prev, duration: next } : prev))} - /> - ); + { + id: 'duration', + header: 'Kestus', + accessorKey: 'duration', + cell: ({ row }) => { + if (row.original.id !== editingId || !draft) return `${row.original.duration} min`; + return ( + setDraft((prev) => (prev ? { ...prev, duration: next } : prev))} + /> + ); + }, }, - }, - { - id: 'location', - header: 'Asukoht', - accessorKey: 'location', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return row.original.location; - return ( - setDraft((prev) => (prev ? { ...prev, location: next } : prev))} - /> - ); + { + id: 'location', + header: 'Asukoht', + accessorKey: 'location', + cell: ({ row }) => { + if (row.original.id !== editingId || !draft) return row.original.location; + return ( + setDraft((prev) => (prev ? { ...prev, location: next } : prev))} + /> + ); + }, }, - }, - { - id: 'actions', - header: '', - cell: ({ row }) => { - if (row.original.id === editingId) { + { + id: 'actions', + header: '', + cell: ({ row }) => { + if (row.original.id === editingId) { + return ( + + + + + ); + } return ( - - - - + { + event.preventDefault(); + beginEdit(row.original); + }} + style={muudaLinkStyle} + > + + Muuda + ); - } - return ( - { - event.preventDefault(); - beginEdit(row.original); - }} - style={muudaLinkStyle} - > - - Muuda - - ); + }, }, - }, - ], - [draft, editingId] - ); + ], + [draft, editingId] + ); - return ( - id="tedi-table-editable" data={rows} columns={columns} pagination={DEFAULT_PAGINATION} /> - ); + return ( + id="tedi-table-editable" data={rows} columns={columns} pagination={DEFAULT_PAGINATION} /> + ); + }, }; -/** - * Row-level inline editing: clicking "Muuda" replaces static cells with `TextField` inputs and - * swaps the action column for confirm/cancel buttons. Track `editingId` + a `draft` copy of the row - * in local state; commit or discard on button click. Only one row edits at a time. - */ -export const EditableValues: Story = { render: () => }; - /** * Sortable columns — header shows a compact sort chevron that cycles through * `unsorted → ascending → descending → unsorted` on click. Matches the Figma @@ -959,44 +788,44 @@ export const EditableValues: Story = { render: () => }; * `columnDef.enableSorting` (default `true`); only columns with * `enableSorting: false` render as plain text. */ -const SortableTemplate = () => { - 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} />; -}; - /** * 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: () => }; +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} />; + }, +}; /** * Per-column filter popovers — matches Figma Example table 7/8 exactly @@ -1245,110 +1074,110 @@ const parseDate = (value: string): number | null => { return Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd)); }; -const FiltersTemplate = () => { - 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} - /> - ); -}; - /** * 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: () => }; +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; @@ -1431,28 +1260,28 @@ export const SelectableRows: Story = { ), }; -const ClickableTemplate = () => { - const [clicked, setClicked] = useState(null); - return ( - <> - {clicked ? `You clicked ${clicked}` : 'Click a row to select it.'} - - id="tedi-table-clickable" - data={people} - columns={personColumns} - onRowClick={(row) => setClicked(row.original.name)} - 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. */ -export const ClickableRows: Story = { render: () => }; +export const ClickableRows: Story = { + render: function ClickableRows() { + const [clicked, setClicked] = useState(null); + return ( + <> + {clicked ? `You clicked ${clicked}` : 'Click a row to select it.'} + + id="tedi-table-clickable" + data={people} + columns={personColumns} + onRowClick={(row) => setClicked(row.original.name)} + pagination={DEFAULT_PAGINATION} + /> + + ); + }, +}; /** * Alternating row background color via `striped`. Helps readability in wide or dense tables. @@ -1515,7 +1344,63 @@ export const WithEmptyState: Story = { * chevron). Bottom table inlines the link at the end of the truncated text * (underlined, no icon). Tip alert in the middle explains the trade-off. */ -export const LongTexts: Story = { render: () => }; +export const LongTexts: Story = { + render: function LongTexts() { + // Block variant: the "Show more" Button sits on its own line below the + // truncated paragraph (matches the top table in the Figma frame). Forced + // via `style: { display: 'block' }` on the underlying Button. + const blockColumns = useMemo[]>( + () => [ + baseDoctorWithDescriptionColumns()[0], + { + id: 'description', + header: 'Kirjeldus', + cell: () => ( + + {LONG_DESCRIPTION} + + ), + }, + baseDoctorWithDescriptionColumns()[1], + baseDoctorWithDescriptionColumns()[2], + ], + [] + ); + + // Inline variant: default Truncate renders the toggle Button inline at the + // end of the truncated text (matches the bottom table in the Figma frame). + const inlineColumns = useMemo[]>( + () => [ + baseDoctorWithDescriptionColumns()[0], + { + id: 'description', + header: 'Kirjeldus', + cell: () => {LONG_DESCRIPTION}, + }, + baseDoctorWithDescriptionColumns()[1], + baseDoctorWithDescriptionColumns()[2], + ], + [] + ); + + return ( + + + id="tedi-table-long-texts-block" + data={doctors} + columns={blockColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + id="tedi-table-long-texts-inline" + data={doctors} + columns={inlineColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; /** * Two row-action patterns from the Figma "Actions" frame. Top table puts @@ -1523,7 +1408,65 @@ export const LongTexts: Story = { render: () => }; * the same affordances into a single kebab (`more_vert`) button — typical * pattern when the row is dense or has many possible actions. */ -export const Actions: Story = { render: () => }; +export const Actions: Story = { + render: function Actions() { + const editDeleteColumns = useMemo[]>( + () => [ + ...baseDoctorActionsColumns(), + { + id: 'actions', + header: '', + cell: () => ( + + + + + ), + }, + ], + [] + ); + + const kebabColumns = useMemo[]>( + () => [ + ...baseDoctorActionsColumns(), + { + id: 'actions', + header: '', + cell: () => ( + + + + ), + }, + ], + [] + ); + + return ( + + + id="tedi-table-actions-edit-delete" + data={doctors} + columns={editDeleteColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + id="tedi-table-actions-kebab" + data={doctors} + columns={kebabColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; /** * "Custom" Figma frame: a tip alert plus a table with custom-rendered cells — @@ -1531,7 +1474,66 @@ export const Actions: Story = { render: () => }; * tags, and the same edit/delete row actions from the Actions showcase. * Demonstrates that any column can return arbitrary JSX. */ -export const Custom: Story = { render: () => }; +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: '', + cell: () => ( + + + + + ), + }, + ], + [] + ); + + return ( + + + id="tedi-table-custom" + data={customDoctors} + columns={columns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, +}; // ───────────────────────────────────────────────────────────────────────────── // Stories not present in the Figma "Types" frame — kept after the Figma-driven @@ -1621,60 +1623,6 @@ const DragHandle = ({ id, label }: { id: string; label: string }) => { ); }; -const DraggableRowsTemplate = () => { - // Story owns its own reorderable copy of `people` so drag-end can mutate it. - const [rows, setRows] = useState(() => people.slice(0, 8)); - 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 handleDragEnd = (event: DragEndEvent) => { - 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); - }); - }; - - 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} /> - - - - ); -}; - /** * 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. @@ -1698,79 +1646,60 @@ const DraggableRowsTemplate = () => { * server (or in `rowOrder`) and re-derive `data` from the response on the * next fetch. */ -export const DraggableRows: Story = { render: () => }; - -const DraggableColumnsTemplate = () => { - 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 sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ); +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 sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); - // Wrap each header in a drag handle so the column can be picked up from the - // header cell itself. We re-derive the columns array whenever `columnOrder` - // changes so the handle's id matches the column we're dragging. - const columns = useMemo[]>( - () => - baseColumns.map((column) => ({ - ...column, - header: ({ column: ctxColumn }) => ( - - - {column.header as string} - - ), - })), - [baseColumns] - ); + 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 handleDragEnd = (event: DragEndEvent) => { - 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 handleDragEnd = (event: DragEndEvent) => { + 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); + }); + }; - 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); - }} - /> - - - - ); + 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} /> + + + + ); + }, }; /** @@ -1793,7 +1722,80 @@ const DraggableColumnsTemplate = () => { * `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: () => }; +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 sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + // Wrap each header in a drag handle so the column can be picked up from the + // header cell itself. We re-derive the columns array whenever `columnOrder` + // changes so the handle's id matches the column we're dragging. + const columns = useMemo[]>( + () => + baseColumns.map((column) => ({ + ...column, + header: ({ column: ctxColumn }) => ( + + + {column.header as string} + + ), + })) as ColumnDef[], + [baseColumns] + ); + + const handleDragEnd = (event: DragEndEvent) => { + 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); + }); + }; + + 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); + }} + /> + + + + ); + }, +}; /** * Server-side pagination + sorting demo. `manualPagination` / `manualSorting` @@ -1809,73 +1811,74 @@ export const DraggableColumns: Story = { render: () => { - 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] - ); +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[], - [] - ); + 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 });
+    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.
@@ -1895,35 +1898,28 @@ const { data: page, total } = useServerQuery({ pagination, 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] }} - /> -
- ); +
+ + + 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] }} + /> +
+ ); + }, }; - -/** - * API-driven pagination and sorting. Pass `manualPagination` + `manualSorting` so TanStack Table - * skips in-memory work; supply `pageCount` / `rowCount` from the server response; control - * `state.pagination` and `state.sorting`; refetch in `onStateChange` when either changes. - * `manualFiltering` works the same way for column filters. - */ -export const ServerSide: Story = { render: () => }; diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index a816c888..6504ff2a 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -84,12 +84,6 @@ function TableBase(props: TableProps): JSX.Element { const { getLabel } = useLabels(); const resolvedPlaceholder = placeholder ?? getLabel('table.no-data'); - - // `getLabel` from `useLabels` is meant to be stable, but tests (and some - // consumer setups) hand back a fresh reference each render. Reading it - // through a ref lets the header / cell closures pick up the latest locale - // without invalidating the `augmentedColumns` memo every render — which - // would otherwise hand TanStack a new columns array and reset row state. const getLabelRef = useRef(getLabel); getLabelRef.current = getLabel; @@ -375,8 +369,6 @@ function TableBase(props: TableProps): JSX.Element { const handleRowKeyDown = (row: Row) => (event: KeyboardEvent) => { if (!onRowClick) return; - // Only activate the row when focus is on the row itself — without this - // gate a Space press inside an inner `` would also fire onRowClick. if (event.target !== event.currentTarget) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); @@ -384,9 +376,6 @@ function TableBase(props: TableProps): JSX.Element { } }; - // ARIA row indexing for paginated tables — lets SR users hear "row 47 of - // 200" instead of "row 7 of 10" on the current page. Includes header rows - // (each `
`. 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 index 6b3469b6..8e4ae202 100644 --- a/src/tedi/components/content/table/table.module.scss +++ b/src/tedi/components/content/table/table.module.scss @@ -200,3 +200,22 @@ 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 index d6ba57ae..62e68756 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -2,8 +2,8 @@ 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 type { TableState } from './table.types'; import { useTableContext } from './table-context'; import '@testing-library/jest-dom'; @@ -83,12 +83,65 @@ describe('Table', () => { 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"]'); @@ -359,6 +412,7 @@ describe('Table', () => { ['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) => { diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 09b88692..1638bf26 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -33,8 +33,8 @@ import { Alert } from '../../notifications/alert/alert'; import { Popover, PopoverContent, PopoverTrigger } from '../../overlays/popover'; import { StatusBadge, type StatusBadgeColor } from '../../tags/status-badge/status-badge'; import { Truncate } from '../truncate/truncate'; +import type { TableProps } from './table'; import { Table } from './table'; -import type { TableProps } from './table.types'; /** * @tanstack/react-table ↗
@@ -1317,6 +1317,42 @@ export const StickyFirstColumn: Story = { ), }; +/** + * 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={people} + columns={personColumns} + stickyHeader + stickyFirstColumn + maxHeight={280} + /> +
+ ), +}; + /** * Empty-state rendering: when `data` is empty, Table falls back to the * `placeholder` prop. Passing an `` node produces the richer diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index 6504ff2a..283d7624 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -2,6 +2,7 @@ import { type ColumnDef, type ColumnFiltersState, type ColumnOrderState, + type ColumnSizingState, type ExpandedState, type FilterFn, flexRender, @@ -15,11 +16,12 @@ import { 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, useCallback, useId, useMemo, useRef } from 'react'; +import { Fragment, type KeyboardEvent, type ReactNode, useCallback, useId, useMemo, useRef } from 'react'; import { useLabels } from '../../../providers/label-provider'; import { Collapse } from '../../buttons/collapse/collapse'; @@ -27,12 +29,253 @@ import { Checkbox } from '../../form/checkbox/checkbox'; import { TextField } from '../../form/textfield/textfield'; import { Pagination } from '../../navigation/pagination'; import styles from './table.module.scss'; -import type { TableContextValue, TableProps } from './table.types'; 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'; +/** + * 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 `
` 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; + /** + * 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; +} + const SELECT_COLUMN_ID = '__select__'; const EXPAND_COLUMN_ID = '__expand__'; @@ -62,12 +305,15 @@ function TableBase(props: TableProps): JSX.Element { onStateChange, persist, placeholder, + placeholderRole, className, children, striped = false, verticalBorders = false, borderless = false, stickyFirstColumn = false, + stickyHeader = false, + maxHeight, onRowClick, enableRowSelection, enableColumnFilters = false, @@ -351,6 +597,7 @@ function TableBase(props: TableProps): JSX.Element { [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--has-pagination']]: paginationEnabled, [styles['tedi-table--grouped-headers']]: hasGroupedHeaders, @@ -386,7 +633,10 @@ function TableBase(props: TableProps): JSX.Element {
{children} -
+
(props: TableProps): JSX.Element { ? 'descending' : 'none' : undefined; + const headerMeta = header.column.columnDef.meta as { label?: string } | undefined; + const headerLabel = + headerMeta?.label ?? + (typeof header.column.columnDef.header === 'string' ? header.column.columnDef.header : undefined); return ( ) : ( @@ -561,15 +816,6 @@ function TableBase(props: TableProps): JSX.Element { TableBase.displayName = 'Table'; -/** - * Optional slot rendered above the `
(props: TableProps): JSX.Element { })} scope="col" aria-sort={ariaSort} + aria-label={headerLabel} style={header.column.getSize() ? { width: header.column.getSize() } : undefined} > {flexRender(header.column.columnDef.header, header.getContext())} @@ -473,7 +728,7 @@ function TableBase(props: TableProps): JSX.Element { colSpan={Math.max(1, leafColumnCount)} className={cn(styles['tedi-table__cell'], styles['tedi-table__cell--placeholder'])} > - {resolvedPlaceholder} + {placeholderRole ?
{resolvedPlaceholder}
: resolvedPlaceholder}
`. Hosts controls like - * ``. Nothing clever — it just provides consistent spacing. - */ -const TableToolbar = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( -
{children}
-); -TableToolbar.displayName = 'Table.Toolbar'; - export const Table = Object.assign(TableBase, { Toolbar: TableToolbar, ColumnsMenu: TableColumnsMenu, diff --git a/src/tedi/components/content/table/table.types.ts b/src/tedi/components/content/table/table.types.ts deleted file mode 100644 index 79b66464..00000000 --- a/src/tedi/components/content/table/table.types.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { - ColumnDef, - ColumnFiltersState, - ColumnOrderState, - ColumnSizingState, - ExpandedState, - PaginationState, - Row, - RowSelectionState, - SortingState, - Table as ReactTable, - VisibilityState, -} from '@tanstack/react-table'; -import type React from 'react'; - -/** - * 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?: React.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; - /** - * 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; - /** - * 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) => React.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?: React.ReactNode; - /** - * Additional class name on the root element. - */ - className?: string; - /** - * Toolbar + Table subcomponents such as ``. - */ - children?: React.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; -} diff --git a/src/tedi/components/content/table/use-table-persistence.ts b/src/tedi/components/content/table/use-table-persistence.ts index 44492dde..e2759e15 100644 --- a/src/tedi/components/content/table/use-table-persistence.ts +++ b/src/tedi/components/content/table/use-table-persistence.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react'; -import type { TablePersistOptions, TableState } from './table.types'; +import type { TablePersistOptions, TableState } from './table'; /** * State slices persisted by default when `persist` is configured without a diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx index 3aabf5ab..f8f5e97b 100644 --- a/src/tedi/components/navigation/pagination/pagination.spec.tsx +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -292,6 +292,18 @@ describe('Pagination component', () => { 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'); @@ -299,12 +311,8 @@ describe('Pagination component', () => { it('renders the page-jump Select instead of the numbered list', () => { const { container } = render(); - - // Numbered page buttons should NOT render on mobile. expect(screen.queryByRole('button', { name: /Go to page 5/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Current page, page 3/i })).not.toBeInTheDocument(); - - // The page-jump Select is in the DOM, identified by its stable id prefix. expect(container.querySelector('[id^="tedi-pagination-jump-"]')).toBeInTheDocument(); }); diff --git a/src/tedi/components/navigation/pagination/pagination.tsx b/src/tedi/components/navigation/pagination/pagination.tsx index 2bd9b871..82a0cf64 100644 --- a/src/tedi/components/navigation/pagination/pagination.tsx +++ b/src/tedi/components/navigation/pagination/pagination.tsx @@ -53,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 { /** @@ -138,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] @@ -244,6 +252,10 @@ export const Pagination = forwardRef((props, re return (
+ + {pageCount > 1 ? mergedLabels.pageStatus(currentPage, pageCount) : ''} + +
{showResults && ( diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 7d8abaf2..3522217b 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -574,6 +574,14 @@ export const labelsMap = validateDefaultLabels({ en: 'Page size', ru: 'Размер страницы', }, + 'pagination.page-status': { + description: + 'Status message announced to screen readers via an aria-live region when the page changes. Receives the current page number and total page count.', + components: ['Pagination'], + et: (page?: number, total?: number) => `Lehekülg ${page ?? 0} / ${total ?? 0}`, + en: (page?: number, total?: number) => `Page ${page ?? 0} of ${total ?? 0}`, + ru: (page?: number, total?: number) => `Страница ${page ?? 0} из ${total ?? 0}`, + }, 'table-of-contents.title': { description: 'Title of the table of contents', components: ['TableOfContents'], From d78fde9a9d9d27ffefe17d32879ecded110f2d35 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 12 May 2026 14:42:12 +0300 Subject: [PATCH 24/32] fix(table): cr fixes #122 --- .../content/table/table.module.scss | 10 +- .../content/table/table.stories.tsx | 91 +++---------------- src/tedi/components/content/table/table.tsx | 15 +-- 3 files changed, 26 insertions(+), 90 deletions(-) diff --git a/src/tedi/components/content/table/table.module.scss b/src/tedi/components/content/table/table.module.scss index 8e4ae202..6d6ceb49 100644 --- a/src/tedi/components/content/table/table.module.scss +++ b/src/tedi/components/content/table/table.module.scss @@ -16,7 +16,7 @@ .tedi-table__scroll { overflow-x: auto; background: var(--table-default); - border: 1px solid var(--table-border); + border: var(--tedi-borders-01) solid var(--table-border); border-radius: var(--table-radius); } @@ -54,7 +54,7 @@ } .tedi-table__row { - border-bottom: 1px solid var(--table-border); + border-bottom: var(--tedi-borders-01) solid var(--table-border); &:last-child { border-bottom: 0; @@ -90,7 +90,7 @@ .tedi-table__foot { font-weight: var(--heading-weight); background: var(--table-default); - border-top: 1px solid var(--table-border-th); + border-top: var(--tedi-borders-01) solid var(--table-border-th); } .tedi-table__cell--footer { @@ -140,7 +140,7 @@ .tedi-table--vertical-borders { .tedi-table__header-cell, .tedi-table__cell { - border-right: 1px solid var(--table-border); + border-right: var(--tedi-borders-01) solid var(--table-border); } thead tr:first-child .tedi-table__header-cell:last-child, @@ -168,7 +168,7 @@ } .tedi-table__pagination { - border: 1px solid var(--table-border); + 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); diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 1638bf26..96ab0e5c 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -1375,37 +1375,14 @@ export const WithEmptyState: Story = { }; /** - * Two long-text patterns from the Figma "Long texts" frame. Top table puts - * the "Show more" link on its own line under the truncated paragraph (with a - * chevron). Bottom table inlines the link at the end of the truncated text - * (underlined, no icon). Tip alert in the middle explains the trade-off. + * 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. */ export const LongTexts: Story = { render: function LongTexts() { - // Block variant: the "Show more" Button sits on its own line below the - // truncated paragraph (matches the top table in the Figma frame). Forced - // via `style: { display: 'block' }` on the underlying Button. - const blockColumns = useMemo[]>( - () => [ - baseDoctorWithDescriptionColumns()[0], - { - id: 'description', - header: 'Kirjeldus', - cell: () => ( - - {LONG_DESCRIPTION} - - ), - }, - baseDoctorWithDescriptionColumns()[1], - baseDoctorWithDescriptionColumns()[2], - ], - [] - ); - - // Inline variant: default Truncate renders the toggle Button inline at the - // end of the truncated text (matches the bottom table in the Figma frame). - const inlineColumns = useMemo[]>( + const columns = useMemo[]>( () => [ baseDoctorWithDescriptionColumns()[0], { @@ -1420,33 +1397,20 @@ export const LongTexts: Story = { ); return ( - - - id="tedi-table-long-texts-block" - data={doctors} - columns={blockColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - id="tedi-table-long-texts-inline" - data={doctors} - columns={inlineColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - + id="tedi-table-long-texts" data={doctors} columns={columns} pagination={SHOWCASE_PAGINATION_3} /> ); }, }; /** - * Two row-action patterns from the Figma "Actions" frame. Top table puts - * separate edit + delete icon buttons on each row. Bottom table collapses - * the same affordances into a single kebab (`more_vert`) button — typical - * pattern when the row is dense or has many possible actions. + * Row-action pattern from the Figma "Actions" frame: each row gets dedicated + * edit + delete icon buttons in a right-aligned cell. For dense rows or many + * possible actions, collapse them under a single `more_vert` kebab button + * instead — same column structure, just one icon-only Button. */ export const Actions: Story = { render: function Actions() { - const editDeleteColumns = useMemo[]>( + const columns = useMemo[]>( () => [ ...baseDoctorActionsColumns(), { @@ -1467,39 +1431,8 @@ export const Actions: Story = { [] ); - const kebabColumns = useMemo[]>( - () => [ - ...baseDoctorActionsColumns(), - { - id: 'actions', - header: '', - cell: () => ( - - - - ), - }, - ], - [] - ); - return ( - - - id="tedi-table-actions-edit-delete" - data={doctors} - columns={editDeleteColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - - id="tedi-table-actions-kebab" - data={doctors} - columns={kebabColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - + id="tedi-table-actions" data={doctors} columns={columns} pagination={SHOWCASE_PAGINATION_3} /> ); }, }; diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index 283d7624..f9900b5d 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -467,12 +467,12 @@ function TableBase(props: TableProps): JSX.Element { table.toggleAllRowsSelected(checked)} + checked={table.getIsAllPageRowsSelected()} + indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()} + onChange={(_value, checked) => table.toggleAllPageRowsSelected(checked)} /> ), cell: ({ row }) => ( @@ -654,10 +654,13 @@ function TableBase(props: TableProps): JSX.Element { {headerGroup.headers.map((header) => { const isGroup = header.subHeaders.length > 0; const hasParentGroup = Boolean(header.column.parent); - if (!header.isPlaceholder && !isGroup && !hasParentGroup && rowIndex > 0) { + if (header.isPlaceholder) { return null; } - const rowSpanCount = header.isPlaceholder ? headerGroups.length - rowIndex : 1; + if (!isGroup && !hasParentGroup && rowIndex > 0) { + return null; + } + const rowSpanCount = !isGroup && !hasParentGroup ? headerGroups.length - rowIndex : 1; const sortDirection = header.column.getIsSorted(); const ariaSort: 'ascending' | 'descending' | 'none' | undefined = header.column.getCanSort() ? sortDirection === 'asc' From c27be0eaeb73706722027f5ac0bfac743a4842b0 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 12 May 2026 14:54:49 +0300 Subject: [PATCH 25/32] fix(table): cr fixes #122 --- src/tedi/components/content/table/table.spec.tsx | 3 +++ src/tedi/components/content/table/table.tsx | 14 +++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx index 62e68756..fed918f2 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -755,6 +755,9 @@ describe('Table', () => { // 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.tsx b/src/tedi/components/content/table/table.tsx index f9900b5d..6a9ec1d8 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -293,6 +293,12 @@ const DEFAULT_FILTER_FNS = { '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, @@ -654,13 +660,15 @@ function TableBase(props: TableProps): JSX.Element { {headerGroup.headers.map((header) => { const isGroup = header.subHeaders.length > 0; const hasParentGroup = Boolean(header.column.parent); - if (header.isPlaceholder) { + const columnHasChildren = hasChildColumns(header.column.columnDef); + const isStandaloneLeaf = !columnHasChildren && !hasParentGroup; + if (header.isPlaceholder && !isStandaloneLeaf) { return null; } - if (!isGroup && !hasParentGroup && rowIndex > 0) { + if (!header.isPlaceholder && isStandaloneLeaf && rowIndex > 0) { return null; } - const rowSpanCount = !isGroup && !hasParentGroup ? headerGroups.length - rowIndex : 1; + const rowSpanCount = isStandaloneLeaf ? headerGroups.length - rowIndex : 1; const sortDirection = header.column.getIsSorted(); const ariaSort: 'ascending' | 'descending' | 'none' | undefined = header.column.getCanSort() ? sortDirection === 'asc' From 73404da08a9d3e050cc5607f51d00a31dd915e08 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 12 May 2026 15:03:01 +0300 Subject: [PATCH 26/32] fix(table): fix columns menu status #122 --- src/tedi/components/content/table/table.spec.tsx | 6 +++++- src/tedi/components/content/table/table.tsx | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx index fed918f2..2782e946 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -173,13 +173,17 @@ describe('Table', () => { ); fireEvent.click(screen.getByRole('button', { name: /Columns/i })); - const roleCheckbox = screen.getByRole('checkbox', { name: 'Role' }); + 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', () => { diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index 6a9ec1d8..fc2ef536 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -274,6 +274,12 @@ 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__'; @@ -586,8 +592,8 @@ function TableBase(props: TableProps): JSX.Element { }); const contextValue = useMemo>( - () => ({ table, size, id: resolvedId }), - [table, size, resolvedId] + () => ({ table, size, id: resolvedId, state: tableState }), + [table, size, resolvedId, tableState] ); const handlePaginationPageChange = useCallback((nextPage: number) => table.setPageIndex(nextPage - 1), [table]); From a463629d811d14be36e2dec976a1c9cbf6cebbc2 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 13 May 2026 11:48:04 +0300 Subject: [PATCH 27/32] chore: make Table stories more interactive #122 --- .../content/table/table.stories.tsx | 640 +++++++++++------- 1 file changed, 414 insertions(+), 226 deletions(-) diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 96ab0e5c..bf9502dc 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -18,18 +18,20 @@ import { import { CSS } from '@dnd-kit/utilities'; import type { Meta, StoryObj } from '@storybook/react'; import type { ColumnDef, ColumnOrderState } from '@tanstack/react-table'; -import { useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; 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 { Checkbox } from '../../form/checkbox/checkbox'; 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 { StatusBadge, type StatusBadgeColor } from '../../tags/status-badge/status-badge'; import { Truncate } from '../truncate/truncate'; @@ -197,21 +199,171 @@ const nameLinkStyle: React.CSSProperties = { fontWeight: 'var(--body-regular-weight)', }; -/** Shared columns for the Default and Sizes stories (Figma "Sizes" frame). */ +const editRowActionsStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 8, +}; + +interface EditableRows { + rows: T[]; + editingId: string | null; + draft: T | null; + setDraft: React.Dispatch>; + beginEdit: (row: T) => void; + cancelEdit: () => void; + commitEdit: () => void; +} + +/** + * Context that flows the editor into the table cells. Cells read the latest + * state via context instead of taking the editor as a prop — that way the + * `columns` array can be a module-level constant and TanStack never rebuilds + * its column instances, which is what kept the TextField mounted across + * keystrokes so it doesn't lose focus. + */ +// 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; +} + +/** + * Tiny shared state machine for row-level inline editing. Tracks which row + * (if any) is currently in edit mode and the draft copy of its values; commits + * or discards back to the parent array on confirm / cancel. Reused across all + * stories that ship a "Muuda" affordance so the button actually does something. + */ +function useEditableRows(initial: T[]): EditableRows { + const [rows, setRows] = useState(initial); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(null); + + // Read latest draft from a ref inside commitEdit so the callback identity + // can stay stable across renders without the `draft` dep. + 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 }; +} + +/** + * Renders the per-row action cell: a Muuda link normally, or cancel / commit + * buttons when this row is the one being edited. + */ +function EditActionsCell({ row }: { row: T }) { + const editor = useEditor(); + if (row.id === editor.editingId) { + return ( + + + + + ); + } + return ( + { + event.preventDefault(); + editor.beginEdit(row); + }} + style={editLinkStyle} + > + + Muuda + + ); +} + +/** Cell renderer that flips to a `` when its row is editing. */ +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))} + /> + ); +} + +/** + * Booking columns used by Default + Sizes (Figma "Sizes" frame). Each cell + * flips into a TextField when its row is being edited; the actions column + * swaps the Muuda link for cancel / commit buttons. The cells read editor + * state from `EditableRowsContext`, so this array stays a stable module-level + * constant — important for TanStack reconciliation across keystrokes. + */ const bookingShowcaseColumns: ColumnDef[] = [ - { id: 'dateRange', header: 'Kuupäev', accessorKey: 'dateRange' }, - { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, - { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, - { id: 'location', header: 'Asukoht', accessorKey: 'location' }, + { + 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: '', - cell: () => ( - event.preventDefault()} style={editLinkStyle}> - - Muuda - - ), + cell: ({ row }) => , }, ]; @@ -220,14 +372,19 @@ const bookingShowcaseColumns: ColumnDef[] = [ * the `Sizes` showcase below, just on its own. */ export const Default: Story = { - render: () => ( - - id="tedi-table-default" - data={bookings} - columns={bookingShowcaseColumns} - pagination={SHOWCASE_PAGINATION_3} - /> - ), + render: function Default() { + const editor = useEditableRows(bookings); + return ( + + + id="tedi-table-default" + data={editor.rows} + columns={bookingShowcaseColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + + ); + }, }; /** @@ -237,23 +394,29 @@ export const Default: Story = { */ export const Sizes: Story = { render: function Sizes() { + const defaultEditor = useEditableRows(bookings); + const smallEditor = useEditableRows(bookings); return ( Default - - id="tedi-table-sizes-default" - data={bookings} - columns={bookingShowcaseColumns} - pagination={SHOWCASE_PAGINATION_3} - /> + + + id="tedi-table-sizes-default" + data={defaultEditor.rows} + columns={bookingShowcaseColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + Small - - id="tedi-table-sizes-small" - data={bookings} - columns={bookingShowcaseColumns} - size="small" - pagination={SHOWCASE_PAGINATION_3} - /> + + + id="tedi-table-sizes-small" + data={smallEditor.rows} + columns={bookingShowcaseColumns} + size="small" + pagination={SHOWCASE_PAGINATION_3} + /> + ); }, @@ -265,102 +428,86 @@ export const Sizes: Story = { * names + status badges, and a doctor list with a multi-line first cell. * Same chrome (borders, pagination), different content patterns. */ -export const Simple: Story = { - render: function Simple() { - const bookingColumns = useMemo[]>( - () => [ - { id: 'dateRange', header: 'Kuupäev', accessorKey: 'dateRange' }, - { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, - { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, - { id: 'location', header: 'Asukoht', accessorKey: 'location' }, - { - id: 'actions', - header: '', - cell: () => ( - event.preventDefault()} style={editLinkStyle}> - - Muuda - - ), - }, - ], - [] - ); +const simplePeopleColumns: ColumnDef[] = [ + { + id: 'name', + header: 'Isik', + accessorKey: 'name', + cell: ({ row }) => ( + event.preventDefault()} style={nameLinkStyle}> + {row.original.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}, + }, +]; - const peopleColumns = useMemo[]>( - () => [ - { - id: 'name', - header: 'Isik', - accessorKey: 'name', - cell: ({ row }) => ( - event.preventDefault()} style={nameLinkStyle}> - {row.original.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} - ), - }, - ], - [] - ); +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: '', + cell: ({ row }) => , + }, +]; - const doctorColumns = useMemo[]>( - () => [ - { - 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' }, - { - id: 'actions', - header: '', - cell: () => ( - event.preventDefault()} style={editLinkStyle}> - - Muuda - - ), - }, - ], - [] - ); +export const Simple: Story = { + render: function Simple() { + const bookingEditor = useEditableRows(bookings); + const doctorEditor = useEditableRows(doctors); return ( - - id="tedi-table-simple-bookings" - data={bookings} - columns={bookingColumns} - pagination={SHOWCASE_PAGINATION_3} - /> + + + id="tedi-table-simple-bookings" + data={bookingEditor.rows} + columns={bookingShowcaseColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + id="tedi-table-simple-people" data={filterablePeople} - columns={peopleColumns} + columns={simplePeopleColumns} pagination={SHOWCASE_PAGINATION_4} /> - - id="tedi-table-simple-doctors" - data={doctors} - columns={doctorColumns} - pagination={SHOWCASE_PAGINATION_3} - /> + + + id="tedi-table-simple-doctors" + data={doctorEditor.rows} + columns={simpleDoctorColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + ); }, @@ -379,7 +526,7 @@ const LONG_DESCRIPTION = const LONG_TEXT_MAX_LENGTH = 70; -const baseDoctorWithDescriptionColumns = (): ColumnDef[] => [ +const baseDoctorWithDescriptionColumns: ColumnDef[] = [ { id: 'name', header: 'Arst', @@ -391,16 +538,16 @@ const baseDoctorWithDescriptionColumns = (): ColumnDef[] => [ ), }, // Description cell injected per variant. - { id: 'location', header: 'Asukoht', accessorKey: 'location' }, + { + id: 'location', + header: 'Asukoht', + accessorKey: 'location', + cell: ({ row }) => , + }, { id: 'actions', header: '', - cell: () => ( - event.preventDefault()} style={editLinkStyle}> - - Muuda - - ), + cell: ({ row }) => , }, ]; @@ -501,71 +648,71 @@ const initialsOf = (name: string) => * Two-level header using column groups. Nest column definitions under `columns` inside a parent * `columnDef` — TanStack Table will render the parent as a merged header cell spanning its children. */ +const mergedCellsColumns: ColumnDef[] = [ + { + id: 'dateRange', + accessorKey: 'dateRange', + 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: '', + cell: ({ row }) => , + }, +]; + export const MergedCells: Story = { render: function MergedCells() { - const columns = useMemo[]>( - () => [ - { - id: 'dateRange', - accessorKey: 'dateRange', - header: ({ column }) => { - const sorted = column.getIsSorted(); - const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; - return ( - - Kuupäev - - - ); - }, - }, - { - id: 'aeg', - header: 'Aeg', - columns: [ - { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, - { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, - ], - }, - { id: 'location', header: 'Asukoht', accessorKey: 'location' }, - { - id: 'actions', - header: '', - cell: () => ( - event.preventDefault()} - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 4, - color: 'var(--link-primary-default)', - textDecoration: 'none', - fontWeight: 'var(--body-regular-weight)', - }} - > - - Muuda - - ), - }, - ], - [] - ); - + const editor = useEditableRows(bookings); return ( - - id="tedi-table-merged" - verticalBorders - data={bookings} - columns={columns} - pagination={DEFAULT_PAGINATION} - /> + + + id="tedi-table-merged" + verticalBorders + data={editor.rows} + columns={mergedCellsColumns} + pagination={DEFAULT_PAGINATION} + /> + ); }, }; @@ -747,9 +894,7 @@ export const EditableValues: Story = { if (row.original.id === editingId) { return ( - + @@ -1380,33 +1525,40 @@ export const WithEmptyState: Story = { * 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', + cell: () => {LONG_DESCRIPTION}, + }, + baseDoctorWithDescriptionColumns[1], + baseDoctorWithDescriptionColumns[2], +]; + export const LongTexts: Story = { render: function LongTexts() { - const columns = useMemo[]>( - () => [ - baseDoctorWithDescriptionColumns()[0], - { - id: 'description', - header: 'Kirjeldus', - cell: () => {LONG_DESCRIPTION}, - }, - baseDoctorWithDescriptionColumns()[1], - baseDoctorWithDescriptionColumns()[2], - ], - [] - ); + const editor = useEditableRows(doctors); return ( - id="tedi-table-long-texts" data={doctors} columns={columns} pagination={SHOWCASE_PAGINATION_3} /> + + + id="tedi-table-long-texts" + data={editor.rows} + columns={longTextsColumns} + pagination={SHOWCASE_PAGINATION_3} + /> + ); }, }; /** - * Row-action pattern from the Figma "Actions" frame: each row gets dedicated - * edit + delete icon buttons in a right-aligned cell. For dense rows or many - * possible actions, collapse them under a single `more_vert` kebab button - * instead — same column structure, just one icon-only Button. + * Row-action pattern from the Figma "Actions" frame: each row collapses its + * actions under a single `more_vert` kebab trigger that opens a `` + * menu. Idiomatic for dense rows or any table with more than two actions — + * the menu portals out of the cell so it isn't clipped by the table's scroll + * container. */ export const Actions: Story = { render: function Actions() { @@ -1416,14 +1568,26 @@ export const Actions: Story = { { id: 'actions', header: '', - cell: () => ( + cell: ({ row }) => ( - - + + + + + + undefined}>Muuda + undefined}>Dubleeri + undefined}>Saada e-mail + undefined}>Kustuta + + ), }, @@ -1440,8 +1604,9 @@ export const Actions: Story = { /** * "Custom" Figma frame: a tip alert plus a table with custom-rendered cells — * avatar circle next to the name, a status note column with coloured `Alert` - * tags, and the same edit/delete row actions from the Actions showcase. - * Demonstrates that any column can return arbitrary JSX. + * tags, and a ``-anchored info card on the actions trigger. + * Demonstrates that any column can return arbitrary JSX, including overlay + * UI like a row-level info preview with action buttons. */ export const Custom: Story = { render: function Custom() { @@ -1476,14 +1641,37 @@ export const Custom: Story = { { id: 'actions', header: '', - cell: () => ( + cell: ({ row }) => ( - - + + + + + + +
{row.original.name}
+
+ {row.original.specialty} · {row.original.location} +
+ +
+ + +
+
+
+
), }, From 5133149acb2def7bfa8417e53b62f99634308fe0 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 13 May 2026 12:23:53 +0300 Subject: [PATCH 28/32] chore: update Table stories #122 --- .../content/table/table.stories.tsx | 254 ++---------------- 1 file changed, 17 insertions(+), 237 deletions(-) diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index bf9502dc..6f9fd561 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -127,16 +127,7 @@ const people: Person[] = Array.from({ length: 28 }, (_, index) => { }; }); -/** - * Default pagination options applied to most stories. Matches Figma examples - * which show pagination on every table variant (default page size 10). - */ const DEFAULT_PAGINATION = { pageSize: 10, pageSizeOptions: [10, 25, 50] }; - -/** - * Used by `Sizes` and `Simple` stories — the Figma frames show 3-4 visible - * rows per page so the table comparison fits without scrolling. - */ const SHOWCASE_PAGINATION_3 = { pageSize: 3, pageSizeOptions: [3, 10, 25, 50] }; const SHOWCASE_PAGINATION_4 = { pageSize: 4, pageSizeOptions: [4, 10, 25, 50] }; @@ -329,8 +320,7 @@ function EditableTextCell({ } /** - * Booking columns used by Default + Sizes (Figma "Sizes" frame). Each cell - * flips into a TextField when its row is being edited; the actions column + * Each cell flips into a TextField when its row is being edited; the actions column * swaps the Muuda link for cancel / commit buttons. The cells read editor * state from `EditableRowsContext`, so this array stays a stable module-level * constant — important for TanStack reconciliation across keystrokes. @@ -387,11 +377,6 @@ export const Default: Story = { }, }; -/** - * Both table sizes side-by-side, mirroring the Figma "Sizes" frame: - * `default` and `small` variants of the same booking columns so the - * difference in row/header padding is easy to compare. - */ export const Sizes: Story = { render: function Sizes() { const defaultEditor = useEditableRows(bookings); @@ -422,12 +407,6 @@ export const Sizes: Story = { }, }; -/** - * Three "simple" table layouts side-by-side, mirroring the Figma "Simple" - * frame: a bookings list with an edit action, a people list with linked - * names + status badges, and a doctor list with a multi-line first cell. - * Same chrome (borders, pagination), different content patterns. - */ const simplePeopleColumns: ColumnDef[] = [ { id: 'name', @@ -513,12 +492,6 @@ export const Simple: Story = { }, }; -/** - * Two long-text patterns from the Figma "Long texts" frame. Top table puts - * the "Show more" link on its own line under the truncated paragraph (with a - * chevron). Bottom table inlines the link at the end of the truncated text - * (underlined, no icon). Tip alert in the middle explains the trade-off. - */ 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 ' + @@ -551,12 +524,6 @@ const baseDoctorWithDescriptionColumns: ColumnDef[] = [ }, ]; -/** - * Two row-action patterns from the Figma "Actions" frame. Top table puts - * separate edit + delete icon buttons on each row. Bottom table collapses - * the same affordances into a single kebab (`more_vert`) button — typical - * pattern when the row is dense or has many possible actions. - */ const baseDoctorActionsColumns = (): ColumnDef[] => [ { id: 'name', @@ -579,12 +546,6 @@ const rowActionsCellStyle: React.CSSProperties = { width: '100%', }; -/** - * "Custom" Figma frame: a tip alert plus a table with custom-rendered cells — - * avatar circle next to the name, a status note column with coloured - * `StatusBadge`s, and the same edit/delete row actions from the Actions - * showcase. Demonstrates that any column can return arbitrary JSX. - */ type CustomNoteColor = 'warning' | 'danger' | undefined; interface CustomDoctor extends Doctor { @@ -750,189 +711,30 @@ export const NoOutsideBorder: Story = { }; /** - * Editable rows — matches Figma "Changeable values". Click "Muuda" on any row - * to swap its cells for form inputs (date range, time, duration, location) - * plus a cancel (×) / confirm (✓) pair. Other rows stay static until picked. - */ -interface EditableBooking { - id: string; - dateRange: string; - hour: string; - duration: string; - location: string; -} - -const editableBookingsSeed: EditableBooking[] = Array.from({ length: 28 }, (_, index) => ({ - id: String(index + 1), - dateRange: '22.03.2029 – 29.03.2029', - hour: '11:14', - duration: '6', - location: 'Harjumaa', -})); - -const muudaLinkStyle: React.CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - gap: 4, - color: 'var(--link-primary-default)', - textDecoration: 'none', - fontWeight: 'var(--body-regular-weight)', -}; - -const editActionsStyle: React.CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - gap: 8, -}; - -/** - * Row-level inline editing: clicking "Muuda" replaces static cells with `TextField` inputs and - * swaps the action column for confirm/cancel buttons. Track `editingId` + a `draft` copy of the row - * in local state; commit or discard on button click. Only one row edits at a time. + * 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 [rows, setRows] = useState(editableBookingsSeed); - const [editingId, setEditingId] = useState(null); - const [draft, setDraft] = useState(null); - - const beginEdit = (row: EditableBooking) => { - setEditingId(row.id); - setDraft(row); - }; - const cancelEdit = () => { - setEditingId(null); - setDraft(null); - }; - const commitEdit = () => { - if (!draft) return; - setRows((current) => current.map((row) => (row.id === draft.id ? draft : row))); - setEditingId(null); - setDraft(null); - }; - - const columns = useMemo[]>( - () => [ - { - id: 'dateRange', - header: 'Kuupäev', - accessorKey: 'dateRange', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return row.original.dateRange; - return ( - setDraft((prev) => (prev ? { ...prev, dateRange: next } : prev))} - /> - ); - }, - }, - { - id: 'hour', - header: 'Kellaaeg', - accessorKey: 'hour', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return row.original.hour; - return ( - setDraft((prev) => (prev ? { ...prev, hour: next } : prev))} - /> - ); - }, - }, - { - id: 'duration', - header: 'Kestus', - accessorKey: 'duration', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return `${row.original.duration} min`; - return ( - setDraft((prev) => (prev ? { ...prev, duration: next } : prev))} - /> - ); - }, - }, - { - id: 'location', - header: 'Asukoht', - accessorKey: 'location', - cell: ({ row }) => { - if (row.original.id !== editingId || !draft) return row.original.location; - return ( - setDraft((prev) => (prev ? { ...prev, location: next } : prev))} - /> - ); - }, - }, - { - id: 'actions', - header: '', - cell: ({ row }) => { - if (row.original.id === editingId) { - return ( - - - - - ); - } - return ( - { - event.preventDefault(); - beginEdit(row.original); - }} - style={muudaLinkStyle} - > - - Muuda - - ); - }, - }, - ], - [draft, editingId] - ); - + const editor = useEditableRows(bookings); return ( - id="tedi-table-editable" data={rows} columns={columns} pagination={DEFAULT_PAGINATION} /> + + + id="tedi-table-editable" + data={editor.rows} + columns={bookingShowcaseColumns} + pagination={DEFAULT_PAGINATION} + /> + ); }, }; -/** - * Sortable columns — header shows a compact sort chevron that cycles through - * `unsorted → ascending → descending → unsorted` on click. Matches the Figma - * sort indicator from Example table 7/8. Columns opt in via - * `columnDef.enableSorting` (default `true`); only columns with - * `enableSorting: false` render as plain text. - */ /** * 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 @@ -973,9 +775,6 @@ export const Sortable: Story = { }; /** - * Per-column filter popovers — matches Figma Example table 7/8 exactly - * (Nimi / Töö algus / Vanus / Külastuste arv / Tõendi staatus). - * * Each filterable header pairs a `unfold_more`/`arrow_upward`/`arrow_downward` * sort chevron with a `filter_alt` funnel `Popover` trigger. Popover contents * vary per column: @@ -1553,13 +1352,6 @@ export const LongTexts: Story = { }, }; -/** - * Row-action pattern from the Figma "Actions" frame: each row collapses its - * actions under a single `more_vert` kebab trigger that opens a `` - * menu. Idiomatic for dense rows or any table with more than two actions — - * the menu portals out of the cell so it isn't clipped by the table's scroll - * container. - */ export const Actions: Story = { render: function Actions() { const columns = useMemo[]>( @@ -1601,13 +1393,6 @@ export const Actions: Story = { }, }; -/** - * "Custom" Figma frame: a tip alert plus a table with custom-rendered cells — - * avatar circle next to the name, a status note column with coloured `Alert` - * tags, and a ``-anchored info card on the actions trigger. - * Demonstrates that any column can return arbitrary JSX, including overlay - * UI like a row-level info preview with action buttons. - */ export const Custom: Story = { render: function Custom() { const columns = useMemo[]>( @@ -1692,11 +1477,6 @@ export const Custom: Story = { }, }; -// ───────────────────────────────────────────────────────────────────────────── -// Stories not present in the Figma "Types" frame — kept after the Figma-driven -// showcase so the story sidebar follows the design's documented order first. -// ───────────────────────────────────────────────────────────────────────────── - /** * Footer row showing per-column aggregates (e.g. salary total, headcount). * Columns opt in by providing a `footer` value/function on `columnDef`. From 46a608f54ce75a3bb820f30a2d39ea19913d22f1 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 15 May 2026 09:16:00 +0300 Subject: [PATCH 29/32] fix(table): design review fixes #122 --- .../table-columns-menu/table-columns-menu.tsx | 2 +- .../content/table/table.module.scss | 40 +- .../components/content/table/table.spec.tsx | 7 +- .../content/table/table.stories.tsx | 422 +++++++++++++++--- src/tedi/components/content/table/table.tsx | 102 ++++- .../pagination/pagination.module.scss | 19 +- .../navigation/pagination/pagination.spec.tsx | 14 +- .../navigation/pagination/pagination.tsx | 32 +- .../providers/label-provider/labels-map.ts | 12 +- 9 files changed, 538 insertions(+), 112 deletions(-) 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 index 4bb04c8a..ff5868e7 100644 --- 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 @@ -35,7 +35,7 @@ export const TableColumnsMenu = ({ triggerLabel, className }: TableColumnsMenuPr return ( - diff --git a/src/tedi/components/content/table/table.module.scss b/src/tedi/components/content/table/table.module.scss index 6d6ceb49..e85c77dc 100644 --- a/src/tedi/components/content/table/table.module.scss +++ b/src/tedi/components/content/table/table.module.scss @@ -61,16 +61,39 @@ } } -.tedi-table__body .tedi-table__row:hover { - background: var(--table-hover); -} - .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); @@ -137,6 +160,15 @@ 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 { diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx index 2782e946..da9eb6e2 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -545,9 +545,10 @@ describe('Table', () => { expect(screen.getByRole('navigation', { name: /Pagination/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Current page, page 1/i })).toHaveAttribute('aria-current', 'page'); - // Previous arrow is hidden on the first page (intentional — see Pagination). - expect(screen.queryByRole('button', { name: /Previous page/i })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Next page/i })).toBeInTheDocument(); + // 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(); }); diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 6f9fd561..d5594013 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -2,6 +2,9 @@ import { closestCenter, DndContext, type DragEndEvent, + type DragOverEvent, + DragOverlay, + type DragStartEvent, KeyboardSensor, PointerSensor, useSensor, @@ -25,6 +28,7 @@ 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 { TextField } from '../../form/textfield/textfield'; import { VerticalSpacing } from '../../layout/vertical-spacing'; @@ -33,6 +37,7 @@ 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'; @@ -191,9 +196,11 @@ const nameLinkStyle: React.CSSProperties = { }; const editRowActionsStyle: React.CSSProperties = { - display: 'inline-flex', + display: 'flex', alignItems: 'center', + justifyContent: 'flex-end', gap: 8, + width: '100%', }; interface EditableRows { @@ -274,17 +281,19 @@ function EditActionsCell({ row }: { row: T }) { ); } return ( - { - event.preventDefault(); - editor.beginEdit(row); - }} - style={editLinkStyle} - > - - Muuda - + + { + event.preventDefault(); + editor.beginEdit(row); + }} + style={editLinkStyle} + > + + Muuda + + ); } @@ -353,6 +362,7 @@ const bookingShowcaseColumns: ColumnDef[] = [ { id: 'actions', header: '', + size: 1, cell: ({ row }) => , }, ]; @@ -454,6 +464,7 @@ const simpleDoctorColumns: ColumnDef[] = [ { id: 'actions', header: '', + size: 1, cell: ({ row }) => , }, ]; @@ -510,7 +521,6 @@ const baseDoctorWithDescriptionColumns: ColumnDef[] = [
), }, - // Description cell injected per variant. { id: 'location', header: 'Asukoht', @@ -520,6 +530,7 @@ const baseDoctorWithDescriptionColumns: ColumnDef[] = [ { id: 'actions', header: '', + size: 1, cell: ({ row }) => , }, ]; @@ -613,6 +624,7 @@ 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'; @@ -657,6 +669,7 @@ const mergedCellsColumns: ColumnDef[] = [ { id: 'actions', header: '', + size: 1, cell: ({ row }) => , }, ]; @@ -678,20 +691,103 @@ export const MergedCells: Story = { }, }; +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: () => ( - - id="tedi-table-vb" - data={people} - columns={personColumns} - verticalBorders - pagination={DEFAULT_PAGINATION} - /> - ), + 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} + /> + ); + }, }; /** @@ -1208,18 +1304,24 @@ export const SelectableRows: Story = { * 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 [clicked, setClicked] = useState(null); + const [active, setActive] = useState<{ id: string; name: string } | null>(null); return ( <> - {clicked ? `You clicked ${clicked}` : 'Click a row to select it.'} + {active ? `You clicked ${active.name}` : 'Click a row to select it.'} id="tedi-table-clickable" data={people} columns={personColumns} - onRowClick={(row) => setClicked(row.original.name)} + onRowClick={(row) => setActive({ id: row.id, name: row.original.name })} + activeRowId={active?.id} pagination={DEFAULT_PAGINATION} /> @@ -1242,18 +1344,82 @@ export const Striped: Story = { ), }; +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: () => ( + + event.preventDefault()} style={editLinkStyle}> + + Muuda + + + ), + }, +]; + /** * First column stays fixed during horizontal scroll via `stickyFirstColumn`. Constrain the - * container width (e.g. `maxWidth: 520`) so there is something to scroll — the sticky effect - * is invisible when the table fits without overflow. + * 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={people} - columns={personColumns} + data={stickyDoctors} + columns={stickyDoctorColumns} stickyFirstColumn pagination={DEFAULT_PAGINATION} /> @@ -1284,11 +1450,11 @@ export const StickyHeader: Story = { */ export const StickyHeaderAndFirstColumn: Story = { render: () => ( -
- +
+ id="tedi-table-sticky-both" - data={people} - columns={personColumns} + data={stickyDoctors} + columns={stickyDoctorColumns} stickyHeader stickyFirstColumn maxHeight={280} @@ -1329,6 +1495,7 @@ const longTextsColumns: ColumnDef[] = [ { id: 'description', header: 'Kirjeldus', + size: 480, cell: () => {LONG_DESCRIPTION}, }, baseDoctorWithDescriptionColumns[1], @@ -1360,12 +1527,14 @@ export const Actions: Story = { { id: 'actions', header: '', + size: 1, cell: ({ row }) => ( + @@ -1489,8 +1652,9 @@ export const WithFooter: Story = { { id: 'location', header: 'Location', accessorKey: 'location' }, { id: 'salary', - header: '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); @@ -1528,6 +1692,59 @@ export const WithColumnsMenu: Story = { // 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 @@ -1587,6 +1804,8 @@ 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 }) @@ -1609,7 +1828,16 @@ export const DraggableRows: Story = { [] ); + 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) => { @@ -1619,6 +1847,11 @@ export const DraggableRows: Story = { return arrayMove(current, oldIndex, newIndex); }); }; + const handleDragCancel = () => { + setActiveRowId(null); + setOverRowId(null); + }; + const activeRow = activeRowId ? rows.find((r) => r.id === activeRowId) : null; return ( @@ -1629,10 +1862,38 @@ export const DraggableRows: Story = { onDragEnd. - + r.id)} strategy={verticalListSortingStrategy}> - id="tedi-table-row-drag" data={rows} columns={columns} /> + + id="tedi-table-row-drag" + data={rows} + columns={columns} + activeRowId={overRowId ?? undefined} + /> + + {activeRow ? ( +
+ + + + + + + + +
{activeRow.name}{activeRow.role}{activeRow.location}
+ ) : null} + ); @@ -1674,29 +1935,32 @@ export const DraggableColumns: Story = { 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 }) ); - // Wrap each header in a drag handle so the column can be picked up from the - // header cell itself. We re-derive the columns array whenever `columnOrder` - // changes so the handle's id matches the column we're dragging. const columns = useMemo[]>( () => baseColumns.map((column) => ({ ...column, - header: ({ column: ctxColumn }) => ( - - - {column.header as string} - - ), + 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) => { @@ -1706,6 +1970,13 @@ export const DraggableColumns: Story = { 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 ( @@ -1716,18 +1987,35 @@ export const DraggableColumns: Story = { 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); - }} - /> - + + + + + 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} + ); diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index fc2ef536..933e2aac 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -35,6 +35,32 @@ 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 `` / `` 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 @@ -154,6 +180,22 @@ export interface TableProps { * 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. @@ -327,6 +369,8 @@ function TableBase(props: TableProps): JSX.Element { stickyHeader = false, maxHeight, onRowClick, + activeRowId, + rowHover, enableRowSelection, enableColumnFilters = false, renderSubComponent, @@ -600,6 +644,9 @@ function TableBase(props: TableProps): JSX.Element { 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'], @@ -611,6 +658,7 @@ function TableBase(props: TableProps): JSX.Element { [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, }, @@ -683,7 +731,7 @@ function TableBase(props: TableProps): JSX.Element { ? 'descending' : 'none' : undefined; - const headerMeta = header.column.columnDef.meta as { label?: string } | 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); @@ -694,6 +742,8 @@ function TableBase(props: TableProps): JSX.Element { rowSpan={rowSpanCount > 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} @@ -712,7 +762,7 @@ function TableBase(props: TableProps): JSX.Element { aria-rowindex={paginationEnabled ? headerGroups.length + 1 : undefined} > {leafColumns.map((column) => { - const meta = column.columnDef.meta as { label?: string } | undefined; + const meta = column.columnDef.meta as TableColumnMeta | undefined; const headerLabel = meta?.label ?? (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id); @@ -751,8 +801,10 @@ function TableBase(props: TableProps): JSX.Element { ) : ( 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, }); @@ -769,12 +821,22 @@ function TableBase(props: TableProps): JSX.Element { tabIndex={clickable ? 0 : undefined} role={clickable ? 'button' : undefined} aria-rowindex={ariaRowIndex} + aria-current={isActiveRow ? 'true' : undefined} > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {row.getVisibleCells().map((cell) => { + const cellMeta = cell.column.columnDef.meta as TableColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} {renderSubComponent && row.getIsExpanded() && ( @@ -798,15 +860,23 @@ function TableBase(props: TableProps): JSX.Element { {footerGroups.map((group) => ( - {group.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.footer, header.getContext())} - - ))} + {group.headers.map((header) => { + const footerMeta = header.column.columnDef.meta as TableColumnMeta | undefined; + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.footer, header.getContext())} + + ); + })} ))} diff --git a/src/tedi/components/navigation/pagination/pagination.module.scss b/src/tedi/components/navigation/pagination/pagination.module.scss index f90fa5b5..fb33a049 100644 --- a/src/tedi/components/navigation/pagination/pagination.module.scss +++ b/src/tedi/components/navigation/pagination/pagination.module.scss @@ -85,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; } @@ -102,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); @@ -186,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 f8f5e97b..8bee4063 100644 --- a/src/tedi/components/navigation/pagination/pagination.spec.tsx +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -172,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', () => { @@ -342,7 +342,9 @@ describe('Pagination component', () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - fireEvent.click(screen.getByText('4 / 5')); + // 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 82a0cf64..2e5cf99b 100644 --- a/src/tedi/components/navigation/pagination/pagination.tsx +++ b/src/tedi/components/navigation/pagination/pagination.tsx @@ -206,9 +206,9 @@ export const Pagination = forwardRef((props, re const pageItems = items.slice(1, -1); const renderArrow = (item: PaginationItem) => { - if (item.disabled) return null; const label = item.type === 'previous' ? mergedLabels.previous : mergedLabels.next; const iconName = item.type === 'previous' ? 'arrow_back' : 'arrow_forward'; + return ( ); diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 3522217b..c24c60d8 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -361,9 +361,9 @@ export const labelsMap = validateDefaultLabels({ 'table.columns': { description: 'Default label on the `Table.ColumnsMenu` trigger (column-visibility menu).', components: ['TableColumnsMenu'], - et: 'Veerud', - en: 'Columns', - ru: 'Столбцы', + et: 'Kohanda', + en: 'Customize', + ru: 'Настроить', }, 'stepper.completed': { description: 'Label for screen-reader that this step is completed (visually hidden)', @@ -570,9 +570,9 @@ export const labelsMap = validateDefaultLabels({ 'pagination.page-size': { description: 'Label of page size select', components: ['Table', 'Pagination'], - et: 'Lehe suurus', - en: 'Page size', - ru: 'Размер страницы', + et: 'Kuva korraga', + en: 'Show per page', + ru: 'Показывать по', }, 'pagination.page-status': { description: From 44afcec9bb60ebd4e8319441a77508f2f7cb2dfe Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 15 May 2026 09:32:40 +0300 Subject: [PATCH 30/32] fix(table): improve stories #122 --- .../content/table/table.stories.tsx | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index d5594013..332b2431 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -22,6 +22,7 @@ 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'; @@ -30,6 +31,7 @@ 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'; @@ -147,20 +149,37 @@ type Story = StoryObj>; interface Booking { id: string; - dateRange: 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: '22.03.2029 – 29.03.2029', + 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; @@ -328,6 +347,39 @@ function EditableTextCell({ ); } +/** Cell renderer that flips to a `` when its row is editing. */ +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 + ) + } + /> + ); +} + /** * Each cell flips into a TextField when its row is being edited; the actions column * swaps the Muuda link for cancel / commit buttons. The cells read editor @@ -339,7 +391,7 @@ const bookingShowcaseColumns: ColumnDef[] = [ id: 'dateRange', header: 'Kuupäev', accessorKey: 'dateRange', - cell: ({ row }) => , + cell: ({ row }) => , }, { id: 'hour', @@ -640,7 +692,7 @@ const mergedCellsColumns: ColumnDef[] = [ ); }, - cell: ({ row }) => , + cell: ({ row }) => , }, { id: 'aeg', From 94f19b5ef1dfa925a210acd71dbd95f2eaa986df Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 18 May 2026 09:21:29 +0300 Subject: [PATCH 31/32] feat(table): update consumer skill for wcag #122 --- skills/tedi-react/references/components.md | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) 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 From 69f5b1a16af927cf5a4c444315344a8de263af80 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 18 May 2026 09:44:19 +0300 Subject: [PATCH 32/32] fix(table): replace custom styled links with buttons in stories #122 --- .../content/table/table.stories.tsx | 83 ++----------------- 1 file changed, 5 insertions(+), 78 deletions(-) diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 332b2431..b32c603d 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -199,21 +199,6 @@ const doctors: Doctor[] = Array.from({ length: 28 }, (_, index) => ({ id: String(index + 1), })); -const editLinkStyle: React.CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - gap: 4, - color: 'var(--link-primary-default)', - textDecoration: 'none', - fontWeight: 'var(--body-regular-weight)', -}; - -const nameLinkStyle: React.CSSProperties = { - color: 'var(--link-primary-default)', - textDecoration: 'none', - fontWeight: 'var(--body-regular-weight)', -}; - const editRowActionsStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', @@ -232,13 +217,6 @@ interface EditableRows { commitEdit: () => void; } -/** - * Context that flows the editor into the table cells. Cells read the latest - * state via context instead of taking the editor as a prop — that way the - * `columns` array can be a module-level constant and TanStack never rebuilds - * its column instances, which is what kept the TextField mounted across - * keystrokes so it doesn't lose focus. - */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const EditableRowsContext = createContext | null>(null); @@ -248,19 +226,11 @@ function useEditor(): EditableRows { return editor as EditableRows; } -/** - * Tiny shared state machine for row-level inline editing. Tracks which row - * (if any) is currently in edit mode and the draft copy of its values; commits - * or discards back to the parent array on confirm / cancel. Reused across all - * stories that ship a "Muuda" affordance so the button actually does something. - */ function useEditableRows(initial: T[]): EditableRows { const [rows, setRows] = useState(initial); const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(null); - // Read latest draft from a ref inside commitEdit so the callback identity - // can stay stable across renders without the `draft` dep. const draftRef = useRef(draft); draftRef.current = draft; @@ -283,10 +253,6 @@ function useEditableRows(initial: T[]): EditableRows({ row }: { row: T }) { const editor = useEditor(); if (row.id === editor.editingId) { @@ -301,22 +267,13 @@ function EditActionsCell({ row }: { row: T }) { } return ( - { - event.preventDefault(); - editor.beginEdit(row); - }} - style={editLinkStyle} - > - + ); } -/** Cell renderer that flips to a `` when its row is editing. */ function EditableTextCell({ row, field, @@ -347,7 +304,6 @@ function EditableTextCell({ ); } -/** Cell renderer that flips to a `` when its row is editing. */ function EditableDateRangeCell({ row, field, @@ -380,12 +336,6 @@ function EditableDateRangeCell({ ); } -/** - * Each cell flips into a TextField when its row is being edited; the actions column - * swaps the Muuda link for cancel / commit buttons. The cells read editor - * state from `EditableRowsContext`, so this array stays a stable module-level - * constant — important for TanStack reconciliation across keystrokes. - */ const bookingShowcaseColumns: ColumnDef[] = [ { id: 'dateRange', @@ -419,10 +369,6 @@ const bookingShowcaseColumns: ColumnDef[] = [ }, ]; -/** - * Baseline render: a single default-size booking table — same content used in - * the `Sizes` showcase below, just on its own. - */ export const Default: Story = { render: function Default() { const editor = useEditableRows(bookings); @@ -474,11 +420,7 @@ const simplePeopleColumns: ColumnDef[] = [ id: 'name', header: 'Isik', accessorKey: 'name', - cell: ({ row }) => ( - event.preventDefault()} style={nameLinkStyle}> - {row.original.name} - - ), + cell: ({ row }) => , }, { id: 'age', header: 'Vanus', accessorKey: 'age' }, { id: 'visits', header: 'Külastuste arv', accessorKey: 'visits' }, @@ -668,10 +610,6 @@ const initialsOf = (name: string) => .slice(0, 2) .join(''); -/** - * Two-level header using column groups. Nest column definitions under `columns` inside a parent - * `columnDef` — TanStack Table will render the parent as a merged header cell spanning its children. - */ const mergedCellsColumns: ColumnDef[] = [ { id: 'dateRange', @@ -922,16 +860,6 @@ export const Sortable: Story = { }, }; -/** - * Each filterable header pairs a `unfold_more`/`arrow_upward`/`arrow_downward` - * sort chevron with a `filter_alt` funnel `Popover` trigger. Popover contents - * vary per column: - * - text filter for `Nimi` - * - date-range (Alates / Kuni) for `Töö algus` - * - multi-select checkbox list for `Tõendi staatus` - * - * Both icons tint `brand` when their state is active; otherwise `tertiary`. - */ type CertStatus = 'Kehtiv' | 'Kehtetu' | 'Aegumas' | 'Aegunud'; const certStatusColor: Record = { @@ -1451,10 +1379,9 @@ const stickyDoctorColumns: ColumnDef[] = [ size: 1, cell: () => ( - event.preventDefault()} style={editLinkStyle}> - + ), },