Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
56cfdbc
feat(pagination): new TEDI-Ready Pagination component #20
airikej Apr 22, 2026
9c19a0b
feat(pagination): behaviour improvements, react router guide #20
airikej Apr 22, 2026
a69e136
feat(empty-state): new TEDI-Ready component #10
airikej Apr 22, 2026
12a8a15
Merge branch 'feat/20-pagination-new-tedi-ready-component' into feat/…
airikej Apr 23, 2026
4f85b50
feat(table): initial commit #112
airikej Apr 24, 2026
0c91021
feat(table): improve stories, fix crashes on filters #122
airikej Apr 24, 2026
2f601a8
fix(table): fix type error #122
airikej Apr 24, 2026
9aee720
feat(pagination): remove small size #20
airikej Apr 27, 2026
27b9c4b
feat(table): improve stories #122
airikej Apr 28, 2026
b91457e
Merge branch 'feat/20-pagination-new-tedi-ready-component' into feat/…
airikej Apr 28, 2026
767b32f
feat(table): improve stories #122
airikej Apr 28, 2026
9959d88
feat(table): update stories #122
airikej Apr 29, 2026
e8cd033
feat(table): add server side support #122
airikej Apr 29, 2026
f730120
feat(table): replace hard coded labels #122
airikej Apr 29, 2026
e30bbc3
feat(table): add small descriptions to stories for guide #122
airikej May 4, 2026
d9bd55e
Merge branch 'rc' into feat/122-table-new-tedi-ready-component
airikej May 8, 2026
2a5a348
fix(table): fix sb build error #122
airikej May 8, 2026
37897ad
fix(table): fix server side #122
airikej May 8, 2026
90d27d5
fix(table): improve ids, remove dead code, cleanup #122
airikej May 11, 2026
d0a7f89
fix(table): improve a11y #122
airikej May 11, 2026
4f3109d
fix(table): add draggable rows/columns examples #122
airikej May 12, 2026
02f6043
fix(table): improve pagination responsive #122
airikej May 12, 2026
d1201ee
feat(table): code review fixes #122
airikej May 12, 2026
39b3167
feat(table): cr fixes #122
airikej May 12, 2026
d777e42
fix(table): cr fixes #122
airikej May 12, 2026
da52955
feat(table): improve screenreader problems, align component more with…
airikej May 12, 2026
d78fde9
fix(table): cr fixes #122
airikej May 12, 2026
c27be0e
fix(table): cr fixes #122
airikej May 12, 2026
73404da
fix(table): fix columns menu status #122
airikej May 12, 2026
a463629
chore: make Table stories more interactive #122
airikej May 13, 2026
5133149
chore: update Table stories #122
airikej May 13, 2026
46a608f
fix(table): design review fixes #122
airikej May 15, 2026
c006c6d
Merge branch 'rc' into feat/122-table-new-tedi-ready-component
airikej May 15, 2026
44afcec
fix(table): improve stories #122
airikej May 15, 2026
94f19b5
feat(table): update consumer skill for wcag #122
airikej May 18, 2026
69f5b1a
fix(table): replace custom styled links with buttons in stories #122
airikej May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions skills/tedi-react/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,52 @@ const [date, setDate] = useState<Date | undefined>();
/>
```

### Table
TanStack Table v8 wrapper. Sub-components: `Table.HeaderButton`. Sortable / filterable / selectable / pinnable / expandable. Built-in pagination announces page changes via `aria-live` so JAWS reports state changes automatically.

```tsx
<Table<Person> id="people" data={rows} columns={columns} pagination={pagination} />
```

**Props (selection):** `id`, `data`, `columns` (TanStack `ColumnDef<T>[]`), `pagination`, `sorting`, `rowSelection`, `columnPinning`, `expandedRows`, `activeRowId`, `rowHover`, `verticalBorders`, `striped`, `size: 'default' | 'small'`, `caption`, `placeholder`, `placeholderRole`.

#### Accessibility — required for column headers with non-text content

- **`Table.HeaderButton` requires `aria-label`** (TS-enforced). Always include the column name in the label — JAWS otherwise reads only "Sorteeri kasvavalt, button" with no indication of *what* you're sorting:
```tsx
<Table.HeaderButton icon="unfold_more" aria-label={`Sorteeri ${columnLabel} järgi`} />
<Table.HeaderButton icon="filter_alt" aria-label={`Filtreeri ${columnLabel}`} />
```
- **For columns with a function `header` (custom JSX containing sort / filter buttons, info icons, etc.), set `meta.label`**. The Table puts `aria-label={meta.label}` on the `<th>` so screen readers use the clean column name as the column header announcement for every cell. Without it, JAWS reads the full visible header text — including the button labels — for *every* data cell:
```tsx
{
id: 'teenus',
accessorKey: 'teenus',
header: ({ column }) => (
<span>
Teenus
<Table.HeaderButton icon="unfold_more" aria-label="Sorteeri Teenus järgi"
onClick={column.getToggleSortingHandler()} />
</span>
),
meta: { label: 'Teenus' }, // ← required when `header` is a function
}
```
String headers (`header: 'Teenus'`) don't need `meta.label` — the string is used automatically.
- **Filter popovers with validation must use `TextField`'s `invalid` + `helper` props**, not a custom red-bordered div. The Table doesn't ship built-in filter validation today, but if you add min-length / format checks, the only WCAG 3.3.1-compliant path is to wire the error through `TextField`. `invalid` sets `aria-invalid`; `helper` with `type: 'error'` renders the message via `<FeedbackText>` and auto-wires it into the input's `aria-describedby`. A red border + red helper text alone (the Angular bug) fails error identification because screen readers can't see colour:
```tsx
<TextField
id={`filter-${column}`} label={column} value={draft} onChange={setDraft}
invalid={hasError}
helper={hasError ? { type: 'error', text: getLabel('table.filter.validation.min-length', 3) } : undefined}
/>
```
The labels `table.filter.validation.min-length` / `table.filter.validation.no-spaces` already exist in `labels-map.ts` — use them as-is for parity with Angular. **Max length / pattern / any other validation rule** belongs on `TextField` directly — pass `maxLength={40}` and let the native HTML attribute enforce it, plus mirror the rule in `invalid` + `helper` if you want a visible error before submit. Don't invent a Table-level `validation: { minLength, maxLength }` config — the primitives already cover it.
- **For "no results after filter" announcements, set `placeholderRole="status"` on the Table.** The Table wraps the empty-state placeholder in `<div role={placeholderRole}>` (an ARIA live region), so screen readers announce the empty state when a filter empties the rows. `'status'` is polite (recommended); `'alert'` is assertive (interrupts the current SR utterance). Leave the prop undefined for tables that are empty on first mount and never change — otherwise the live region announces on every render.
```tsx
<Table data={rows} columns={columns} placeholderRole="status" />
```

## Form

### TextField
Expand Down
5 changes: 5 additions & 0 deletions src/community/components/table/table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ const meta: Meta<typeof Table> = {
control: false,
},
},
parameters: {
status: {
type: ['deprecated', 'ExistsInTediReady'],
},
},
};

export default meta;
Expand Down
30 changes: 30 additions & 0 deletions src/tedi/components/buttons/collapse/collapse.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,34 @@ describe('Collapse component with breakpoint support', () => {

expect(screen.getByRole('button', { name: 'Toggle section' })).toBeInTheDocument();
});

it('toggles open state when Enter or Space is pressed on the trigger', () => {
const onToggle = jest.fn();
const { getByRole } = getComponent({ id: 'collapse-keyboard', onToggle });
const button = getByRole('button', { name: /näita rohkem/i });

fireEvent.keyDown(button, { key: 'Enter' });
expect(onToggle).toHaveBeenCalledWith(true);

fireEvent.keyDown(button, { key: ' ' });
expect(onToggle).toHaveBeenCalledWith(false);
expect(onToggle).toHaveBeenCalledTimes(2);
});

it('ignores repeated key events (e.repeat is true)', () => {
const onToggle = jest.fn();
const { getByRole } = getComponent({ id: 'collapse-repeat', onToggle });
const button = getByRole('button', { name: /näita rohkem/i });
fireEvent.keyDown(button, { key: 'Enter', repeat: true });
expect(onToggle).not.toHaveBeenCalled();
});

it('ignores keys other than Enter / Space', () => {
const onToggle = jest.fn();
const { getByRole } = getComponent({ id: 'collapse-other-key', onToggle });
const button = getByRole('button', { name: /näita rohkem/i });
fireEvent.keyDown(button, { key: 'a' });
fireEvent.keyDown(button, { key: 'Tab' });
expect(onToggle).not.toHaveBeenCalled();
});
});
38 changes: 30 additions & 8 deletions src/tedi/components/buttons/collapse/collapse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ export interface CollapseProps extends BreakpointSupport<CollapseBreakpointProps
* If provided, overrides the default open/close text for the accessible name.
*/
toggleLabel?: string;
/**
* Use Collapse purely as a toggle trigger for content rendered elsewhere.
*
* When set, the toggle button's `aria-controls` points at the supplied id
* instead of Collapse's internal content panel, and the internal panel is
* **not rendered**. Useful when the disclosed region must live outside
* Collapse's DOM subtree (e.g. a table row whose details live in a
* sibling `<tr>`). The consumer is responsible for rendering the target
* element with the matching `id` and an appropriate `role` (typically
* `region`).
*
* When omitted (default), Collapse renders its own `children` inside a
* built-in `role="region"` panel.
*/
controlsId?: string;
}

export const Collapse = (props: CollapseProps): JSX.Element => {
Expand All @@ -116,9 +131,12 @@ export const Collapse = (props: CollapseProps): JSX.Element => {
underline = true,
toggleLabel,
iconOnly = false,
controlsId,
...rest
} = getCurrentBreakpointProps<CollapseProps>(props);

const isExternallyControlled = controlsId !== undefined;

const triggerId = `${id}__trigger`;
const contentId = `${id}__content`;
const animateId = `${id}__animate`;
Expand Down Expand Up @@ -175,7 +193,7 @@ export const Collapse = (props: CollapseProps): JSX.Element => {
className={styles['tedi-collapse__title']}
aria-label={accessibleName}
aria-expanded={isOpen}
aria-controls={contentId}
aria-controls={isExternallyControlled ? controlsId : contentId}
onKeyDown={handleKeyDown}
onClick={handleClick}
>
Expand Down Expand Up @@ -219,13 +237,17 @@ export const Collapse = (props: CollapseProps): JSX.Element => {
</Row>
</button>

{isPrint ? (
renderContent
) : (
<AnimateHeight id={animateId} duration={300} height={isOpen ? 'auto' : 0} data-testid="collapse-inner">
{renderContent}
</AnimateHeight>
)}
{/* In externally-controlled mode the disclosed region lives outside
Collapse's DOM — render nothing here so we don't emit an orphan
`role="region"` panel pointing back at the same trigger. */}
{!isExternallyControlled &&
(isPrint ? (
renderContent
) : (
<AnimateHeight id={animateId} duration={300} height={isOpen ? 'auto' : 0} data-testid="collapse-inner">
{renderContent}
</AnimateHeight>
))}
</div>
);
};
Expand Down
6 changes: 6 additions & 0 deletions src/tedi/components/content/table/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './table';
export * from './table-context';
export * from './use-table-persistence';
export * from './table-columns-menu/table-columns-menu';
export * from './table-header-button/table-header-button';
export * from './table-toolbar/table-toolbar';
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useLabels } from '../../../../providers/label-provider';
import { Button } from '../../../buttons/button/button';
import { Checkbox } from '../../../form/checkbox/checkbox';
import { Dropdown } from '../../../overlays/dropdown/dropdown';
import { DropdownContent } from '../../../overlays/dropdown/dropdown-content/dropdown-content';
import { DropdownItem } from '../../../overlays/dropdown/dropdown-item/dropdown-item';
import { DropdownTrigger } from '../../../overlays/dropdown/dropdown-trigger/dropdown-trigger';
import { useTableContext } from '../table-context';

export interface TableColumnsMenuProps {
/**
* Trigger label. Falls back to the localised `table.columns` label from
* `LabelProvider` when not provided.
*/
triggerLabel?: React.ReactNode;
/**
* Additional class name on the dropdown trigger button.
*/
className?: string;
}

export const TableColumnsMenu = ({ triggerLabel, className }: TableColumnsMenuProps) => {
const { table, id } = useTableContext();
const { getLabel } = useLabels();
const resolvedTriggerLabel = triggerLabel ?? getLabel('table.columns');

const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide());
const visibleCount = hideableColumns.filter((column) => column.getIsVisible()).length;

const resolveHeader = (column: (typeof hideableColumns)[number]) => {
const header = column.columnDef.header;
return typeof header === 'string' ? header : column.id;
};

return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button type="button" visualType="neutral" iconLeft="tune" className={className}>
{resolvedTriggerLabel}
</Button>
</DropdownTrigger>
<DropdownContent>
{hideableColumns.map((column) => {
const isVisible = column.getIsVisible();
const isLastVisible = isVisible && visibleCount === 1;
const checkboxId = `${id}-columns-menu-${column.id}`;
const headerLabel = resolveHeader(column);

return (
<DropdownItem key={column.id} asChild closeOnSelect={false}>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
id={checkboxId}
name={checkboxId}
label={headerLabel}
value={column.id}
checked={isVisible}
disabled={isLastVisible}
onChange={() => column.toggleVisibility()}
/>
</div>
</DropdownItem>
);
})}
</DropdownContent>
</Dropdown>
);
};

TableColumnsMenu.displayName = 'Table.ColumnsMenu';
11 changes: 11 additions & 0 deletions src/tedi/components/content/table/table-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext, useContext } from 'react';

import type { TableContextValue } from './table';

export const TableContext = createContext<TableContextValue | null>(null);

export function useTableContext<TData = unknown>(): TableContextValue<TData> {
const ctx = useContext(TableContext);
if (!ctx) throw new Error('TableContext missing — wrap the component in <Table>.');
return ctx as TableContextValue<TData>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.tedi-table-header-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--general-text-tertiary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--button-radius-sm);
transition: background-color 120ms ease, color 120ms ease, outline-color 120ms ease;

&:hover:not(:disabled) {
background: var(--button-main-neutral-icon-only-background-hover);
}

&:active:not(:disabled) {
color: var(--button-main-primary-background-default);
background: var(--button-main-neutral-icon-only-background-active);
}

&:focus-visible {
color: var(--button-main-primary-background-default);
outline: var(--tedi-borders-02) solid var(--general-border-focus);
outline-offset: 0;
}

&--selected {
color: var(--button-main-primary-background-default);
}

&:disabled {
color: var(--general-text-disabled);
cursor: not-allowed;
background: transparent;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';

import { TableHeaderButton } from './table-header-button';

import '@testing-library/jest-dom';

describe('TableHeaderButton', () => {
it('renders an icon-only button with the required aria-label', () => {
render(<TableHeaderButton icon="unfold_more" aria-label="Sort by name" />);
expect(screen.getByRole('button', { name: 'Sort by name' })).toBeInTheDocument();
});

it('applies the selected modifier class when selected is true', () => {
const { container } = render(<TableHeaderButton icon="arrow_upward" selected aria-label="Sorted" />);
expect(container.querySelector('button')?.className).toMatch(/--selected/);
});

it('fires onClick when the button is clicked', () => {
const handleClick = jest.fn();
render(<TableHeaderButton icon="filter_alt" aria-label="Filter" onClick={handleClick} />);
fireEvent.click(screen.getByRole('button', { name: 'Filter' }));
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('forwards arbitrary button attributes (aria-expanded, aria-controls)', () => {
render(
<TableHeaderButton icon="filter_alt" aria-label="Open filter" aria-expanded="true" aria-controls="filter-panel" />
);
const button = screen.getByRole('button', { name: 'Open filter' });
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(button).toHaveAttribute('aria-controls', 'filter-panel');
});

it('renders the disabled state', () => {
render(<TableHeaderButton icon="unfold_more" aria-label="Disabled" disabled />);
expect(screen.getByRole('button', { name: 'Disabled' })).toBeDisabled();
});

it('forwards a ref to the underlying button', () => {
const ref = createRef<HTMLButtonElement>();
render(<TableHeaderButton ref={ref} icon="unfold_more" aria-label="Ref target" />);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});

it('defaults to type="button" but honours an override', () => {
const { rerender } = render(<TableHeaderButton icon="unfold_more" aria-label="Default" />);
expect(screen.getByRole('button', { name: 'Default' })).toHaveAttribute('type', 'button');

rerender(<TableHeaderButton icon="unfold_more" aria-label="Submit" type="submit" />);
expect(screen.getByRole('button', { name: 'Submit' })).toHaveAttribute('type', 'submit');
});

it('merges custom className with the component class', () => {
const { container } = render(<TableHeaderButton icon="unfold_more" aria-label="Custom" className="custom-class" />);
const button = container.querySelector('button');
expect(button).toHaveClass('custom-class');
expect(button?.className).toMatch(/tedi-table-header-button/);
});
});
Loading
Loading