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 ( + <> + setPage(4)}> + jump + + + > + ); + }; + + 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 && ( + + + {items.map((item, index) => { + if (item.type === 'ellipsis') { + return ( + + … + + ); + } + + if (item.type === 'previous' || item.type === 'next') { + const label = item.type === 'previous' ? mergedLabels.previous : mergedLabels.next; + const iconName = item.type === 'previous' ? 'arrow_back' : 'arrow_forward'; + return ( + + item.page !== null && handlePageChange(item.page)} + noStyle + > + + + + ); + } + + const pageNumber = item.page as number; + return ( + + handlePageChange(pageNumber)} + noStyle + > + {pageNumber} + + + ); + })} + + + )} + + + + {showPageSizeSelect && ( + + + {mergedLabels.pageSize} + + + + )} + + + ); +}; + +Pagination.displayName = 'Pagination'; + +export default Pagination; diff --git a/src/tedi/components/navigation/pagination/pagination.types.ts b/src/tedi/components/navigation/pagination/pagination.types.ts new file mode 100644 index 00000000..5c434e73 --- /dev/null +++ b/src/tedi/components/navigation/pagination/pagination.types.ts @@ -0,0 +1,108 @@ +export type PaginationItemType = 'page' | 'previous' | 'next' | 'ellipsis'; + +export interface PaginationItem { + type: PaginationItemType; + page: number | null; + selected: boolean; + disabled: boolean; +} + +export interface PaginationLabels { + /** + * Accessible label for the nav wrapper. + * @default 'Pagination' + */ + ariaLabel: string; + /** + * Previous button label (icon-only, used as aria-label). + * @default 'Previous page' + */ + previous: string; + /** + * Next button label (icon-only, used as aria-label). + * @default 'Next page' + */ + next: string; + /** + * Function that builds the aria-label for a numeric page button. + * @default (page) => `Go to page ${page}` + */ + pageAriaLabel: (page: number) => string; + /** + * Function that builds the aria-label for the currently active page. + * @default (page) => `Current page, page ${page}` + */ + currentPageAriaLabel: (page: number) => string; + /** + * Rendered to the left of the pagination nav when `totalItems` is set. + * @default (count) => `${count} results` + */ + results: (count: number) => string; + /** + * Prefix label for the page-size select. + * @default 'Show per page' + */ + pageSize: string; +} + +export interface PaginationProps { + /** + * Total number of pages. + */ + pageCount: number; + /** + * Controlled current page (1-based). Pair with `onPageChange`. + */ + page?: number; + /** + * Initial page for uncontrolled mode (1-based). + * @default 1 + */ + defaultPage?: number; + /** + * Fires whenever the user navigates to a different page. + */ + onPageChange?: (page: number) => void; + /** + * Total number of items across all pages. Renders a "{count} results" label + * to the left of the nav when set. + */ + totalItems?: number; + /** + * Current page size. Shown in the page-size select when + * `pageSizeOptions` is provided. + */ + pageSize?: number; + /** + * Options for the page-size select. Omit to hide the select. + */ + pageSizeOptions?: number[]; + /** + * Fires when the user picks a different page size. + */ + onPageSizeChange?: (pageSize: number) => void; + /** + * Number of pages always shown at the start and end of the range before + * ellipsis kicks in. + * @default 1 + */ + boundaryCount?: number; + /** + * Number of sibling pages shown on either side of the current page. + * @default 1 + */ + siblingCount?: number; + /** + * Visual size of the buttons. + * @default 'medium' + */ + size?: 'medium' | 'small'; + /** + * Override any of the default text labels / aria labels. + */ + labels?: Partial; + /** + * Additional class name on the root element. + */ + className?: string; +} diff --git a/src/tedi/components/navigation/pagination/usage-with-router.mdx b/src/tedi/components/navigation/pagination/usage-with-router.mdx new file mode 100644 index 00000000..c32e9a93 --- /dev/null +++ b/src/tedi/components/navigation/pagination/usage-with-router.mdx @@ -0,0 +1,71 @@ +import { Meta } from '@storybook/blocks'; + + + +# Usage with React Router + +Pagination state maps naturally to URL search params — bind `page` (and +optionally `pageSize`) to `useSearchParams` so users can bookmark, share, and +refresh a specific page of the result set. + +--- + +## Example + +```tsx +import { useSearchParams } from 'react-router-dom'; +import { Pagination } from '@tedi-design-system/react/tedi'; + +const PAGE_SIZE_OPTIONS = [10, 25, 50]; + +function ResultsWithPagination({ totalItems }: { totalItems: number }) { + const [searchParams, setSearchParams] = useSearchParams(); + + const pageSize = Number(searchParams.get('pageSize') ?? PAGE_SIZE_OPTIONS[0]); + const pageCount = Math.max(1, Math.ceil(totalItems / pageSize)); + const page = Math.min(pageCount, Math.max(1, Number(searchParams.get('page') ?? 1))); + + const update = (next: Partial<{ page: number; pageSize: number }>) => { + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + if (next.page !== undefined) { + if (next.page === 1) params.delete('page'); + else params.set('page', String(next.page)); + } + if (next.pageSize !== undefined) { + params.set('pageSize', String(next.pageSize)); + params.delete('page'); // jump back to the first page on size change + } + return params; + }, + { replace: true } + ); + }; + + return ( + update({ page: next })} + totalItems={totalItems} + pageSize={pageSize} + pageSizeOptions={PAGE_SIZE_OPTIONS} + onPageSizeChange={(next) => update({ pageSize: next })} + /> + ); +} +``` + +--- + +## Key Points + +- Bind `page` (and optionally `pageSize`) to `useSearchParams` so users can + bookmark and share URLs. +- Pass `{ replace: true }` to `setSearchParams` — paging is navigation within + the same view and shouldn't stack history entries. +- Clamp the URL-derived page into `[1, pageCount]` before feeding it to + ``; URLs can be hand-edited and may hold stale values. +- Reset `page` back to 1 whenever `pageSize` changes, and omit `page=1` from + the URL to keep the default URL clean. diff --git a/src/tedi/components/navigation/pagination/use-pagination.ts b/src/tedi/components/navigation/pagination/use-pagination.ts new file mode 100644 index 00000000..951d568b --- /dev/null +++ b/src/tedi/components/navigation/pagination/use-pagination.ts @@ -0,0 +1,106 @@ +import type { PaginationItem, PaginationItemType } from './pagination.types'; + +export interface UsePaginationArgs { + /** 1-based current page. */ + page: number; + /** Total page count. */ + pageCount: number; + /** Pages always shown at the very start and very end. */ + boundaryCount?: number; + /** Pages shown on either side of the current page. */ + siblingCount?: number; +} + +/** + * Computes the ordered list of pagination items (previous, page-buttons, ellipsis, next) + * using the boundary + sibling strategy popularised by MUI. + * + * Edge cases: + * - `pageCount <= 0` → returns `[]` (nothing to render). + * - `page` is clamped to `[1, pageCount]` before computation. + * - Adjacent numeric pages never collapse into ellipsis (an ellipsis only appears + * when there is a true gap of ≥ 2). + */ +export function usePagination({ + page, + pageCount, + boundaryCount = 1, + siblingCount = 1, +}: UsePaginationArgs): PaginationItem[] { + if (pageCount <= 0) return []; + + const safeBoundary = Math.max(0, boundaryCount); + const safeSibling = Math.max(0, siblingCount); + const currentPage = Math.max(1, Math.min(pageCount, page)); + + const range = (start: number, end: number): number[] => { + if (end < start) return []; + return Array.from({ length: end - start + 1 }, (_, index) => start + index); + }; + + const startPages = range(1, Math.min(safeBoundary, pageCount)); + const endPages = range(Math.max(pageCount - safeBoundary + 1, safeBoundary + 1), pageCount); + + const siblingsStart = Math.max( + Math.min(currentPage - safeSibling, pageCount - safeBoundary - safeSibling * 2 - 1), + safeBoundary + 2 + ); + + const siblingsEnd = Math.min( + Math.max(currentPage + safeSibling, safeBoundary + safeSibling * 2 + 2), + endPages.length > 0 ? endPages[0] - 2 : pageCount - 1 + ); + + const middle = range(siblingsStart, siblingsEnd); + + const pageList: (number | 'ellipsis')[] = [ + ...startPages, + ...(siblingsStart > safeBoundary + 2 + ? ['ellipsis' as const] + : safeBoundary + 1 < pageCount - safeBoundary + ? [safeBoundary + 1] + : []), + ...middle, + ...(siblingsEnd < pageCount - safeBoundary - 1 + ? ['ellipsis' as const] + : pageCount - safeBoundary > safeBoundary + ? [pageCount - safeBoundary] + : []), + ...endPages, + ]; + + const deduped: (number | 'ellipsis')[] = []; + for (const entry of pageList) { + const last = deduped[deduped.length - 1]; + if (entry === 'ellipsis' && last === 'ellipsis') continue; + if (typeof entry === 'number' && entry === last) continue; + deduped.push(entry); + } + + const items: PaginationItem[] = [ + { + type: 'previous' as PaginationItemType, + page: currentPage > 1 ? currentPage - 1 : null, + selected: false, + disabled: currentPage <= 1, + }, + ...deduped.map((entry) => + entry === 'ellipsis' + ? { type: 'ellipsis', page: null, selected: false, disabled: true } + : { + type: 'page', + page: entry, + selected: entry === currentPage, + disabled: false, + } + ), + { + type: 'next', + page: currentPage < pageCount ? currentPage + 1 : null, + selected: false, + disabled: currentPage >= pageCount, + }, + ]; + + return items; +} diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 7493812a..efd1819e 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -25,6 +25,7 @@ export * from './components/notifications/toast/toast'; export * from './components/cards/card'; export * from './components/navigation/hash-trigger/hash-trigger'; export * from './components/navigation/link/link'; +export * from './components/navigation/pagination'; export * from './components/navigation/tabs'; export * from './components/form/textfield/textfield'; export * from './components/form/textarea/textarea'; From 9c19a0b832dd6d14be6a98a29e7006124169d960 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:13:49 +0300 Subject: [PATCH 02/32] feat(pagination): behaviour improvements, react router guide #20 --- .../pagination/pagination.module.scss | 17 ++-- .../navigation/pagination/pagination.spec.tsx | 30 +++++++ .../pagination/pagination.stories.tsx | 6 +- .../navigation/pagination/pagination.types.ts | 2 +- .../navigation/pagination/use-pagination.ts | 89 ++++++++++--------- 5 files changed, 86 insertions(+), 58 deletions(-) diff --git a/src/tedi/components/navigation/pagination/pagination.module.scss b/src/tedi/components/navigation/pagination/pagination.module.scss index 9b3fa98c..29ca5544 100644 --- a/src/tedi/components/navigation/pagination/pagination.module.scss +++ b/src/tedi/components/navigation/pagination/pagination.module.scss @@ -42,7 +42,7 @@ .tedi-pagination__list { display: flex; flex-wrap: wrap; - gap: var(--tedi-dimensions-03); + gap: var(--layout-grid-gutters-08); align-items: center; padding: 0; margin: 0; @@ -57,8 +57,6 @@ .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; } @@ -67,19 +65,14 @@ 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); + min-width: var(--pagination-button-size); + height: var(--pagination-button-size); 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; + border-radius: 100%; + transition: color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; &:hover:not(:disabled, .tedi-pagination__button--selected) { color: var(--button-main-neutral-text-hover); diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx index 04f32347..4956ad1a 100644 --- a/src/tedi/components/navigation/pagination/pagination.spec.tsx +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -72,6 +72,36 @@ describe('usePagination', () => { expect(first[0]).toEqual(expect.objectContaining({ type: 'previous', disabled: true })); expect(last[last.length - 1]).toEqual(expect.objectContaining({ type: 'next', disabled: true })); }); + + it('produces the same number of slots for every page when pageCount exceeds the window', () => { + const pageCount = 30; + const counts = new Set( + Array.from({ length: pageCount }, (_, index) => usePagination({ page: index + 1, pageCount }).length) + ); + expect(counts.size).toBe(1); + }); + + it('keeps the slot count constant with custom boundary + sibling counts', () => { + const counts = new Set( + Array.from( + { length: 25 }, + (_, index) => usePagination({ page: index + 1, pageCount: 25, boundaryCount: 2, siblingCount: 2 }).length + ) + ); + expect(counts.size).toBe(1); + }); + + it('swaps the ellipsis for an extra page number when near the start boundary', () => { + const nearStart = usePagination({ page: 2, pageCount: 20 }); + const middle = usePagination({ page: 10, pageCount: 20 }); + + const ellipsesAtStart = nearStart.filter((item) => item.type === 'ellipsis').length; + const ellipsesAtMiddle = middle.filter((item) => item.type === 'ellipsis').length; + + expect(ellipsesAtStart).toBe(1); + expect(ellipsesAtMiddle).toBe(2); + expect(nearStart.length).toBe(middle.length); + }); }); describe('Pagination component', () => { diff --git a/src/tedi/components/navigation/pagination/pagination.stories.tsx b/src/tedi/components/navigation/pagination/pagination.stories.tsx index b71b0b08..e1eacfe5 100644 --- a/src/tedi/components/navigation/pagination/pagination.stories.tsx +++ b/src/tedi/components/navigation/pagination/pagination.stories.tsx @@ -8,12 +8,16 @@ 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 ↗ + * Figma ↗ + * ZeroHeight ↗ */ const meta: Meta = { component: Pagination, title: 'TEDI-Ready/Components/Navigation/Pagination', parameters: { + status: { + type: 'partiallyTediReady', + }, design: { type: 'figma', url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=8478-72385&m=dev', diff --git a/src/tedi/components/navigation/pagination/pagination.types.ts b/src/tedi/components/navigation/pagination/pagination.types.ts index 5c434e73..620ead1f 100644 --- a/src/tedi/components/navigation/pagination/pagination.types.ts +++ b/src/tedi/components/navigation/pagination/pagination.types.ts @@ -94,7 +94,7 @@ export interface PaginationProps { siblingCount?: number; /** * Visual size of the buttons. - * @default 'medium' + * @default medium */ size?: 'medium' | 'small'; /** diff --git a/src/tedi/components/navigation/pagination/use-pagination.ts b/src/tedi/components/navigation/pagination/use-pagination.ts index 951d568b..8223ab65 100644 --- a/src/tedi/components/navigation/pagination/use-pagination.ts +++ b/src/tedi/components/navigation/pagination/use-pagination.ts @@ -11,15 +11,26 @@ export interface UsePaginationArgs { siblingCount?: number; } +const range = (start: number, end: number): number[] => { + if (end < start) return []; + return Array.from({ length: end - start + 1 }, (_, index) => start + index); +}; + /** - * Computes the ordered list of pagination items (previous, page-buttons, ellipsis, next) - * using the boundary + sibling strategy popularised by MUI. + * Computes the ordered list of pagination items (previous, page-buttons, + * ellipsis, next). + * + * The algorithm targets a stable slot count: for any `pageCount` larger than + * the window size, the returned list always has the same number of page + * entries (`boundaryCount * 2 + siblingCount * 2 + 3`). As the current page + * moves toward a boundary, the corresponding ellipsis is swapped for an extra + * adjacent page number rather than collapsing the list — the visual row does + * not expand or contract as the user navigates. * * Edge cases: - * - `pageCount <= 0` → returns `[]` (nothing to render). + * - `pageCount <= 0` → returns `[]`. * - `page` is clamped to `[1, pageCount]` before computation. - * - Adjacent numeric pages never collapse into ellipsis (an ellipsis only appears - * when there is a true gap of ≥ 2). + * - `pageCount <= window size` → every page is rendered, no ellipsis. */ export function usePagination({ page, @@ -33,48 +44,38 @@ export function usePagination({ const safeSibling = Math.max(0, siblingCount); const currentPage = Math.max(1, Math.min(pageCount, page)); - const range = (start: number, end: number): number[] => { - if (end < start) return []; - return Array.from({ length: end - start + 1 }, (_, index) => start + index); - }; - - const startPages = range(1, Math.min(safeBoundary, pageCount)); - const endPages = range(Math.max(pageCount - safeBoundary + 1, safeBoundary + 1), pageCount); + // Constant number of page slots when the list is larger than the window. + // Breakdown: boundary (start) + 1 ellipsis + sibling window (2*sibling+1) + + // 1 ellipsis + boundary (end). Both ellipses are always present — near a + // boundary the "ellipsis" slot is filled with a real page number instead. + const windowSize = safeBoundary * 2 + safeSibling * 2 + 3; - const siblingsStart = Math.max( - Math.min(currentPage - safeSibling, pageCount - safeBoundary - safeSibling * 2 - 1), - safeBoundary + 2 - ); + let pageList: (number | 'ellipsis')[]; - const siblingsEnd = Math.min( - Math.max(currentPage + safeSibling, safeBoundary + safeSibling * 2 + 2), - endPages.length > 0 ? endPages[0] - 2 : pageCount - 1 - ); + if (pageCount <= windowSize) { + pageList = range(1, pageCount); + } else { + // Number of pages to render in the leading / trailing run when we skip an + // ellipsis on that side (boundary already accounts for the "always shown" + // end piece, plus one slot reclaimed from the missing ellipsis). + const edgeRun = safeBoundary + safeSibling * 2 + 2; - const middle = range(siblingsStart, siblingsEnd); - - const pageList: (number | 'ellipsis')[] = [ - ...startPages, - ...(siblingsStart > safeBoundary + 2 - ? ['ellipsis' as const] - : safeBoundary + 1 < pageCount - safeBoundary - ? [safeBoundary + 1] - : []), - ...middle, - ...(siblingsEnd < pageCount - safeBoundary - 1 - ? ['ellipsis' as const] - : pageCount - safeBoundary > safeBoundary - ? [pageCount - safeBoundary] - : []), - ...endPages, - ]; + const startThreshold = safeBoundary + safeSibling + 2; + const endThreshold = pageCount - safeBoundary - safeSibling - 1; - const deduped: (number | 'ellipsis')[] = []; - for (const entry of pageList) { - const last = deduped[deduped.length - 1]; - if (entry === 'ellipsis' && last === 'ellipsis') continue; - if (typeof entry === 'number' && entry === last) continue; - deduped.push(entry); + if (currentPage <= startThreshold) { + pageList = [...range(1, edgeRun), 'ellipsis', ...range(pageCount - safeBoundary + 1, pageCount)]; + } else if (currentPage >= endThreshold) { + pageList = [...range(1, safeBoundary), 'ellipsis', ...range(pageCount - edgeRun + 1, pageCount)]; + } else { + pageList = [ + ...range(1, safeBoundary), + 'ellipsis', + ...range(currentPage - safeSibling, currentPage + safeSibling), + 'ellipsis', + ...range(pageCount - safeBoundary + 1, pageCount), + ]; + } } const items: PaginationItem[] = [ @@ -84,7 +85,7 @@ export function usePagination({ selected: false, disabled: currentPage <= 1, }, - ...deduped.map((entry) => + ...pageList.map((entry) => entry === 'ellipsis' ? { type: 'ellipsis', page: null, selected: false, disabled: true } : { From a69e136dfff844f56bb73413aaee70809b856366 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:44:09 +0300 Subject: [PATCH 03/32] feat(empty-state): new TEDI-Ready component #10 --- .../empty-state/empty-state.module.scss | 66 +++++++++ .../empty-state/empty-state.spec.tsx | 108 ++++++++++++++ .../empty-state/empty-state.stories.tsx | 140 ++++++++++++++++++ .../notifications/empty-state/empty-state.tsx | 111 ++++++++++++++ .../notifications/empty-state/index.ts | 2 + src/tedi/index.ts | 1 + 6 files changed, 428 insertions(+) create mode 100644 src/tedi/components/notifications/empty-state/empty-state.module.scss create mode 100644 src/tedi/components/notifications/empty-state/empty-state.spec.tsx create mode 100644 src/tedi/components/notifications/empty-state/empty-state.stories.tsx create mode 100644 src/tedi/components/notifications/empty-state/empty-state.tsx create mode 100644 src/tedi/components/notifications/empty-state/index.ts diff --git a/src/tedi/components/notifications/empty-state/empty-state.module.scss b/src/tedi/components/notifications/empty-state/empty-state.module.scss new file mode 100644 index 00000000..b887ead5 --- /dev/null +++ b/src/tedi/components/notifications/empty-state/empty-state.module.scss @@ -0,0 +1,66 @@ +.tedi-empty-state { + display: flex; + flex-direction: column; + gap: var(--empty-state-inner-spacing-y-lg); + align-items: center; + justify-content: center; + width: 100%; + padding: var(--empty-state-padding); + background: var(--empty-state-background-primary); + border: 1px solid var(--empty-state-border); + border-radius: var(--empty-state-radius); +} + +.tedi-empty-state--small { + padding: var(--empty-state-padding-sm); +} + +.tedi-empty-state--attached { + border-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.tedi-empty-state--inside { + background: transparent; + border: 0; + border-radius: 0; +} + +.tedi-empty-state__text { + display: flex; + flex-direction: column; + gap: var(--empty-state-inner-spacing-y-md); + align-items: center; + justify-content: center; + width: 100%; +} + +.tedi-empty-state__icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--empty-state-icon-primary); +} + +.tedi-empty-state__content { + display: flex; + flex-direction: column; + gap: var(--empty-state-inner-spacing-y-sm); + align-items: center; + width: 100%; + text-align: center; +} + +.tedi-empty-state__heading, +.tedi-empty-state__description { + margin: 0; +} + +.tedi-empty-state__actions { + display: flex; + flex-wrap: wrap; + gap: var(--tedi-dimensions-05); + align-items: center; + justify-content: center; +} diff --git a/src/tedi/components/notifications/empty-state/empty-state.spec.tsx b/src/tedi/components/notifications/empty-state/empty-state.spec.tsx new file mode 100644 index 00000000..4bf1ae8a --- /dev/null +++ b/src/tedi/components/notifications/empty-state/empty-state.spec.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; + +import { EmptyState } from './empty-state'; + +import '@testing-library/jest-dom'; + +describe('EmptyState', () => { + it('renders the description passed as children', () => { + render(Nothing to see here); + expect(screen.getByText('Nothing to see here')).toBeInTheDocument(); + }); + + it('renders the default spa icon when no icon prop is provided', () => { + const { container } = render(Empty); + const icon = container.querySelector('[data-name="icon"]'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveTextContent('spa'); + }); + + it('renders the icon named by a string icon prop', () => { + const { container } = render(Empty); + const icon = container.querySelector('[data-name="icon"]'); + expect(icon).toHaveTextContent('event_busy'); + }); + + it('accepts a full IconProps object for the icon', () => { + const { container } = render(Empty); + const icon = container.querySelector('[data-name="icon"]'); + expect(icon).toHaveTextContent('inbox'); + }); + + it('renders an arbitrary ReactNode icon', () => { + render( + + + + } + > + Empty + + ); + expect(screen.getByLabelText('custom-illustration')).toBeInTheDocument(); + }); + + it('hides the icon when icon is null', () => { + const { container } = render(Empty); + expect(container.querySelector('[data-name="icon"]')).not.toBeInTheDocument(); + }); + + it('renders a heading as an h3 in brand color', () => { + const { container } = render(You have no data to display); + const heading = container.querySelector('h3'); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveTextContent('Choose new time'); + }); + + it('renders the actions slot', () => { + render(Create new}>Empty); + expect(screen.getByRole('button', { name: 'Create new' })).toBeInTheDocument(); + }); + + it('applies the separate type class by default', () => { + const { container } = render(Empty); + const root = container.querySelector('[data-name="tedi-empty-state"]'); + expect(root?.className).toMatch(/--separate/); + }); + + it.each([ + ['separate', '--separate'], + ['attached', '--attached'], + ['inside', '--inside'], + ] as const)('applies the %s type class', (type, fragment) => { + const { container } = render(Empty); + const root = container.querySelector('[data-name="tedi-empty-state"]'); + expect(root?.className).toContain(fragment); + }); + + it.each([ + ['default', '--default'], + ['small', '--small'], + ] as const)('applies the %s size class', (size, fragment) => { + const { container } = render(Empty); + const root = container.querySelector('[data-name="tedi-empty-state"]'); + expect(root?.className).toContain(fragment); + }); + + it('merges a custom className onto the root', () => { + const { container } = render(Empty); + expect(container.querySelector('[data-name="tedi-empty-state"]')?.className).toContain('my-empty'); + }); + + it('omits the content wrapper when neither heading nor description is provided', () => { + const { container } = render({null}); + const contentDivs = container.querySelectorAll('[class*="tedi-empty-state__content"]'); + expect(contentDivs).toHaveLength(0); + }); + + it('omits the actions wrapper when actions is not provided', () => { + const { container } = render(Empty); + expect(container.querySelector('[class*="tedi-empty-state__actions"]')).not.toBeInTheDocument(); + }); + + it('has the expected displayName', () => { + expect(EmptyState.displayName).toBe('EmptyState'); + }); +}); diff --git a/src/tedi/components/notifications/empty-state/empty-state.stories.tsx b/src/tedi/components/notifications/empty-state/empty-state.stories.tsx new file mode 100644 index 00000000..521520bc --- /dev/null +++ b/src/tedi/components/notifications/empty-state/empty-state.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '../../buttons/button/button'; +import { Card, CardContent } from '../../cards/card'; +import { Link } from '../../navigation/link/link'; +import type { EmptyStateProps } from './empty-state'; +import { EmptyState } from './empty-state'; + +/** + * EmptyState communicates that there is nothing to display — empty search + * results, an unpopulated list, a freshly-created workspace — and optionally + * guides the user toward the next step via action buttons or a link. + * + * Figma ↗ + * Zeroheight ↗ + */ +const meta: Meta = { + component: EmptyState, + title: 'TEDI-Ready/Components/Helpers/EmptyState', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=2413-40492&m=dev', + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'You have no data to display', + }, +}; + +export const WithPrimaryAction: Story = { + args: { + children: 'You have no data to display', + actions: ( + + Create new + + ), + }, +}; + +export const WithSecondaryAction: Story = { + args: { + children: 'You have no data to display', + actions: ( + + Create new + + ), + }, +}; + +export const WithLink: Story = { + args: { + children: 'You have no data to display', + actions: ( + + Read more + + ), + }, +}; + +export const WithHeading: Story = { + args: { + icon: 'event_busy', + heading: 'Choose new time', + children: 'You have no data to display', + actions: Choose time, + }, +}; + +export const Minimal: Story = { + args: { + icon: null, + children: 'You have no data to display', + }, +}; + +export const SmallPadding: Story = { + args: { + children: 'You have no data to display', + size: 'small', + actions: ( + <> + + Create new + + + Read more + + > + ), + }, +}; + +export const Separate: Story = { + args: { + children: 'You have no data to display', + type: 'separate', + }, +}; + +export const AttachedToComponent: Story = { + render: () => ( + + + Previous content + + You have no data to display + + ), +}; + +export const InsideComponent: Story = { + render: () => ( + + + You have no data to display + + + ), +}; + +/** + * Any ReactNode can be passed as `icon` — useful when you have a bespoke SVG + * or illustration. + */ +export const CustomIcon: Story = { + args: { + children: 'No products in your cart', + icon: { name: 'shopping_cart_off' }, + }, +}; diff --git a/src/tedi/components/notifications/empty-state/empty-state.tsx b/src/tedi/components/notifications/empty-state/empty-state.tsx new file mode 100644 index 00000000..712be507 --- /dev/null +++ b/src/tedi/components/notifications/empty-state/empty-state.tsx @@ -0,0 +1,111 @@ +import cn from 'classnames'; +import React from 'react'; + +import { Icon, type IconProps } from '../../base/icon/icon'; +import { Heading } from '../../base/typography/heading/heading'; +import { Text } from '../../base/typography/text/text'; +import styles from './empty-state.module.scss'; + +export type EmptyStateType = 'separate' | 'attached' | 'inside'; +export type EmptyStateSize = 'default' | 'small'; + +export interface EmptyStateProps { + /** + * Container variant — matches the Figma "Types" section. + * - `'separate'` (default) — full border + radius, stands on its own. + * - `'attached'` — top border omitted so the block sits flush beneath a + * preceding card or table (same width + same bottom-radius). + * - `'inside'` — no border, no radius; intended to be placed inside another + * container such as a `` or ``. + * @default separate + */ + type?: EmptyStateType; + /** + * Padding scale. `default` = 24px, `small` = 16px. + * @default default + */ + size?: EmptyStateSize; + /** + * Icon rendered above the text block. Pass a Material icon name, a full + * `IconProps` object, any React node (e.g. a custom SVG), or `null` to hide + * the icon. + * @default spa + */ + icon?: string | IconProps | React.ReactNode | null; + /** + * Optional heading rendered above the description — appears as an H3 in + * brand-primary text color. + */ + heading?: React.ReactNode; + /** + * Main body text describing why there is nothing to show. + */ + children?: React.ReactNode; + /** + * Call-to-action slot. Typically a `` (or two) or a ``. + * Rendered below the text block. + */ + actions?: React.ReactNode; + /** + * Additional class name on the root element. + */ + className?: string; +} + +const isIconPropsObject = (value: unknown): value is IconProps => + typeof value === 'object' && value !== null && !React.isValidElement(value) && 'name' in (value as IconProps); + +export const EmptyState = ({ + type = 'separate', + size = 'default', + icon = 'spa', + heading, + children, + actions, + className, +}: EmptyStateProps): JSX.Element => { + const rootClassName = cn( + styles['tedi-empty-state'], + styles[`tedi-empty-state--${type}`], + styles[`tedi-empty-state--${size}`], + className + ); + + const renderedIcon = (() => { + if (icon === null || icon === undefined) return null; + if (typeof icon === 'string') { + return ; + } + if (isIconPropsObject(icon)) { + return ; + } + return icon; + })(); + + return ( + + + {renderedIcon && {renderedIcon}} + {(heading || children) && ( + + {heading && ( + + {heading} + + )} + {children && ( + + {children} + + )} + + )} + + {actions && {actions}} + + ); +}; + +EmptyState.displayName = 'EmptyState'; + +export default EmptyState; diff --git a/src/tedi/components/notifications/empty-state/index.ts b/src/tedi/components/notifications/empty-state/index.ts new file mode 100644 index 00000000..e5c80c5f --- /dev/null +++ b/src/tedi/components/notifications/empty-state/index.ts @@ -0,0 +1,2 @@ +export { EmptyState } from './empty-state'; +export type { EmptyStateProps, EmptyStateSize, EmptyStateType } from './empty-state'; diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 7493812a..8a37c5ca 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -21,6 +21,7 @@ export * from './components/buttons/collapse/collapse'; export * from './components/layout/vertical-spacing'; export * from './components/layout/grid'; export * from './components/notifications/alert/alert'; +export * from './components/notifications/empty-state'; export * from './components/notifications/toast/toast'; export * from './components/cards/card'; export * from './components/navigation/hash-trigger/hash-trigger'; From 4f85b5005e89d874fb5f20c9bab64363e01ce997 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:42:47 +0300 Subject: [PATCH 04/32] feat(table): initial commit #112 --- src/tedi/components/content/table/index.ts | 5 + .../table-columns-menu/table-columns-menu.tsx | 73 +++ .../content/table/table-context.tsx | 11 + .../content/table/table.module.scss | 215 +++++++++ .../components/content/table/table.spec.tsx | 448 ++++++++++++++++++ .../content/table/table.stories.tsx | 406 ++++++++++++++++ src/tedi/components/content/table/table.tsx | 439 +++++++++++++++++ .../components/content/table/table.types.ts | 188 ++++++++ .../content/table/use-table-persistence.ts | 92 ++++ src/tedi/index.ts | 1 + 10 files changed, 1878 insertions(+) create mode 100644 src/tedi/components/content/table/index.ts create mode 100644 src/tedi/components/content/table/table-columns-menu/table-columns-menu.tsx create mode 100644 src/tedi/components/content/table/table-context.tsx create mode 100644 src/tedi/components/content/table/table.module.scss create mode 100644 src/tedi/components/content/table/table.spec.tsx create mode 100644 src/tedi/components/content/table/table.stories.tsx create mode 100644 src/tedi/components/content/table/table.tsx create mode 100644 src/tedi/components/content/table/table.types.ts create mode 100644 src/tedi/components/content/table/use-table-persistence.ts diff --git a/src/tedi/components/content/table/index.ts b/src/tedi/components/content/table/index.ts new file mode 100644 index 00000000..6d2c242b --- /dev/null +++ b/src/tedi/components/content/table/index.ts @@ -0,0 +1,5 @@ +export { Table } from './table'; +export type { TableProps, TableState, TableSize, TablePersistOptions, TableContextValue } from './table.types'; +export { TableColumnsMenu } from './table-columns-menu/table-columns-menu'; +export type { TableColumnsMenuProps } from './table-columns-menu/table-columns-menu'; +export { useTablePersistence } from './use-table-persistence'; diff --git a/src/tedi/components/content/table/table-columns-menu/table-columns-menu.tsx b/src/tedi/components/content/table/table-columns-menu/table-columns-menu.tsx new file mode 100644 index 00000000..9ef60d1c --- /dev/null +++ b/src/tedi/components/content/table/table-columns-menu/table-columns-menu.tsx @@ -0,0 +1,73 @@ +import { Button } from '../../../buttons/button/button'; +import { Checkbox } from '../../../form/checkbox/checkbox'; +import { Dropdown } from '../../../overlays/dropdown/dropdown'; +import { DropdownContent } from '../../../overlays/dropdown/dropdown-content/dropdown-content'; +import { DropdownItem } from '../../../overlays/dropdown/dropdown-item/dropdown-item'; +import { DropdownTrigger } from '../../../overlays/dropdown/dropdown-trigger/dropdown-trigger'; +import { useTableContext } from '../table-context'; + +export interface TableColumnsMenuProps { + /** + * Trigger label. + * @default "Columns" + */ + triggerLabel?: React.ReactNode; + /** + * Additional class name on the dropdown trigger button. + */ + className?: string; +} + +/** + * Dropdown-based menu that toggles individual column visibility through the + * parent `Table`'s state. Uses the column's `columnDef.header` (string) or + * `columnDef.id` as the checkbox label. + * + * Prevents hiding the last visible column — a table with zero columns has no + * useful state to reach. + */ +export const TableColumnsMenu = ({ triggerLabel = 'Columns', className }: TableColumnsMenuProps) => { + const { table, id } = useTableContext(); + + const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide()); + const visibleCount = hideableColumns.filter((column) => column.getIsVisible()).length; + + const resolveHeader = (column: (typeof hideableColumns)[number]) => { + const header = column.columnDef.header; + return typeof header === 'string' ? header : column.id; + }; + + return ( + + + + {triggerLabel} + + + + {hideableColumns.map((column) => { + const isVisible = column.getIsVisible(); + const isLastVisible = isVisible && visibleCount === 1; + const checkboxId = `${id ?? 'tedi-table'}-columns-menu-${column.id}`; + const headerLabel = resolveHeader(column); + + return ( + + column.toggleVisibility()} + /> + + ); + })} + + + ); +}; + +TableColumnsMenu.displayName = 'Table.ColumnsMenu'; diff --git a/src/tedi/components/content/table/table-context.tsx b/src/tedi/components/content/table/table-context.tsx new file mode 100644 index 00000000..b00b7b9f --- /dev/null +++ b/src/tedi/components/content/table/table-context.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react'; + +import type { TableContextValue } from './table.types'; + +export const TableContext = createContext(null); + +export function useTableContext(): TableContextValue { + const ctx = useContext(TableContext); + if (!ctx) throw new Error('TableContext missing — wrap the component in .'); + return ctx as TableContextValue; +} diff --git a/src/tedi/components/content/table/table.module.scss b/src/tedi/components/content/table/table.module.scss new file mode 100644 index 00000000..1cac9a6b --- /dev/null +++ b/src/tedi/components/content/table/table.module.scss @@ -0,0 +1,215 @@ +.tedi-table { + display: flex; + flex-direction: column; + gap: var(--tedi-dimensions-10); + width: 100%; +} + +.tedi-table__toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--tedi-dimensions-8); + align-items: center; + justify-content: flex-end; +} + +.tedi-table__scroll { + overflow-x: auto; + background: var(--table-default); + border: 1px solid var(--table-border); + border-radius: var(--table-radius); +} + +.tedi-table__table { + width: 100%; + font-size: var(--body-regular-size); + line-height: var(--body-regular-line-height); + color: var(--general-text-primary); + border-spacing: 0; + border-collapse: collapse; + background: var(--table-default); +} + +.tedi-table__caption { + padding: var(--tedi-dimensions-10) var(--table-header-padding-x); + font-weight: var(--body-regular-weight); + color: var(--general-text-primary); + text-align: left; + caption-side: top; +} + +.tedi-table__head { + background: var(--table-default); + border-bottom: 1px solid var(--table-border-th); +} + +.tedi-table__header-cell { + padding: var(--table-header-padding-y) var(--table-header-padding-x); + font-size: var(--body-regular-size); + font-weight: var(--body-regular-weight); + color: var(--general-text-tertiary); + text-align: left; + white-space: nowrap; + background: var(--table-default); +} + +.tedi-table__row { + border-bottom: 1px solid var(--table-border); + + &:last-child { + border-bottom: 0; + } +} + +.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; +} + +.tedi-table__cell--placeholder { + padding: var(--tedi-dimensions-14) var(--table-data-padding-x); + color: var(--general-text-secondary); + text-align: center; +} + +.tedi-table--small { + .tedi-table__header-cell { + padding: var(--table-header-padding-y-sm) var(--table-header-padding-x-sm); + } + + .tedi-table__cell { + padding: var(--table-data-padding-y-sm) var(--table-data-padding-x-sm); + } +} + +.tedi-table__foot { + font-weight: var(--heading-weight); + background: var(--table-default); + border-top: 1px solid var(--table-border-th); +} + +.tedi-table__cell--footer { + color: var(--general-text-primary); +} + +.tedi-table__row--selected { + background: var(--table-active); +} + +.tedi-table__row--clickable { + cursor: pointer; + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--general-border-focus); + outline-offset: calc(var(--tedi-borders-02) * -1); + } +} + +.tedi-table__row--sub-component { + background: var(--table-striped); +} + +.tedi-table__cell--sub-component { + padding: var(--table-data-padding-y) var(--table-data-padding-x); +} + +.tedi-table__expand-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + color: var(--general-text-primary); + cursor: pointer; + background: transparent; + border: 0; + + &:focus-visible { + border-radius: var(--tedi-radius-02-default); + outline: var(--tedi-borders-02) solid var(--general-border-focus); + outline-offset: var(--tedi-borders-01); + } +} + +.tedi-table__row--filter { + background: var(--general-surface-primary); +} + +.tedi-table__row--filter .tedi-table__header-cell { + padding-top: var(--tedi-dimensions-8); + padding-bottom: var(--tedi-dimensions-8); + font-weight: var(--body-regular-weight); + background: var(--general-surface-primary); +} + +.tedi-table--striped .tedi-table__body .tedi-table__row:nth-of-type(even) { + background: var(--table-striped); +} + +.tedi-table--vertical-borders { + .tedi-table__header-cell, + .tedi-table__cell { + border-right: 1px solid var(--table-border); + } + + .tedi-table__header-cell:last-child, + .tedi-table__row > .tedi-table__cell:last-child { + border-right: 0; + } +} + +.tedi-table--borderless { + .tedi-table__scroll { + background: transparent; + border: 0; + border-radius: 0; + } +} + +.tedi-table--has-pagination { + gap: 0; + + .tedi-table__scroll { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} + +.tedi-table__pagination { + background: var(--general-surface-secondary); + border: 1px solid var(--table-border); + border-top: 0; + border-bottom-right-radius: var(--table-radius); + border-bottom-left-radius: var(--table-radius); +} + +.tedi-table--borderless .tedi-table__pagination { + background: transparent; + border: 0; +} + +.tedi-table--sticky-first-column { + .tedi-table__row > .tedi-table__header-cell:first-child { + position: sticky; + left: 0; + z-index: 2; + background: var(--table-default); + border-right: 1px solid var(--table-border); + } + + .tedi-table__row > .tedi-table__cell:first-child { + position: sticky; + left: 0; + z-index: 1; + background: var(--table-default); + border-right: 1px solid var(--table-border); + } + + &.tedi-table--striped .tedi-table__body .tedi-table__row:nth-of-type(even) > .tedi-table__cell:first-child { + background: var(--table-striped); + } +} diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx new file mode 100644 index 00000000..8ebfa642 --- /dev/null +++ b/src/tedi/components/content/table/table.spec.tsx @@ -0,0 +1,448 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; + +import { Table } from './table'; +import type { TableState } from './table.types'; + +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; + } + }, + }), +})); + +interface Person { + id: string; + name: string; + role: string; +} + +const data: Person[] = [ + { id: '1', name: 'Anna', role: 'Engineer' }, + { id: '2', name: 'Jüri', role: 'Designer' }, +]; + +const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, +]; + +describe('Table', () => { + it('renders the column headers and row data', () => { + render( id="t" data={data} columns={columns} />); + + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Anna' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Designer' })).toBeInTheDocument(); + }); + + it('renders the placeholder when data is empty', () => { + render( id="t-empty" data={[]} columns={columns} placeholder="Nothing here" />); + + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + }); + + it('applies the small size class when size="small"', () => { + const { container } = render( id="t-sm" data={data} columns={columns} size="small" />); + const root = container.querySelector('[data-name="tedi-table"]'); + expect(root?.className).toMatch(/--small/); + }); + + it('renders a caption when provided', () => { + render( id="t-cap" data={data} columns={columns} caption="All people" />); + expect(screen.getByText('All people').tagName).toBe('CAPTION'); + }); + + it('hides a column via defaultState.columnVisibility', () => { + render( + id="t-hidden" data={data} columns={columns} defaultState={{ columnVisibility: { role: false } }} /> + ); + + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + }); + + describe('ColumnsMenu', () => { + it('toggles column visibility through the menu', () => { + render( + id="t-menu" data={data} columns={columns}> + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + const roleCheckbox = screen.getByRole('checkbox', { name: 'Role' }); + + fireEvent.click(roleCheckbox); + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + + fireEvent.click(roleCheckbox); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + }); + + it('prevents hiding the last visible column', () => { + render( + + id="t-menu-last" + data={data} + columns={columns} + defaultState={{ columnVisibility: { role: false } }} + > + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + expect(screen.getByRole('checkbox', { name: 'Name' })).toBeDisabled(); + }); + + it('accepts a custom trigger label', () => { + render( + id="t-menu-label" data={data} columns={columns}> + + + + + ); + + expect(screen.getByRole('button', { name: /Manage columns/i })).toBeInTheDocument(); + }); + + it('throws when rendered outside of ', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => render()).toThrow('TableContext missing'); + spy.mockRestore(); + }); + }); + + describe('state integration', () => { + it('reports state changes through onStateChange', () => { + const onStateChange = jest.fn(); + render( + id="t-ctrl" data={data} columns={columns} onStateChange={onStateChange}> + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Role' })); + + expect(onStateChange).toHaveBeenCalled(); + // The column ends up hidden; assert the last-reported state reflects that. + expect(onStateChange.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ columnVisibility: { role: false } }) + ); + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + }); + + it('respects fully controlled state', () => { + const Wrapper = () => { + const [state, setState] = useState({ columnVisibility: { role: false } }); + return ( + <> + setState({ columnVisibility: {} })}> + reveal-role + + + id="t-fully-controlled" + data={data} + columns={columns} + state={state} + onStateChange={setState} + /> + > + ); + }; + + render(); + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'reveal-role' })); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + }); + }); + + describe('persistence', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('hydrates state from localStorage on mount', () => { + window.localStorage.setItem('persist-hydrate', JSON.stringify({ columnVisibility: { role: false } })); + + render( id="t-hyd" data={data} columns={columns} persist={{ key: 'persist-hydrate' }} />); + + expect(screen.queryByRole('columnheader', { name: 'Role' })).not.toBeInTheDocument(); + }); + + it('writes state changes back to localStorage', () => { + render( + id="t-write" data={data} columns={columns} persist={{ key: 'persist-write' }}> + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Role' })); + + const stored = window.localStorage.getItem('persist-write'); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored as string)).toEqual({ columnVisibility: { role: false } }); + }); + + it('ignores non-included keys when include is provided', () => { + render( + + id="t-incl" + data={data} + columns={columns} + persist={{ key: 'persist-include', include: ['columnOrder'] }} + > + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /Columns/i })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Role' })); + + const stored = window.localStorage.getItem('persist-include'); + expect(JSON.parse(stored as string)).toEqual({}); + }); + + it('falls back gracefully when stored JSON is corrupt', () => { + window.localStorage.setItem('persist-corrupt', '{not json'); + expect(() => + render( id="t-corr" data={data} columns={columns} persist={{ key: 'persist-corrupt' }} />) + ).not.toThrow(); + expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument(); + }); + }); + + describe('variant classes', () => { + const flags = [ + ['striped', '--striped'], + ['verticalBorders', '--vertical-borders'], + ['borderless', '--borderless'], + ['stickyFirstColumn', '--sticky-first-column'], + ] as const; + + it.each(flags)('applies the %s class when the matching prop is true', (prop, fragment) => { + const { container } = render( + id={`t-${prop}`} data={data} columns={columns} {...{ [prop]: true }} /> + ); + expect(container.querySelector('[data-name="tedi-table"]')?.className).toContain(fragment); + }); + }); + + describe('clickable rows', () => { + it('fires onRowClick and marks the row role=button + tabIndex=0', () => { + const onRowClick = jest.fn(); + render( id="t-click" data={data} columns={columns} onRowClick={onRowClick} />); + + const rows = screen.getAllByRole('button'); + expect(rows).toHaveLength(data.length); + expect(rows[0]).toHaveAttribute('tabIndex', '0'); + + fireEvent.click(rows[0]); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick.mock.calls[0][0].original).toEqual(data[0]); + }); + + it('activates on Enter/Space keydown', () => { + const onRowClick = jest.fn(); + render( id="t-click-kb" data={data} columns={columns} onRowClick={onRowClick} />); + + const firstRow = screen.getAllByRole('button')[0]; + fireEvent.keyDown(firstRow, { key: 'Enter' }); + fireEvent.keyDown(firstRow, { key: ' ' }); + fireEvent.keyDown(firstRow, { key: 'Escape' }); + + expect(onRowClick).toHaveBeenCalledTimes(2); + }); + }); + + describe('row selection', () => { + it('auto-injects a select column and toggles row selection', () => { + render( id="t-sel" data={data} columns={columns} enableRowSelection />); + + const checkboxes = screen.getAllByRole('checkbox'); + // 1 header + data.length rows + expect(checkboxes).toHaveLength(data.length + 1); + + fireEvent.click(checkboxes[1]); + expect(checkboxes[1]).toBeChecked(); + }); + + it('select-all toggles every row', () => { + render( id="t-sel-all" data={data} columns={columns} enableRowSelection />); + + const [selectAll, ...rowBoxes] = screen.getAllByRole('checkbox'); + fireEvent.click(selectAll); + + rowBoxes.forEach((box) => expect(box).toBeChecked()); + }); + }); + + describe('expansion', () => { + it('renders the expand column + sub-component when renderSubComponent is provided', () => { + render( + + id="t-exp" + data={data} + columns={columns} + renderSubComponent={(row) => details for {row.original.name}} + /> + ); + + const toggle = screen.getAllByRole('button', { name: /Expand row/i })[0]; + fireEvent.click(toggle); + + expect(screen.getByText(/details for Anna/)).toBeInTheDocument(); + + const collapse = screen.getByRole('button', { name: /Collapse row/i }); + fireEvent.click(collapse); + expect(screen.queryByText(/details for Anna/)).not.toBeInTheDocument(); + }); + }); + + describe('column filters', () => { + it('filters rows based on the per-column filter input', () => { + render( id="t-filter" data={data} columns={columns} enableColumnFilters />); + + expect(screen.getByRole('cell', { name: 'Anna' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Jüri' })).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Filter Name'), { target: { value: 'Anna' } }); + + expect(screen.getByRole('cell', { name: 'Anna' })).toBeInTheDocument(); + expect(screen.queryByRole('cell', { name: 'Jüri' })).not.toBeInTheDocument(); + }); + }); + + describe('footer', () => { + it('renders a tfoot when any column defines footer', () => { + const withFooter: typeof columns = [ + { id: 'name', header: 'Name', accessorKey: 'name', footer: 'Total' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + ]; + render( id="t-foot" data={data} columns={withFooter} />); + + const foot = document.querySelector('tfoot'); + expect(foot).toBeInTheDocument(); + expect(foot).toHaveTextContent('Total'); + }); + + it('omits the tfoot entirely when no column defines footer', () => { + render( id="t-nofoot" data={data} columns={columns} />); + expect(document.querySelector('tfoot')).not.toBeInTheDocument(); + }); + }); + + describe('pagination', () => { + const many: Person[] = Array.from({ length: 7 }, (_, index) => ({ + id: String(index + 1), + name: `Person ${index + 1}`, + role: 'Tester', + })); + + it('renders the pagination bar with prev/next buttons and current page marker', () => { + render( id="t-page" data={many} columns={columns} pagination={{ pageSize: 3 }} />); + + expect(screen.getByRole('navigation', { name: /Pagination/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Current page, page 1/i })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: /Previous page/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Next page/i })).toBeEnabled(); + expect(screen.getByText('7 results')).toBeInTheDocument(); + }); + + it('navigates forward + backward between pages', () => { + render( id="t-page-nav" data={many} columns={columns} pagination={{ pageSize: 3 }} />); + + expect(screen.getByRole('cell', { name: 'Person 1' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Next page/i })); + expect(screen.getByRole('button', { name: /Current page, page 2/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Person 4' })).toBeInTheDocument(); + expect(screen.queryByRole('cell', { name: 'Person 1' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Previous page/i })); + expect(screen.getByRole('button', { name: /Current page, page 1/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Person 1' })).toBeInTheDocument(); + }); + + it('changes page size via the selector and keeps the new page in the viewport', async () => { + render( + + id="t-page-size" + data={many} + columns={columns} + pagination={{ pageSize: 3, pageSizeOptions: [3, 5] }} + /> + ); + + const combobox = screen.getByRole('combobox', { name: /Page size/i }); + + await act(async () => { + combobox.focus(); + fireEvent.keyDown(combobox, { key: 'ArrowDown', code: 'ArrowDown' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + fireEvent.click(screen.getByText('5')); + + expect(screen.getByRole('cell', { name: 'Person 5' })).toBeInTheDocument(); + }); + + it('hides the page-size selector when pageSizeOptions is false', () => { + render( + + id="t-page-no-select" + data={many} + columns={columns} + pagination={{ pageSize: 3, pageSizeOptions: false }} + /> + ); + + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('omits the pagination bar when pagination is not enabled', () => { + render( id="t-no-page" data={data} columns={columns} />); + expect(screen.queryByRole('navigation', { name: /Pagination/i })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx new file mode 100644 index 00000000..8247b641 --- /dev/null +++ b/src/tedi/components/content/table/table.stories.tsx @@ -0,0 +1,406 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useMemo, useState } from 'react'; + +import { Text } from '../../base/typography/text/text'; +import Button from '../../buttons/button/button'; +import { Collapse } from '../../buttons/collapse/collapse'; +import { TextField } from '../../form/textfield/textfield'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { EmptyState } from '../../notifications/empty-state'; +import { Tag } from '../../tags/tag/tag'; +import { Table } from './table'; +import type { TableProps, TableState } from './table.types'; + +/** + * @tanstack/react-table ↗ + * Figma ↗ + * ZeroHeight ↗ + */ +const meta: Meta = { + component: Table, + title: 'TEDI-Ready/Content/Table', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=4514-63761&m=dev', + }, + }, +}; +export default meta; + +interface Person { + id: string; + name: string; + email: string; + role: string; + location: string; + salary: number; + status: 'active' | 'inactive'; +} + +const people: Person[] = [ + { + id: '1', + name: 'Anna Tamm', + email: 'anna.tamm@example.ee', + role: 'Engineer', + location: 'Tallinn', + salary: 4200, + status: 'active', + }, + { + id: '2', + name: 'Jüri Kask', + email: 'juri.kask@example.ee', + role: 'Designer', + location: 'Tartu', + salary: 3800, + status: 'active', + }, + { + id: '3', + name: 'Maria Saar', + email: 'maria.saar@example.ee', + role: 'Product', + location: 'Pärnu', + salary: 4600, + status: 'active', + }, + { + id: '4', + name: 'Mart Mets', + email: 'mart.mets@example.ee', + role: 'Engineer', + location: 'Tallinn', + salary: 4100, + status: 'inactive', + }, + { + id: '5', + name: 'Liis Lepp', + email: 'liis.lepp@example.ee', + role: 'Ops', + location: 'Narva', + salary: 3600, + status: 'active', + }, +]; + +const personColumns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'email', header: 'Email', accessorKey: 'email' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, +]; + +type Story = StoryObj>; + +/** + * Baseline render: headers + rows + the default border/padding chrome. + */ +export const Simple: Story = { + render: () => id="tedi-table-simple" data={people} columns={personColumns} />, +}; + +/** + * Merged header cells via grouped column definitions — TanStack Table nests + * columns under a shared header that spans every child column. + */ +export const MergedCells: Story = { + render: () => { + const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { + id: 'work', + header: 'Work', + columns: [ + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + ], + }, + { + id: 'contact', + header: 'Contact', + columns: [{ id: 'email', header: 'Email', accessorKey: 'email' }], + }, + ]; + return id="tedi-table-merged" data={people} columns={columns} />; + }, +}; + +export const VerticalBorders: Story = { + render: () => id="tedi-table-vb" data={people} columns={personColumns} verticalBorders />, +}; + +/** + * Compact variant: reduced row padding per Figma small-size tokens + * (`table/header/padding-*-sm`, `table/data/padding-*-sm`). + */ +export const Small: Story = { + render: () => id="tedi-table-small" data={people} columns={personColumns} size="small" />, +}; + +export const NoOutsideBorder: Story = { + render: () => id="tedi-table-borderless" data={people} columns={personColumns} borderless />, +}; + +/** + * Editable cells: the cell renderer returns a TEDI `TextField` bound to the + * data store. The owning component holds the data source. + */ +const EditableTemplate = () => { + const [rows, setRows] = useState(people); + + const columns = useMemo[]>( + () => [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { + id: 'role', + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => ( + + setRows((current) => + current.map((person) => (person.id === row.original.id ? { ...person, role: next } : person)) + ) + } + /> + ), + }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + ], + [] + ); + + return id="tedi-table-editable" data={rows} columns={columns} />; +}; + +export const EditableValues: Story = { render: () => }; + +export const Filters: Story = { + render: () => id="tedi-table-filters" data={people} columns={personColumns} enableColumnFilters />, +}; + +/** + * Collapsible rows using the TEDI `Collapse` component in icon-only secondary + * mode. Each row gets a compact arrow trigger; supplementary content reveals + * inline below it when expanded. + */ +export const CollapsibleRows: Story = { + render: () => { + const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + { + id: 'details', + header: '', + size: 40, + cell: ({ row }) => ( + + + Details for {row.original.name} + Monthly salary: €{row.original.salary.toLocaleString('et-EE')} + {row.original.status} + + + ), + }, + ]; + return id="tedi-table-collapse" data={people} columns={columns} />; + }, +}; + +/** + * Alternative collapsible layout using the TEDI `Collapse` component inside a + * regular cell. Use this when the disclosure should stay inline with its row + * rather than push content into a separate full-width row. + */ +export const CollapsibleInlineContent: Story = { + render: () => { + const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { + id: 'details', + header: 'Details', + cell: ({ row }) => ( + View details} size="small"> + + Location: {row.original.location} + Email: {row.original.email} + {row.original.status} + + + ), + }, + ]; + return id="tedi-table-collapse-inline" data={people} columns={columns} />; + }, +}; + +export const SelectableRows: Story = { + render: () => id="tedi-table-selectable" data={people} columns={personColumns} enableRowSelection />, +}; + +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)} + /> + > + ); +}; + +export const ClickableRows: Story = { render: () => }; + +export const Striped: Story = { + render: () => id="tedi-table-striped" data={people} columns={personColumns} striped />, +}; + +export const StickyFirstColumn: Story = { + render: () => ( + + id="tedi-table-sticky" data={people} columns={personColumns} stickyFirstColumn /> + + ), +}; + +/** + * Empty-state rendering: when `data` is empty, Table falls back to the + * `placeholder` prop. Passing an `` node produces the richer + * zero-data layout (icon + heading + description + actions) inside the table + * body. + */ +export const EmptyWithEmptyState: Story = { + render: () => ( + + id="tedi-table-empty-state" + data={[]} + columns={personColumns} + placeholder={ + + Clear filters + + } + > + Try adjusting your filters or search terms to see more results. + + } + /> + ), +}; + +export const WithPagination: Story = { + render: () => { + // Repeat the data set so pagination has something to page through. + const largeData = Array.from({ length: 24 }, (_, index) => { + const base = people[index % people.length]; + return { ...base, id: String(index + 1), name: `${base.name} ${Math.floor(index / people.length) + 1}` }; + }); + return ( + + id="tedi-table-pagination" + data={largeData} + columns={personColumns} + pagination={{ pageSize: 5, pageSizeOptions: [5, 10, 25] }} + /> + ); + }, +}; + +export const WithFooter: Story = { + render: () => { + const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name', footer: `${people.length} people` }, + { id: 'role', header: 'Role', accessorKey: 'role' }, + { id: 'location', header: 'Location', accessorKey: 'location' }, + { + id: 'salary', + header: 'Salary (€)', + accessorKey: 'salary', + cell: (info) => (info.getValue() as number).toLocaleString('et-EE'), + footer: (info) => { + const total = info.table.getRowModel().rows.reduce((sum, row) => sum + row.original.salary, 0); + return `Total €${total.toLocaleString('et-EE')}`; + }, + }, + ]; + return id="tedi-table-footer" data={people} columns={columns} />; + }, +}; + +/** + * Column-visibility toolbar example. Reuses the dropdown from the base + * story file so consumers can see how `` plugs into + * ``. + */ +export const WithColumnsMenu: Story = { + render: () => ( + id="tedi-table-visibility" data={people} columns={personColumns}> + + + + + ), +}; + +/** + * Combines tag rendering with selectable rows to preview a richer production- + * style table. + */ +export const StatusShowcase: Story = { + render: () => { + const columns: ColumnDef[] = [ + { id: 'name', header: 'Name', accessorKey: 'name' }, + { id: 'email', header: 'Email', accessorKey: 'email' }, + { + id: 'status', + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => ( + {row.original.status} + ), + }, + ]; + return id="tedi-table-status" data={people} columns={columns} enableRowSelection />; + }, +}; + +const PersistedTemplate = () => { + const [state, setState] = useState({}); + return ( + + id="tedi-table-persisted" + data={people} + columns={personColumns} + state={state} + onStateChange={setState} + persist={{ key: 'tedi-table-persisted-story' }} + > + + + + + ); +}; + +export const Persisted: Story = { render: () => }; diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx new file mode 100644 index 00000000..56922923 --- /dev/null +++ b/src/tedi/components/content/table/table.tsx @@ -0,0 +1,439 @@ +import { + type ColumnDef, + type ColumnFiltersState, + type ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type OnChangeFn, + type PaginationState, + type Row, + type RowSelectionState, + type SortingState, + useReactTable, + type VisibilityState, +} from '@tanstack/react-table'; +import cn from 'classnames'; +import { Fragment, type KeyboardEvent, useCallback, useMemo } from 'react'; + +import { Icon } from '../../base/icon/icon'; +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 { useTablePersistence } from './use-table-persistence'; + +const SELECT_COLUMN_ID = '__select__'; +const EXPAND_COLUMN_ID = '__expand__'; + +function TableBase(props: TableProps): JSX.Element { + const { + id, + data, + columns, + size = 'medium', + caption, + state, + defaultState, + onStateChange, + persist, + placeholder = 'No data', + className, + children, + striped = false, + verticalBorders = false, + borderless = false, + stickyFirstColumn = false, + onRowClick, + enableRowSelection, + enableColumnFilters = false, + renderSubComponent, + getRowCanExpand, + getSubRows, + pagination: paginationProp, + } = props; + + const paginationOptions = useMemo(() => { + if (!paginationProp) return null; + if (paginationProp === true) return { pageSize: 10, pageSizeOptions: [10, 25, 50] as number[] | false }; + return { + pageSize: paginationProp.pageSize ?? 10, + pageSizeOptions: + paginationProp.pageSizeOptions === undefined ? ([10, 25, 50] as number[]) : paginationProp.pageSizeOptions, + }; + }, [paginationProp]); + const paginationEnabled = paginationOptions !== null; + + const [tableState, setTableState] = useTablePersistence({ + persist, + controlled: state, + defaultState, + onStateChange, + }); + + const handleVisibilityChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.columnVisibility ?? {}; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { columnVisibility: next }; + }); + }, + [setTableState] + ); + + const handleRowSelectionChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.rowSelection ?? {}; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { rowSelection: next }; + }); + }, + [setTableState] + ); + + const handleExpandedChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.expanded ?? {}; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { expanded: next }; + }); + }, + [setTableState] + ); + + const handleColumnFiltersChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.columnFilters ?? []; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { columnFilters: next }; + }); + }, + [setTableState] + ); + + const handleSortingChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous = prev.sorting ?? []; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { sorting: next }; + }); + }, + [setTableState] + ); + + const handlePaginationChange: OnChangeFn = useCallback( + (updater) => { + setTableState((prev) => { + const previous: PaginationState = prev.pagination ?? { + pageIndex: 0, + pageSize: paginationOptions?.pageSize ?? 10, + }; + const next = typeof updater === 'function' ? updater(previous) : updater; + return { pagination: next }; + }); + }, + [setTableState, paginationOptions] + ); + + const hasExpansion = Boolean(renderSubComponent || getSubRows); + const hasSelection = Boolean(enableRowSelection); + + const augmentedColumns = useMemo[]>(() => { + const leading: ColumnDef[] = []; + + if (hasSelection) { + leading.push({ + id: SELECT_COLUMN_ID, + enableSorting: false, + enableHiding: false, + enableColumnFilter: false, + size: 40, + header: ({ table }) => ( + table.toggleAllRowsSelected(checked)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(checked)} + /> + ), + }); + } + + if (hasExpansion) { + leading.push({ + id: EXPAND_COLUMN_ID, + enableSorting: false, + enableHiding: false, + enableColumnFilter: false, + size: 40, + header: '', + cell: ({ row }) => + row.getCanExpand() ? ( + { + event.stopPropagation(); + row.toggleExpanded(); + }} + > + + + ) : null, + }); + } + + return [...leading, ...columns]; + }, [columns, hasSelection, hasExpansion, id]); + + const memoColumns = useMemo(() => augmentedColumns, [augmentedColumns]); + + const table = useReactTable({ + data, + columns: memoColumns, + state: { + columnVisibility: tableState.columnVisibility, + rowSelection: tableState.rowSelection ?? {}, + expanded: tableState.expanded ?? {}, + columnFilters: tableState.columnFilters ?? [], + sorting: tableState.sorting ?? [], + pagination: paginationEnabled + ? tableState.pagination ?? { pageIndex: 0, pageSize: paginationOptions?.pageSize ?? 10 } + : undefined, + }, + enableRowSelection, + enableColumnFilters, + getRowCanExpand: renderSubComponent ? getRowCanExpand ?? (() => true) : getRowCanExpand, + getSubRows, + onColumnVisibilityChange: handleVisibilityChange, + onRowSelectionChange: handleRowSelectionChange, + onExpandedChange: handleExpandedChange, + onColumnFiltersChange: handleColumnFiltersChange, + onSortingChange: handleSortingChange, + onPaginationChange: paginationEnabled ? handlePaginationChange : undefined, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: enableColumnFilters ? getFilteredRowModel() : undefined, + getExpandedRowModel: hasExpansion ? getExpandedRowModel() : undefined, + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined, + }); + + const contextValue: TableContextValue = useMemo(() => ({ table, size, id }), [table, size, id]); + + const rootClassName = cn( + styles['tedi-table'], + styles[`tedi-table--${size}`], + { + [styles['tedi-table--striped']]: striped, + [styles['tedi-table--vertical-borders']]: verticalBorders, + [styles['tedi-table--borderless']]: borderless, + [styles['tedi-table--sticky-first-column']]: stickyFirstColumn, + [styles['tedi-table--clickable-rows']]: Boolean(onRowClick), + [styles['tedi-table--has-pagination']]: paginationEnabled, + }, + className + ); + + const rows = table.getRowModel().rows; + const headerGroups = table.getHeaderGroups(); + const footerGroups = table.getFooterGroups(); + const leafColumns = table.getVisibleLeafColumns(); + const leafColumnCount = leafColumns.length; + const hasFooter = footerGroups.some((group) => + group.headers.some((header) => header.column.columnDef.footer !== undefined) + ); + + const handleRowKeyDown = (row: Row) => (event: KeyboardEvent) => { + if (!onRowClick) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onRowClick(row); + } + }; + + return ( + + + {children} + + + {caption && {caption}} + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + {enableColumnFilters && ( + + {leafColumns.map((column) => { + const headerLabel = + typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; + const filterId = `${id ?? 'tedi-table'}-filter-${column.id}`; + + return ( + + {column.getCanFilter() && ( + column.setFilterValue(next || undefined)} + /> + )} + + ); + })} + + )} + + + {rows.length === 0 ? ( + + + {placeholder} + + + ) : ( + rows.map((row) => { + const clickable = Boolean(onRowClick); + const rowClassName = cn(styles['tedi-table__row'], { + [styles['tedi-table__row--selected']]: row.getIsSelected(), + [styles['tedi-table__row--clickable']]: clickable, + }); + return ( + + onRowClick?.(row) : undefined} + onKeyDown={clickable ? handleRowKeyDown(row) : undefined} + tabIndex={clickable ? 0 : undefined} + role={clickable ? 'button' : undefined} + aria-selected={row.getIsSelected() || undefined} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {renderSubComponent && row.getIsExpanded() && ( + + + {renderSubComponent(row)} + + + )} + + ); + }) + )} + + {hasFooter && ( + + {footerGroups.map((group) => ( + + {group.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.footer, header.getContext())} + + ))} + + ))} + + )} + + + {paginationEnabled && ( + + table.setPageIndex(nextPage - 1)} + totalItems={table.getFilteredRowModel().rows.length} + pageSize={table.getState().pagination.pageSize} + pageSizeOptions={ + paginationOptions?.pageSizeOptions && paginationOptions.pageSizeOptions.length > 0 + ? paginationOptions.pageSizeOptions + : undefined + } + onPageSizeChange={(nextSize) => table.setPageSize(nextSize)} + /> + + )} + + + ); +} + +TableBase.displayName = 'Table'; + +/** + * Optional slot rendered above the ``. 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, +}); + +export default Table; diff --git a/src/tedi/components/content/table/table.types.ts b/src/tedi/components/content/table/table.types.ts new file mode 100644 index 00000000..3317e8a9 --- /dev/null +++ b/src/tedi/components/content/table/table.types.ts @@ -0,0 +1,188 @@ +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 all slices. + */ + 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; + /** + * 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 new file mode 100644 index 00000000..c258c870 --- /dev/null +++ b/src/tedi/components/content/table/use-table-persistence.ts @@ -0,0 +1,92 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; + +import type { TablePersistOptions, TableState } from './table.types'; + +const ALL_KEYS: (keyof TableState)[] = ['columnVisibility', 'columnOrder', 'rowOrder', 'columnSizing']; + +function getStorage(options?: TablePersistOptions): Storage | null { + if (!options) return null; + if (options.storage) return options.storage; + if (typeof window === 'undefined') return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function readInitialState( + options: TablePersistOptions | undefined, + fallback: Partial +): Partial { + const storage = getStorage(options); + if (!storage || !options) return fallback; + try { + const raw = storage.getItem(options.key); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as Partial; + const include = options.include ?? ALL_KEYS; + const filtered: Partial = {}; + for (const key of include) { + if (parsed[key] !== undefined) filtered[key] = parsed[key] as never; + } + return { ...fallback, ...filtered }; + } catch { + return fallback; + } +} + +/** + * Owns the Table's internal state and (optionally) syncs it to a Storage backend. + * The hook always returns a fully-merged TableState so callers never have to + * reason about `undefined` slices. + */ +export type TableStatePatch = Partial | ((prev: TableState) => Partial); + +export function useTablePersistence(options: { + persist?: TablePersistOptions; + controlled?: Partial; + defaultState?: Partial; + onStateChange?: (state: TableState) => void; +}): [TableState, (next: TableStatePatch) => void] { + const { persist, controlled, defaultState, onStateChange } = options; + + const [internal, setInternal] = useState(() => readInitialState(persist, defaultState ?? {})); + + const state = useMemo(() => ({ ...internal, ...controlled }), [internal, controlled]); + + const persistRef = useRef(persist); + persistRef.current = persist; + + const setState = useCallback( + (patchOrFn: TableStatePatch) => { + setInternal((prev) => { + const mergedPrev: TableState = { ...prev, ...controlled }; + const patch = typeof patchOrFn === 'function' ? patchOrFn(mergedPrev) : patchOrFn; + const next: TableState = { ...prev, ...patch }; + const merged: TableState = { ...next, ...controlled }; + + const current = persistRef.current; + const storage = getStorage(current); + if (storage && current) { + try { + const include = current.include ?? ALL_KEYS; + const persisted: Partial = {}; + for (const key of include) { + if (merged[key] !== undefined) persisted[key] = merged[key] as never; + } + storage.setItem(current.key, JSON.stringify(persisted)); + } catch { + // silently ignore quota / serialization errors + } + } + + onStateChange?.(merged); + return next; + }); + }, + [controlled, onStateChange] + ); + + return [state, setState]; +} diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 7f28112c..3b14626b 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -7,6 +7,7 @@ export * from './components/content/section/section'; export * from './components/content/heading-with-icon/heading-with-icon'; export * from './components/content/truncate/truncate'; export * from './components/content/text-group/text-group'; +export * from './components/content/table'; export * from './components/loaders/spinner/spinner'; export * from './components/loaders/skeleton'; export * from './components/tags/tag/tag'; From 0c91021992be8546a8bd46bcbb82f779d0717114 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:38:35 +0300 Subject: [PATCH 05/32] feat(table): improve stories, fix crashes on filters #122 --- .../content/table/table.module.scss | 8 +- .../content/table/table.stories.tsx | 859 ++++++++++++++++-- src/tedi/components/content/table/table.tsx | 115 ++- 3 files changed, 856 insertions(+), 126 deletions(-) diff --git a/src/tedi/components/content/table/table.module.scss b/src/tedi/components/content/table/table.module.scss index 1cac9a6b..b85727a0 100644 --- a/src/tedi/components/content/table/table.module.scss +++ b/src/tedi/components/content/table/table.module.scss @@ -156,7 +156,11 @@ border-right: 1px solid var(--table-border); } - .tedi-table__header-cell:last-child, + // Only strip the divider on the top header row's last cell — in grouped + // headers the deeper rows' `:last-child` (e.g. Kestus) is NOT the + // visually-rightmost column, so it must keep its border-right against the + // rowSpanning cell to its right (e.g. Asukoht). + thead tr:first-child .tedi-table__header-cell:last-child, .tedi-table__row > .tedi-table__cell:last-child { border-right: 0; } @@ -174,13 +178,13 @@ gap: 0; .tedi-table__scroll { + border-bottom: 0; border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } .tedi-table__pagination { - background: var(--general-surface-secondary); border: 1px solid var(--table-border); border-top: 0; border-bottom-right-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 8247b641..65f8f8b5 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -2,12 +2,15 @@ import type { Meta, StoryObj } from '@storybook/react'; import type { ColumnDef } from '@tanstack/react-table'; import { useMemo, useState } from 'react'; +import { Icon } from '../../base/icon/icon'; import { Text } from '../../base/typography/text/text'; import Button from '../../buttons/button/button'; import { Collapse } from '../../buttons/collapse/collapse'; +import { Checkbox } from '../../form/checkbox/checkbox'; import { TextField } from '../../form/textfield/textfield'; import { VerticalSpacing } from '../../layout/vertical-spacing'; import { EmptyState } from '../../notifications/empty-state'; +import { Popover, PopoverContent, PopoverTrigger } from '../../overlays/popover'; import { Tag } from '../../tags/tag/tag'; import { Table } from './table'; import type { TableProps, TableState } from './table.types'; @@ -39,9 +42,8 @@ interface Person { status: 'active' | 'inactive'; } -const people: Person[] = [ +const personSeed: Omit[] = [ { - id: '1', name: 'Anna Tamm', email: 'anna.tamm@example.ee', role: 'Engineer', @@ -50,7 +52,6 @@ const people: Person[] = [ status: 'active', }, { - id: '2', name: 'Jüri Kask', email: 'juri.kask@example.ee', role: 'Designer', @@ -59,7 +60,6 @@ const people: Person[] = [ status: 'active', }, { - id: '3', name: 'Maria Saar', email: 'maria.saar@example.ee', role: 'Product', @@ -68,7 +68,6 @@ const people: Person[] = [ status: 'active', }, { - id: '4', name: 'Mart Mets', email: 'mart.mets@example.ee', role: 'Engineer', @@ -76,17 +75,41 @@ const people: Person[] = [ salary: 4100, status: 'inactive', }, + { name: 'Liis Lepp', email: 'liis.lepp@example.ee', role: 'Ops', location: 'Narva', salary: 3600, status: 'active' }, { - id: '5', - name: 'Liis Lepp', - email: 'liis.lepp@example.ee', - role: 'Ops', - location: 'Narva', - salary: 3600, + name: 'Kadri Kask', + email: 'kadri.kask@example.ee', + role: 'Engineer', + location: 'Viljandi', + salary: 4000, status: 'active', }, + { + name: 'Rain Roos', + email: 'rain.roos@example.ee', + role: 'Designer', + location: 'Rakvere', + salary: 3900, + status: 'inactive', + }, ]; +const people: Person[] = Array.from({ length: 28 }, (_, index) => { + const seed = personSeed[index % personSeed.length]; + const round = Math.floor(index / personSeed.length); + return { + ...seed, + id: String(index + 1), + name: round === 0 ? seed.name : `${seed.name} ${round + 1}`, + }; +}); + +/** + * 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] } as const; + const personColumns: ColumnDef[] = [ { id: 'name', header: 'Name', accessorKey: 'name' }, { id: 'email', header: 'Email', accessorKey: 'email' }, @@ -100,37 +123,122 @@ type Story = StoryObj>; * Baseline render: headers + rows + the default border/padding chrome. */ export const Simple: Story = { - render: () => id="tedi-table-simple" data={people} columns={personColumns} />, + render: () => ( + id="tedi-table-simple" data={people} columns={personColumns} pagination={DEFAULT_PAGINATION} /> + ), }; /** - * Merged header cells via grouped column definitions — TanStack Table nests - * columns under a shared header that spans every child column. + * Merged header cells — matches Figma Example "Merged cells". The "Aeg" (time) + * header group spans two sub-columns (Kellaaeg / Kestus); Kuupäev, Asukoht, + * and the action column are single-column headers that span both header rows. + * Sort indicator on Kuupäev. */ -export const MergedCells: Story = { - render: () => { - const columns: ColumnDef[] = [ - { id: 'name', header: 'Name', accessorKey: 'name' }, +interface Booking { + id: string; + dateRange: string; + hour: string; + duration: string; + location: string; +} + +const bookings: Booking[] = Array.from({ length: 28 }, (_, index) => ({ + id: String(index + 1), + dateRange: '22.03.2029 – 29.03.2029', + hour: '11:14', + duration: '6 min', + location: 'Harjumaa', +})); + +const MergedCellsTemplate = () => { + const columns = useMemo[]>( + () => [ { - id: 'work', - header: 'Work', + 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: 'role', header: 'Role', accessorKey: 'role' }, - { id: 'location', header: 'Location', accessorKey: 'location' }, + { id: 'hour', header: 'Kellaaeg', accessorKey: 'hour' }, + { id: 'duration', header: 'Kestus', accessorKey: 'duration' }, ], }, + { id: 'location', header: 'Asukoht', accessorKey: 'location' }, { - id: 'contact', - header: 'Contact', - columns: [{ id: 'email', header: 'Email', accessorKey: 'email' }], + 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" data={people} columns={columns} />; - }, + ], + [] + ); + + return ( + + id="tedi-table-merged" + verticalBorders + data={bookings} + columns={columns} + pagination={DEFAULT_PAGINATION} + /> + ); }; +export const MergedCells: Story = { render: () => }; + export const VerticalBorders: Story = { - render: () => id="tedi-table-vb" data={people} columns={personColumns} verticalBorders />, + render: () => ( + + id="tedi-table-vb" + data={people} + columns={personColumns} + verticalBorders + pagination={DEFAULT_PAGINATION} + /> + ), }; /** @@ -138,56 +246,613 @@ export const VerticalBorders: Story = { * (`table/header/padding-*-sm`, `table/data/padding-*-sm`). */ export const Small: Story = { - render: () => id="tedi-table-small" data={people} columns={personColumns} size="small" />, + render: () => ( + + id="tedi-table-small" + data={people} + columns={personColumns} + size="small" + pagination={DEFAULT_PAGINATION} + /> + ), }; export const NoOutsideBorder: Story = { - render: () => id="tedi-table-borderless" data={people} columns={personColumns} borderless />, + render: () => ( + + id="tedi-table-borderless" + data={people} + columns={personColumns} + borderless + pagination={DEFAULT_PAGINATION} + /> + ), }; /** - * Editable cells: the cell renderer returns a TEDI `TextField` bound to the - * data store. The owning component holds the data source. + * 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, +}; + const EditableTemplate = () => { - const [rows, setRows] = useState(people); + const [rows, setRows] = useState(editableBookingsSeed); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(null); - const columns = useMemo[]>( + 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: 'name', header: 'Name', accessorKey: 'name' }, { - id: 'role', - header: 'Role', - accessorKey: 'role', - cell: ({ row }) => ( + 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] + ); + + return ( + id="tedi-table-editable" data={rows} columns={columns} pagination={DEFAULT_PAGINATION} /> + ); +}; + +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 + * sort indicator from Example table 7/8. Columns opt in via + * `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} />; +}; + +export const Sortable: Story = { render: () => }; + +/** + * 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: + * - 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'; + +interface PersonRecord { + id: string; + name: string; + jobStart: string; + age: number; + visits: number; + status: CertStatus; +} + +const CERT_STATUSES: CertStatus[] = ['Kehtiv', 'Kehtetu', 'Aegumas', 'Aegunud']; + +const filterablePeopleSeed: Omit[] = [ + { name: 'Mari Maasikas', jobStart: '21.08.2019', age: 25, visits: 6, status: 'Kehtiv' }, + { name: 'Kalle Kapsapea', jobStart: '14.03.2020', age: 35, visits: 13, status: 'Kehtiv' }, + { name: 'Mart Mägi', jobStart: '02.01.2018', age: 43, visits: 26, status: 'Kehtiv' }, + { name: 'Meelis Mets', jobStart: '10.07.2021', age: 64, visits: 26, status: 'Kehtetu' }, + { name: 'Kadri Kask', jobStart: '30.11.2022', age: 32, visits: 4, status: 'Aegumas' }, + { name: 'Liis Linn', jobStart: '21.08.2019', age: 21, visits: 13, status: 'Aegunud' }, +]; + +const filterablePeople: PersonRecord[] = Array.from({ length: 28 }, (_, index) => { + const seed = filterablePeopleSeed[index % filterablePeopleSeed.length]; + const round = Math.floor(index / filterablePeopleSeed.length); + return { + ...seed, + id: String(index + 1), + name: round === 0 ? seed.name : `${seed.name} ${round + 1}`, + }; +}); + +const headerButtonStyle: React.CSSProperties = { + background: 'transparent', + border: 0, + padding: 0, + font: 'inherit', + color: 'inherit', + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + gap: 4, +}; + +const SortLabel = ({ + column, + children, + ariaLabel, +}: { + column: { + getIsSorted: () => false | 'asc' | 'desc'; + getToggleSortingHandler: () => ((e: unknown) => void) | undefined; + }; + children: React.ReactNode; + ariaLabel: string; +}) => { + const sorted = column.getIsSorted(); + const iconName = sorted === 'asc' ? 'arrow_upward' : sorted === 'desc' ? 'arrow_downward' : 'unfold_more'; + return ( + + {children} + + + ); +}; + +const filterTriggerStyle: React.CSSProperties = { + background: 'transparent', + border: 0, + padding: 2, + cursor: 'pointer', +}; + +const TextFilterPopover = ({ + value, + onApply, + label, +}: { + value: string; + onApply: (next: string | undefined) => void; + label: string; +}) => { + const [draft, setDraft] = useState(value); + return ( + + + + + + + + - setRows((current) => - current.map((person) => (person.id === row.original.id ? { ...person, role: next } : person)) - ) - } + value={draft} + onChange={setDraft} /> + + { + setDraft(''); + onApply(undefined); + }} + > + Tühista + + onApply(draft || undefined)}> + Filtreeri + + + + + + ); +}; + +type DateRangeValue = { from?: string; to?: string }; + +const DateRangeFilterPopover = ({ + value, + onApply, + label, +}: { + value: DateRangeValue | undefined; + onApply: (next: DateRangeValue | undefined) => void; + label: string; +}) => { + const [from, setFrom] = useState(value?.from ?? ''); + const [to, setTo] = useState(value?.to ?? ''); + const active = Boolean(value?.from || value?.to); + return ( + + + + + + + + + + + + { + setFrom(''); + setTo(''); + onApply(undefined); + }} + > + Tühista + + onApply(from || to ? { from: from || undefined, to: to || undefined } : undefined)} + > + Filtreeri + + + + + + ); +}; + +const MultiSelectFilterPopover = ({ + value, + onApply, + label, +}: { + value: CertStatus[] | undefined; + onApply: (next: CertStatus[] | undefined) => void; + label: string; +}) => { + const [draft, setDraft] = useState(value ?? []); + const active = (value?.length ?? 0) > 0; + return ( + + + + + + + + + {CERT_STATUSES.map((option) => ( + + setDraft((prev) => (checked ? [...prev, option] : prev.filter((v) => v !== option))) + } + /> + ))} + + { + setDraft([]); + onApply(undefined); + }} + > + Tühista + + onApply(draft.length ? draft : undefined)}> + Filtreeri + + + + + + ); +}; + +const parseDate = (value: string): number | null => { + const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(value); + if (!match) return null; + const [, dd, mm, yyyy] = match; + return Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd)); +}; + +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} ), }, - { id: 'location', header: 'Location', accessorKey: 'location' }, ], [] ); - return id="tedi-table-editable" data={rows} columns={columns} />; + return ( + + id="tedi-table-filters" + data={filterablePeople} + columns={columns} + pagination={DEFAULT_PAGINATION} + /> + ); }; -export const EditableValues: Story = { render: () => }; - -export const Filters: Story = { - render: () => id="tedi-table-filters" data={people} columns={personColumns} enableColumnFilters />, -}; +export const Filters: Story = { render: () => }; /** * Collapsible rows using the TEDI `Collapse` component in icon-only secondary @@ -215,7 +880,7 @@ export const CollapsibleRows: Story = { ), }, ]; - return id="tedi-table-collapse" data={people} columns={columns} />; + return id="tedi-table-collapse" data={people} columns={columns} pagination={DEFAULT_PAGINATION} />; }, }; @@ -243,12 +908,22 @@ export const CollapsibleInlineContent: Story = { ), }, ]; - return id="tedi-table-collapse-inline" data={people} columns={columns} />; + return ( + id="tedi-table-collapse-inline" data={people} columns={columns} pagination={DEFAULT_PAGINATION} /> + ); }, }; export const SelectableRows: Story = { - render: () => id="tedi-table-selectable" data={people} columns={personColumns} enableRowSelection />, + render: () => ( + + id="tedi-table-selectable" + data={people} + columns={personColumns} + enableRowSelection + pagination={DEFAULT_PAGINATION} + /> + ), }; const ClickableTemplate = () => { @@ -261,6 +936,7 @@ const ClickableTemplate = () => { data={people} columns={personColumns} onRowClick={(row) => setClicked(row.original.name)} + pagination={DEFAULT_PAGINATION} /> > ); @@ -269,13 +945,27 @@ const ClickableTemplate = () => { export const ClickableRows: Story = { render: () => }; export const Striped: Story = { - render: () => id="tedi-table-striped" data={people} columns={personColumns} striped />, + render: () => ( + + id="tedi-table-striped" + data={people} + columns={personColumns} + striped + pagination={DEFAULT_PAGINATION} + /> + ), }; export const StickyFirstColumn: Story = { render: () => ( - id="tedi-table-sticky" data={people} columns={personColumns} stickyFirstColumn /> + + id="tedi-table-sticky" + data={people} + columns={personColumns} + stickyFirstColumn + pagination={DEFAULT_PAGINATION} + /> ), }; @@ -293,40 +983,14 @@ export const EmptyWithEmptyState: Story = { data={[]} columns={personColumns} placeholder={ - - Clear filters - - } - > - Try adjusting your filters or search terms to see more results. + + No results found } /> ), }; -export const WithPagination: Story = { - render: () => { - // Repeat the data set so pagination has something to page through. - const largeData = Array.from({ length: 24 }, (_, index) => { - const base = people[index % people.length]; - return { ...base, id: String(index + 1), name: `${base.name} ${Math.floor(index / people.length) + 1}` }; - }); - return ( - - id="tedi-table-pagination" - data={largeData} - columns={personColumns} - pagination={{ pageSize: 5, pageSizeOptions: [5, 10, 25] }} - /> - ); - }, -}; - export const WithFooter: Story = { render: () => { const columns: ColumnDef[] = [ @@ -344,7 +1008,7 @@ export const WithFooter: Story = { }, }, ]; - return id="tedi-table-footer" data={people} columns={columns} />; + return id="tedi-table-footer" data={people} columns={columns} pagination={DEFAULT_PAGINATION} />; }, }; @@ -355,7 +1019,7 @@ export const WithFooter: Story = { */ export const WithColumnsMenu: Story = { render: () => ( - id="tedi-table-visibility" data={people} columns={personColumns}> + id="tedi-table-visibility" data={people} columns={personColumns} pagination={DEFAULT_PAGINATION}> @@ -381,7 +1045,15 @@ export const StatusShowcase: Story = { ), }, ]; - return id="tedi-table-status" data={people} columns={columns} enableRowSelection />; + return ( + + id="tedi-table-status" + data={people} + columns={columns} + enableRowSelection + pagination={DEFAULT_PAGINATION} + /> + ); }, }; @@ -395,6 +1067,7 @@ const PersistedTemplate = () => { state={state} onStateChange={setState} persist={{ key: 'tedi-table-persisted-story' }} + pagination={DEFAULT_PAGINATION} > diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index 56922923..70a7b99f 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -70,6 +70,15 @@ function TableBase(props: TableProps): JSX.Element { }, [paginationProp]); const paginationEnabled = paginationOptions !== null; + // Stable options array reference passed down to ``. Without this + // the array is recomputed from `paginationOptions` every render, which in + // the real browser can cascade through react-select inside the page-size + // picker and produce a feedback loop. + const paginationPageSizeOptions = useMemo(() => { + const opts = paginationOptions?.pageSizeOptions; + return Array.isArray(opts) && opts.length > 0 ? opts : undefined; + }, [paginationOptions]); + const [tableState, setTableState] = useTablePersistence({ persist, controlled: state, @@ -149,6 +158,18 @@ function TableBase(props: TableProps): JSX.Element { const hasExpansion = Boolean(renderSubComponent || getSubRows); const hasSelection = Boolean(enableRowSelection); + // Memoise row-model factories: TanStack compares these by reference, so a + // fresh function every render can (and occasionally does) look like a + // row-model swap and cascade through autoReset handlers in the real browser. + const coreRowModel = useMemo(() => getCoreRowModel(), []); + const filteredRowModel = useMemo(() => getFilteredRowModel(), []); + const sortedRowModel = useMemo(() => getSortedRowModel(), []); + const expandedRowModel = useMemo(() => (hasExpansion ? getExpandedRowModel() : undefined), [hasExpansion]); + const paginationRowModel = useMemo( + () => (paginationEnabled ? getPaginationRowModel() : undefined), + [paginationEnabled] + ); + const augmentedColumns = useMemo[]>(() => { const leading: ColumnDef[] = []; @@ -218,18 +239,27 @@ function TableBase(props: TableProps): JSX.Element { const memoColumns = useMemo(() => augmentedColumns, [augmentedColumns]); + // Stabilise fallback references so unset slices don't churn the state object + // every render (TanStack can treat new-but-equal refs as state changes). + const fallbackRowSelection = useMemo(() => ({}), []); + const fallbackExpanded = useMemo(() => ({}), []); + const fallbackColumnFilters = useMemo(() => [], []); + const fallbackSorting = useMemo(() => [], []); + const fallbackPagination = useMemo( + () => ({ pageIndex: 0, pageSize: paginationOptions?.pageSize ?? 10 }), + [paginationOptions] + ); + const table = useReactTable({ data, columns: memoColumns, state: { columnVisibility: tableState.columnVisibility, - rowSelection: tableState.rowSelection ?? {}, - expanded: tableState.expanded ?? {}, - columnFilters: tableState.columnFilters ?? [], - sorting: tableState.sorting ?? [], - pagination: paginationEnabled - ? tableState.pagination ?? { pageIndex: 0, pageSize: paginationOptions?.pageSize ?? 10 } - : undefined, + rowSelection: tableState.rowSelection ?? fallbackRowSelection, + expanded: tableState.expanded ?? fallbackExpanded, + columnFilters: tableState.columnFilters ?? fallbackColumnFilters, + sorting: tableState.sorting ?? fallbackSorting, + pagination: paginationEnabled ? tableState.pagination ?? fallbackPagination : undefined, }, enableRowSelection, enableColumnFilters, @@ -241,15 +271,24 @@ function TableBase(props: TableProps): JSX.Element { onColumnFiltersChange: handleColumnFiltersChange, onSortingChange: handleSortingChange, onPaginationChange: paginationEnabled ? handlePaginationChange : undefined, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: enableColumnFilters ? getFilteredRowModel() : undefined, - getExpandedRowModel: hasExpansion ? getExpandedRowModel() : undefined, - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined, + getCoreRowModel: coreRowModel, + // Always-on: filtering runs whenever columnFilters has entries, regardless of + // whether the built-in inline filter row is shown. Cheap when no filters set. + getFilteredRowModel: filteredRowModel, + getExpandedRowModel: expandedRowModel, + getSortedRowModel: sortedRowModel, + getPaginationRowModel: paginationRowModel, }); const contextValue: TableContextValue = useMemo(() => ({ table, size, id }), [table, size, id]); + // Stable references for the Pagination controls — react-select reacts badly + // to a new callback identity on every render inside its controlled flow. + const handlePaginationPageChange = useCallback((nextPage: number) => table.setPageIndex(nextPage - 1), [table]); + const handlePaginationPageSizeChange = useCallback((nextSize: number) => table.setPageSize(nextSize), [table]); + + const hasGroupedHeaders = table.getHeaderGroups().length > 1; + const rootClassName = cn( styles['tedi-table'], styles[`tedi-table--${size}`], @@ -260,6 +299,7 @@ function TableBase(props: TableProps): JSX.Element { [styles['tedi-table--sticky-first-column']]: stickyFirstColumn, [styles['tedi-table--clickable-rows']]: Boolean(onRowClick), [styles['tedi-table--has-pagination']]: paginationEnabled, + [styles['tedi-table--grouped-headers']]: hasGroupedHeaders, }, className ); @@ -289,19 +329,36 @@ function TableBase(props: TableProps): JSX.Element { {caption && {caption}} - {headerGroups.map((headerGroup) => ( + {headerGroups.map((headerGroup, rowIndex) => ( - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} + {headerGroup.headers.map((header) => { + const isGroup = header.subHeaders.length > 0; + const hasParentGroup = Boolean(header.column.parent); + // Top-level leaf columns (no parent group) are represented by a + // placeholder at row 0 that rowSpans down. Skip their real leaf + // header in deeper rows to avoid a duplicate — matches Figma's + // "Merged cells" where Kuupäev / Asukoht span both header rows. + // Leaves that DO have a parent group (Kellaaeg / Kestus under + // "Aeg") still render in their designated deep row. + if (!header.isPlaceholder && !isGroup && !hasParentGroup && rowIndex > 0) { + return null; + } + const rowSpanCount = header.isPlaceholder ? headerGroups.length - rowIndex : 1; + return ( + 1 ? rowSpanCount : undefined} + className={cn(styles['tedi-table__header-cell'], { + [styles['tedi-table__header-cell--group']]: isGroup, + })} + scope="col" + style={header.column.getSize() ? { width: header.column.getSize() } : undefined} + > + {flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} ))} {enableColumnFilters && ( @@ -403,15 +460,11 @@ function TableBase(props: TableProps): JSX.Element { table.setPageIndex(nextPage - 1)} + onPageChange={handlePaginationPageChange} totalItems={table.getFilteredRowModel().rows.length} pageSize={table.getState().pagination.pageSize} - pageSizeOptions={ - paginationOptions?.pageSizeOptions && paginationOptions.pageSizeOptions.length > 0 - ? paginationOptions.pageSizeOptions - : undefined - } - onPageSizeChange={(nextSize) => table.setPageSize(nextSize)} + pageSizeOptions={paginationPageSizeOptions} + onPageSizeChange={handlePaginationPageSizeChange} /> )} From 2f601a853bc8223be3404d34253c85d61d8ef259 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:50:47 +0300 Subject: [PATCH 06/32] fix(table): fix type error #122 --- src/tedi/components/content/table/table.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index 70a7b99f..d582ddfa 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 ExpandedState, + type FilterFn, flexRender, getCoreRowModel, getExpandedRowModel, @@ -32,6 +33,20 @@ import { useTablePersistence } from './use-table-persistence'; const SELECT_COLUMN_ID = '__select__'; const EXPAND_COLUMN_ID = '__expand__'; +// Satisfy the community-side `declare module '@tanstack/table-core'` FilterFns +// augmentation so the typed `useReactTable` signature accepts our options. The +// community Table uses richer implementations; the TEDI-Ready Table's stories +// drive filtering via built-ins (`includesString`) or per-column `filterFn` +// overrides, so these stubs are never invoked in practice. +const passthroughFilter: FilterFn = () => true; +const DEFAULT_FILTER_FNS = { + text: passthroughFilter, + select: passthroughFilter, + 'multi-select': passthroughFilter, + 'date-range': passthroughFilter, + 'date-range-period': passthroughFilter, +} as const; + function TableBase(props: TableProps): JSX.Element { const { id, @@ -271,6 +286,9 @@ function TableBase(props: TableProps): JSX.Element { onColumnFiltersChange: handleColumnFiltersChange, onSortingChange: handleSortingChange, onPaginationChange: paginationEnabled ? handlePaginationChange : undefined, + // Satisfies the globally-augmented FilterFns contract from community/Table. + // Per-column `filterFn` overrides take precedence when a story sets one. + filterFns: DEFAULT_FILTER_FNS, getCoreRowModel: coreRowModel, // Always-on: filtering runs whenever columnFilters has entries, regardless of // whether the built-in inline filter row is shown. Cheap when no filters set. From 9aee720c63c9483c7d965b5627492f265b15ef85 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:01:01 +0300 Subject: [PATCH 07/32] feat(pagination): remove small size #20 --- .../navigation/pagination/pagination.module.scss | 10 ++-------- .../navigation/pagination/pagination.spec.tsx | 6 ------ .../navigation/pagination/pagination.stories.tsx | 8 -------- .../components/navigation/pagination/pagination.tsx | 3 +-- .../navigation/pagination/pagination.types.ts | 5 ----- 5 files changed, 3 insertions(+), 29 deletions(-) diff --git a/src/tedi/components/navigation/pagination/pagination.module.scss b/src/tedi/components/navigation/pagination/pagination.module.scss index 29ca5544..db8238c3 100644 --- a/src/tedi/components/navigation/pagination/pagination.module.scss +++ b/src/tedi/components/navigation/pagination/pagination.module.scss @@ -65,8 +65,9 @@ display: inline-flex; align-items: center; justify-content: center; - min-width: var(--pagination-button-size); + width: var(--pagination-button-size); height: var(--pagination-button-size); + font-size: var(--body-small-regular-size); color: var(--general-text-secondary); cursor: pointer; background: var(--button-main-neutral-icon-only-background-default); @@ -118,13 +119,6 @@ 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; diff --git a/src/tedi/components/navigation/pagination/pagination.spec.tsx b/src/tedi/components/navigation/pagination/pagination.spec.tsx index 4956ad1a..cbd12daa 100644 --- a/src/tedi/components/navigation/pagination/pagination.spec.tsx +++ b/src/tedi/components/navigation/pagination/pagination.spec.tsx @@ -241,12 +241,6 @@ describe('Pagination component', () => { 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'); diff --git a/src/tedi/components/navigation/pagination/pagination.stories.tsx b/src/tedi/components/navigation/pagination/pagination.stories.tsx index e1eacfe5..80fc53f1 100644 --- a/src/tedi/components/navigation/pagination/pagination.stories.tsx +++ b/src/tedi/components/navigation/pagination/pagination.stories.tsx @@ -125,14 +125,6 @@ export const ManyPagesEllipsis: Story = { }, }; -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. diff --git a/src/tedi/components/navigation/pagination/pagination.tsx b/src/tedi/components/navigation/pagination/pagination.tsx index ad6b407a..c3902b53 100644 --- a/src/tedi/components/navigation/pagination/pagination.tsx +++ b/src/tedi/components/navigation/pagination/pagination.tsx @@ -22,7 +22,6 @@ export const Pagination = (props: PaginationProps): JSX.Element => { onPageSizeChange, boundaryCount = 1, siblingCount = 1, - size = 'medium', labels, className, } = props; @@ -83,7 +82,7 @@ export const Pagination = (props: PaginationProps): JSX.Element => { [onPageSizeChange] ); - const rootClassName = cn(styles['tedi-pagination'], styles[`tedi-pagination--${size}`], className); + const rootClassName = cn(styles['tedi-pagination'], className); const showResults = totalItems !== undefined; const showPageSizeSelect = Array.isArray(pageSizeOptions) && pageSizeOptions.length > 0; diff --git a/src/tedi/components/navigation/pagination/pagination.types.ts b/src/tedi/components/navigation/pagination/pagination.types.ts index 620ead1f..816a4ac9 100644 --- a/src/tedi/components/navigation/pagination/pagination.types.ts +++ b/src/tedi/components/navigation/pagination/pagination.types.ts @@ -92,11 +92,6 @@ export interface PaginationProps { * @default 1 */ siblingCount?: number; - /** - * Visual size of the buttons. - * @default medium - */ - size?: 'medium' | 'small'; /** * Override any of the default text labels / aria labels. */ From 27b9c4bbf08beeacdfcef59397e892d84054bd42 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:54:50 +0300 Subject: [PATCH 08/32] feat(table): improve stories #122 --- .../table-columns-menu/table-columns-menu.tsx | 24 +-- .../content/table/table.module.scss | 21 +-- .../components/content/table/table.spec.tsx | 5 + .../content/table/table.stories.tsx | 145 ++++++++---------- src/tedi/components/content/table/table.tsx | 34 ++-- 5 files changed, 110 insertions(+), 119 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 9ef60d1c..b34442b4 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 @@ -38,9 +38,9 @@ export const TableColumnsMenu = ({ triggerLabel = 'Columns', className }: TableC }; return ( - + - + {triggerLabel} @@ -53,15 +53,17 @@ export const TableColumnsMenu = ({ triggerLabel = 'Columns', className }: TableC return ( - column.toggleVisibility()} - /> + e.stopPropagation()}> + column.toggleVisibility()} + /> + ); })} diff --git a/src/tedi/components/content/table/table.module.scss b/src/tedi/components/content/table/table.module.scss index b85727a0..960dbf66 100644 --- a/src/tedi/components/content/table/table.module.scss +++ b/src/tedi/components/content/table/table.module.scss @@ -114,25 +114,12 @@ background: var(--table-striped); } -.tedi-table__cell--sub-component { - padding: var(--table-data-padding-y) var(--table-data-padding-x); +.tedi-table__row--sub-row { + background: var(--table-striped); } -.tedi-table__expand-button { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - color: var(--general-text-primary); - cursor: pointer; - background: transparent; - border: 0; - - &:focus-visible { - border-radius: var(--tedi-radius-02-default); - outline: var(--tedi-borders-02) solid var(--general-border-focus); - outline-offset: var(--tedi-borders-01); - } +.tedi-table__cell--sub-component { + padding: var(--table-data-padding-y) var(--table-data-padding-x); } .tedi-table__row--filter { diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx index 8ebfa642..30cc29fa 100644 --- a/src/tedi/components/content/table/table.spec.tsx +++ b/src/tedi/components/content/table/table.spec.tsx @@ -7,6 +7,11 @@ import type { TableState } from './table.types'; import '@testing-library/jest-dom'; +jest.mock('../../../providers/printing-provider/printing-provider', () => ({ + PrintingProvider: ({ children }: { children: React.ReactNode }) => <>{children}>, + usePrint: jest.fn().mockReturnValue(false), +})); + jest.mock('../../../providers/label-provider', () => ({ useLabels: () => ({ getLabel: (key: string, ...args: unknown[]) => { diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 65f8f8b5..780f57e6 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -11,9 +11,10 @@ import { TextField } from '../../form/textfield/textfield'; import { VerticalSpacing } from '../../layout/vertical-spacing'; import { EmptyState } from '../../notifications/empty-state'; import { Popover, PopoverContent, PopoverTrigger } from '../../overlays/popover'; +import { StatusBadge, type StatusBadgeColor } from '../../tags/status-badge/status-badge'; import { Tag } from '../../tags/tag/tag'; import { Table } from './table'; -import type { TableProps, TableState } from './table.types'; +import type { TableProps } from './table.types'; /** * @tanstack/react-table ↗ @@ -108,7 +109,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] } as const; +const DEFAULT_PAGINATION = { pageSize: 10, pageSizeOptions: [10, 25, 50] }; const personColumns: ColumnDef[] = [ { id: 'name', header: 'Name', accessorKey: 'name' }, @@ -412,8 +413,12 @@ const EditableTemplate = () => { if (row.original.id === editingId) { return ( - - + + Tühista + + + Kinnita + ); } @@ -509,6 +514,13 @@ export const Sortable: Story = { render: () => }; */ type CertStatus = 'Kehtiv' | 'Kehtetu' | 'Aegumas' | 'Aegunud'; +const certStatusColor: Record = { + Kehtiv: 'success', + Aegumas: 'warning', + Kehtetu: 'danger', + Aegunud: 'neutral', +}; + interface PersonRecord { id: string; name: string; @@ -835,7 +847,7 @@ const FiltersTemplate = () => { ), cell: ({ row }) => ( - {row.original.status} + {row.original.status} ), }, ], @@ -854,33 +866,63 @@ const FiltersTemplate = () => { export const Filters: Story = { render: () => }; -/** - * Collapsible rows using the TEDI `Collapse` component in icon-only secondary - * mode. Each row gets a compact arrow trigger; supplementary content reveals - * inline below it when expanded. - */ +interface CollapsibleRecord { + id: string; + name: string; + age: number; + visits: number; + status: CertStatus; + subRows?: CollapsibleRecord[]; +} + +const collapsibleSeed: Omit[] = [ + { name: 'Mari Maasikas', age: 25, visits: 6, status: 'Kehtiv' }, + { name: 'Kalle Kapsapea', age: 35, visits: 13, status: 'Kehtiv' }, + { name: 'Mart Mägi', age: 43, visits: 26, status: 'Kehtiv' }, + { name: 'Meelis Mets', age: 64, visits: 26, status: 'Kehtetu' }, + { name: 'Kadri Kask', age: 32, visits: 4, status: 'Aegumas' }, + { name: 'Liis Linn', age: 21, visits: 13, status: 'Aegunud' }, +]; + +const collapsiblePeople: CollapsibleRecord[] = Array.from({ length: 28 }, (_, index) => { + const seed = collapsibleSeed[index % collapsibleSeed.length]; + const round = Math.floor(index / collapsibleSeed.length); + const name = round === 0 ? seed.name : `${seed.name} ${round + 1}`; + const id = String(index + 1); + const subRows: CollapsibleRecord[] | undefined = + index % 2 === 0 + ? [ + { id: `${id}-1`, name, age: seed.age, visits: Math.floor(seed.visits / 2), status: 'Kehtiv' }, + { id: `${id}-2`, name, age: seed.age, visits: seed.visits - Math.floor(seed.visits / 2), status: 'Kehtetu' }, + ] + : undefined; + return { ...seed, id, name, ...(subRows ? { subRows } : {}) }; +}); + export const CollapsibleRows: Story = { render: () => { - const columns: ColumnDef[] = [ - { id: 'name', header: 'Name', accessorKey: 'name' }, - { id: 'role', header: 'Role', accessorKey: 'role' }, - { id: 'location', header: 'Location', accessorKey: 'location' }, + const columns: ColumnDef[] = [ + { id: 'name', header: 'Isik', accessorKey: 'name' }, + { id: 'age', header: 'Vanus', accessorKey: 'age' }, + { id: 'visits', header: 'Külastuste arv', accessorKey: 'visits' }, { - id: 'details', - header: '', - size: 40, + id: 'status', + header: 'Tõendi staatus', + accessorKey: 'status', cell: ({ row }) => ( - - - Details for {row.original.name} - Monthly salary: €{row.original.salary.toLocaleString('et-EE')} - {row.original.status} - - + {row.original.status} ), }, ]; - return id="tedi-table-collapse" data={people} columns={columns} pagination={DEFAULT_PAGINATION} />; + return ( + + id="tedi-table-collapse" + data={collapsiblePeople} + columns={columns} + getSubRows={(row) => row.subRows} + pagination={DEFAULT_PAGINATION} + /> + ); }, }; @@ -976,7 +1018,7 @@ export const StickyFirstColumn: Story = { * zero-data layout (icon + heading + description + actions) inside the table * body. */ -export const EmptyWithEmptyState: Story = { +export const WithEmptyState: Story = { render: () => ( id="tedi-table-empty-state" @@ -1026,54 +1068,3 @@ export const WithColumnsMenu: Story = { ), }; - -/** - * Combines tag rendering with selectable rows to preview a richer production- - * style table. - */ -export const StatusShowcase: Story = { - render: () => { - const columns: ColumnDef[] = [ - { id: 'name', header: 'Name', accessorKey: 'name' }, - { id: 'email', header: 'Email', accessorKey: 'email' }, - { - id: 'status', - header: 'Status', - accessorKey: 'status', - cell: ({ row }) => ( - {row.original.status} - ), - }, - ]; - return ( - - id="tedi-table-status" - data={people} - columns={columns} - enableRowSelection - pagination={DEFAULT_PAGINATION} - /> - ); - }, -}; - -const PersistedTemplate = () => { - const [state, setState] = useState({}); - return ( - - id="tedi-table-persisted" - data={people} - columns={personColumns} - state={state} - onStateChange={setState} - persist={{ key: 'tedi-table-persisted-story' }} - pagination={DEFAULT_PAGINATION} - > - - - - - ); -}; - -export const Persisted: Story = { render: () => }; diff --git a/src/tedi/components/content/table/table.tsx b/src/tedi/components/content/table/table.tsx index d582ddfa..bbadf25f 100644 --- a/src/tedi/components/content/table/table.tsx +++ b/src/tedi/components/content/table/table.tsx @@ -20,7 +20,7 @@ import { import cn from 'classnames'; import { Fragment, type KeyboardEvent, useCallback, useMemo } from 'react'; -import { Icon } from '../../base/icon/icon'; +import { Collapse } from '../../buttons/collapse/collapse'; import { Checkbox } from '../../form/checkbox/checkbox'; import { TextField } from '../../form/textfield/textfield'; import { Pagination } from '../../navigation/pagination'; @@ -233,18 +233,20 @@ function TableBase(props: TableProps): JSX.Element { header: '', cell: ({ row }) => row.getCanExpand() ? ( - { - event.stopPropagation(); - row.toggleExpanded(); - }} - > - - + e.stopPropagation()}> + row.toggleExpanded()} + > + {null} + + ) : null, }); } @@ -298,7 +300,10 @@ function TableBase(props: TableProps): JSX.Element { getPaginationRowModel: paginationRowModel, }); - const contextValue: TableContextValue = useMemo(() => ({ table, size, id }), [table, size, id]); + // Not memoised: TanStack returns the same `table` reference every render + // (stored in a useState ref), so a useMemo here would never recompute and + // context consumers (e.g. TableColumnsMenu) would never see state changes. + const contextValue: TableContextValue = { table, size, id }; // Stable references for the Pagination controls — react-select reacts badly // to a new callback identity on every render inside its controlled flow. @@ -422,6 +427,7 @@ function TableBase(props: TableProps): JSX.Element { 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, }); return ( From 767b32f7c3ce5a169bd3cf842584e52befd63647 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:11:20 +0300 Subject: [PATCH 09/32] feat(table): improve stories #122 --- .../content/table/table.stories.tsx | 235 +++++++++++++++--- 1 file changed, 204 insertions(+), 31 deletions(-) diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index 780f57e6..be25243d 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -3,6 +3,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import { useMemo, 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 { Collapse } from '../../buttons/collapse/collapse'; @@ -111,6 +112,13 @@ const people: Person[] = Array.from({ length: 28 }, (_, index) => { */ 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] }; + const personColumns: ColumnDef[] = [ { id: 'name', header: 'Name', accessorKey: 'name' }, { id: 'email', header: 'Email', accessorKey: 'email' }, @@ -120,21 +128,6 @@ const personColumns: ColumnDef[] = [ type Story = StoryObj>; -/** - * Baseline render: headers + rows + the default border/padding chrome. - */ -export const Simple: Story = { - render: () => ( - id="tedi-table-simple" data={people} columns={personColumns} pagination={DEFAULT_PAGINATION} /> - ), -}; - -/** - * Merged header cells — matches Figma Example "Merged cells". The "Aeg" (time) - * header group spans two sub-columns (Kellaaeg / Kestus); Kuupäev, Asukoht, - * and the action column are single-column headers that span both header rows. - * Sort indicator on Kuupäev. - */ interface Booking { id: string; dateRange: string; @@ -151,6 +144,202 @@ const bookings: Booking[] = Array.from({ length: 28 }, (_, index) => ({ location: 'Harjumaa', })); +interface Doctor { + id: string; + name: string; + specialty: string; + experience: string; + location: string; +} + +const doctorSeed: Omit[] = [ + { name: 'Kalle Kask', specialty: 'Dermatovenereoloog', experience: '4 a', location: 'Tallinn' }, + { name: 'Mari Maasikas', specialty: 'Kopsuarst', experience: '4 a', location: 'Tallinn' }, + { name: 'Vello Vaarikas', specialty: 'Kõrva-nina-kurguarst', experience: '4 a', location: 'Tallinn' }, +]; + +const doctors: Doctor[] = Array.from({ length: 28 }, (_, index) => ({ + ...doctorSeed[index % doctorSeed.length], + id: String(index + 1), +})); + +const 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)', +}; + +/** + * 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} + /> + + ); +}; + +/** + * Both table sizes side-by-side, mirroring the Figma "Sizes" frame: + * a warning Alert, then `default` and `small` variants of the same booking + * columns so the difference in row/header padding is easy to compare. + */ +const SizesTemplate = () => { + const columns = 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 + + ), + }, + ], + [] + ); + + return ( + + Default + + id="tedi-table-sizes-default" + data={bookings} + columns={columns} + pagination={SHOWCASE_PAGINATION_3} + /> + Small + + id="tedi-table-sizes-small" + data={bookings} + columns={columns} + size="small" + pagination={SHOWCASE_PAGINATION_3} + /> + + ); +}; + +export const Sizes: Story = { render: () => }; +export const Simple: Story = { render: () => }; + +/** + * Merged header cells — matches Figma Example "Merged cells". The "Aeg" (time) + * header group spans two sub-columns (Kellaaeg / Kestus); Kuupäev, Asukoht, + * and the action column are single-column headers that span both header rows. + * Sort indicator on Kuupäev. + */ + const MergedCellsTemplate = () => { const columns = useMemo[]>( () => [ @@ -242,22 +431,6 @@ export const VerticalBorders: Story = { ), }; -/** - * Compact variant: reduced row padding per Figma small-size tokens - * (`table/header/padding-*-sm`, `table/data/padding-*-sm`). - */ -export const Small: Story = { - render: () => ( - - id="tedi-table-small" - data={people} - columns={personColumns} - size="small" - pagination={DEFAULT_PAGINATION} - /> - ), -}; - export const NoOutsideBorder: Story = { render: () => ( From 9959d88d0f8eb47253dd2d83ecdca93895005c95 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:55:32 +0300 Subject: [PATCH 10/32] feat(table): update stories #122 --- src/tedi/components/content/table/index.ts | 2 + .../table-header-button.module.scss | 44 ++ .../table-header-button.tsx | 84 +++ .../content/table/table.stories.tsx | 618 ++++++++++++++---- src/tedi/components/content/table/table.tsx | 2 + 5 files changed, 621 insertions(+), 129 deletions(-) create mode 100644 src/tedi/components/content/table/table-header-button/table-header-button.module.scss create mode 100644 src/tedi/components/content/table/table-header-button/table-header-button.tsx diff --git a/src/tedi/components/content/table/index.ts b/src/tedi/components/content/table/index.ts index 6d2c242b..a3631ae5 100644 --- a/src/tedi/components/content/table/index.ts +++ b/src/tedi/components/content/table/index.ts @@ -2,4 +2,6 @@ export { Table } from './table'; export type { TableProps, TableState, TableSize, TablePersistOptions, TableContextValue } from './table.types'; export { TableColumnsMenu } from './table-columns-menu/table-columns-menu'; export type { TableColumnsMenuProps } from './table-columns-menu/table-columns-menu'; +export { TableHeaderButton } from './table-header-button/table-header-button'; +export type { TableHeaderButtonProps } from './table-header-button/table-header-button'; export { useTablePersistence } from './use-table-persistence'; diff --git a/src/tedi/components/content/table/table-header-button/table-header-button.module.scss b/src/tedi/components/content/table/table-header-button/table-header-button.module.scss new file mode 100644 index 00000000..e3a14e4c --- /dev/null +++ b/src/tedi/components/content/table/table-header-button/table-header-button.module.scss @@ -0,0 +1,44 @@ +.tedi-table-header-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + color: var(--general-text-tertiary); + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--button-radius-sm); + transition: background-color 120ms ease, color 120ms ease, outline-color 120ms ease; + + // Default → Hover: light tint, icon stays neutral. + &:hover:not(:disabled) { + background: var(--button-main-neutral-icon-only-background-hover); + } + + // Active (mouse-down): slightly stronger tint, icon picks up brand colour. + &:active:not(:disabled) { + color: var(--button-main-primary-background-default); + background: var(--button-main-neutral-icon-only-background-active); + } + + // Focus ring on keyboard focus, brand-coloured icon. + &:focus-visible { + color: var(--button-main-primary-background-default); + outline: var(--tedi-borders-02) solid var(--general-border-focus); + outline-offset: 0; + } + + // Selected: paint the icon brand colour even at rest (e.g. active filter, + // currently-sorted column). + &--selected { + color: var(--button-main-primary-background-default); + } + + &:disabled { + color: var(--general-text-disabled); + cursor: not-allowed; + background: transparent; + } +} diff --git a/src/tedi/components/content/table/table-header-button/table-header-button.tsx b/src/tedi/components/content/table/table-header-button/table-header-button.tsx new file mode 100644 index 00000000..81fe6fed --- /dev/null +++ b/src/tedi/components/content/table/table-header-button/table-header-button.tsx @@ -0,0 +1,84 @@ +import cn from 'classnames'; +import React, { forwardRef } from 'react'; + +import { Icon, IconSize } from '../../../base/icon/icon'; +import styles from './table-header-button.module.scss'; + +export interface TableHeaderButtonProps { + /** + * Material icon name rendered inside the button (e.g. `unfold_more`, + * `arrow_downward`, `filter_alt`). + */ + icon: string; + /** + * Render the icon's "filled" variant. Pair with `selected` for a fully + * activated look (e.g. an applied filter). + * @default false + */ + filled?: boolean; + /** + * When `true`, the icon paints in the brand colour to indicate an active + * sort or filter at rest. Hover / focus / active states are still applied + * on top. + * @default false + */ + selected?: boolean; + /** + * Disables interaction and applies disabled styling. + * @default false + */ + disabled?: boolean; + /** + * Required accessible name — these are icon-only buttons, so screen readers + * have nothing else to announce. + */ + 'aria-label': string; + /** Size of the icon, in pixels. @default 18 */ + iconSize?: IconSize; + /** Click handler. */ + onClick?: (event: React.MouseEvent) => void; + /** Additional class on the root button element. */ + className?: string; + /** + * Native `type` attribute. Defaults to `'button'` to avoid accidentally + * submitting an enclosing ``. + * @default 'button' + */ + type?: 'button' | 'submit' | 'reset'; +} + +/** + * Compact icon-only button intended for table header cells — sort toggles, + * filter triggers, and similar inline header actions. Matches the Figma + * "Filter and sort buttons" frame: transparent at rest, light-tint on + * hover / active, brand colour when `selected` or focused, focus ring on + * keyboard focus. + * + * `forwardRef` is wired through so the component can be used directly as a + * `Popover.Trigger` child or referenced for imperative focus management. + */ +export const TableHeaderButton = forwardRef( + ( + { icon, filled = false, selected = false, disabled, onClick, className, iconSize = 18, type = 'button', ...rest }, + ref + ) => ( + + + + ) +); + +TableHeaderButton.displayName = 'TableHeaderButton'; + +export default TableHeaderButton; diff --git a/src/tedi/components/content/table/table.stories.tsx b/src/tedi/components/content/table/table.stories.tsx index be25243d..36ae3457 100644 --- a/src/tedi/components/content/table/table.stories.tsx +++ b/src/tedi/components/content/table/table.stories.tsx @@ -10,6 +10,7 @@ import { Collapse } from '../../buttons/collapse/collapse'; import { Checkbox } from '../../form/checkbox/checkbox'; import { TextField } from '../../form/textfield/textfield'; import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { Alert } from '../../notifications/alert/alert'; import { EmptyState } from '../../notifications/empty-state'; import { Popover, PopoverContent, PopoverTrigger } from '../../overlays/popover'; import { StatusBadge, type StatusBadgeColor } from '../../tags/status-badge/status-badge'; @@ -283,26 +284,276 @@ const SimpleTemplate = () => { ); }; +/** Shared columns for the Default and Sizes stories (Figma "Sizes" frame). */ +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: 'actions', + header: '', + cell: () => ( + event.preventDefault()} style={editLinkStyle}> +