@aleph-alpha/ui-library 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,16 +2,29 @@
2
2
 
3
3
  ---
4
4
 
5
- ## Figma Access & Library Links
5
+ ## Figma design system
6
6
 
7
- Figma components and design files:
7
+ The design system is published as a Figma **library** for the Aleph Alpha organisation. Add it to each design file and build with **library instances** (**components** from **Assets**, and **variables** and **styles** from property pickers) so your work stays connected when the team publishes updates.
8
8
 
9
- - **[AA Design System Components (Figma)](https://www.figma.com/design/SrZQ19QVN1sQkuAMnMC746/AA26--WIP-?node-id=580-9181&t=2HPn1uKWpFKHnrMR-1)** — component library and design system.
9
+ ### Adding Components to your Figma file
10
10
 
11
- ## Using Design Tokens in Figma
11
+ 1. In the left sidebar, click the **Assets** tab next to **Files**. That switches the sidebar from the layer list to Assets.
12
+ 2. Click **Browse team libraries**.
13
+ 3. In the libraries dialog, select **Your organisation** in the left sidebar.
14
+ 4. Select **AA26 (WIP)**, then enable it with **Add to file**.
12
15
 
13
- Coming soon.
16
+ You can then drag **components** from the **Assets** tab into your file.
17
+
18
+ See Figma documentation for more details on how to manage libraries in [Add or remove a library from a design file](https://help.figma.com/hc/en-us/articles/1500008731201-Add-or-remove-a-library-from-a-design-file).
19
+
20
+ !!! note "Beta"
21
+
22
+ The Figma library is in **beta**. Components will change in look and feel (and sometimes structure) as we iterate with the Design team. Expect updates over time.
23
+
24
+ ### Design tokens
25
+
26
+ With the library enabled on the file, open the **Style and variables** picker in the **right sidebar** on properties such as fill, stroke, spacing, and typography to apply **variables** and **styles** from the design system.
14
27
 
15
28
  ## Link to Contribution Model
16
29
 
17
- Coming soon.
30
+ Coming soon.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aleph-alpha/ui-library",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/system/lib.js",
@@ -71,9 +71,9 @@
71
71
  "vitest": "^3.0.0",
72
72
  "vue-tsc": "^2.2.12",
73
73
  "wait-on": "9.0.3",
74
+ "@aleph-alpha/eslint-config-frontend": "0.5.0",
74
75
  "@aleph-alpha/prettier-config-frontend": "0.4.0",
75
- "@aleph-alpha/tsconfig-frontend": "0.5.0",
76
- "@aleph-alpha/eslint-config-frontend": "0.5.0"
76
+ "@aleph-alpha/tsconfig-frontend": "0.5.0"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "@unocss/preset-wind4": ">=66.0.0",
@@ -229,7 +229,7 @@ const data: Payment[] = [
229
229
  { id: '3', amount: 837, status: 'processing', email: 'monserrat44@gmail.com' },
230
230
  ];
231
231
 
232
- // Use UiDataTableColumnHeader for sortable columns with dropdown menu
232
+ // Use UiDataTableColumnHeader for sortable columns (click to cycle: unsorted → asc → desc)
233
233
  const columns: ColumnDef<Payment>[] = [
234
234
  {
235
235
  accessorKey: 'status',
@@ -1575,3 +1575,100 @@ export const WithCellFormatting: Story = {
1575
1575
  template: '<UiDataTable v-bind="args" />',
1576
1576
  }),
1577
1577
  };
1578
+
1579
+ const withRowClickTemplateSource = `<script setup lang="ts">
1580
+ import { ref, h } from 'vue';
1581
+ import { UiDataTable, UiDataTableColumnHeader } from '@aleph-alpha/ui-library';
1582
+ import type { ColumnDef } from '@tanstack/vue-table';
1583
+
1584
+ interface Payment {
1585
+ id: string;
1586
+ amount: number;
1587
+ status: string;
1588
+ email: string;
1589
+ }
1590
+
1591
+ const data: Payment[] = [
1592
+ { id: '1', amount: 316, status: 'success', email: 'ken99@yahoo.com' },
1593
+ { id: '2', amount: 242, status: 'success', email: 'abe45@gmail.com' },
1594
+ { id: '3', amount: 837, status: 'processing', email: 'monserrat44@gmail.com' },
1595
+ ];
1596
+
1597
+ const columns: ColumnDef<Payment>[] = [
1598
+ {
1599
+ accessorKey: 'status',
1600
+ header: ({ column }) => h(UiDataTableColumnHeader, { column, title: 'Status' }),
1601
+ size: 120,
1602
+ },
1603
+ {
1604
+ accessorKey: 'email',
1605
+ header: ({ column }) => h(UiDataTableColumnHeader, { column, title: 'Email' }),
1606
+ size: 250,
1607
+ },
1608
+ {
1609
+ accessorKey: 'amount',
1610
+ header: ({ column }) => h(UiDataTableColumnHeader, {
1611
+ column,
1612
+ title: 'Amount',
1613
+ class: 'justify-end'
1614
+ }),
1615
+ cell: ({ row }) => {
1616
+ const amount = Number.parseFloat(row.getValue('amount'));
1617
+ return new Intl.NumberFormat('de-DE', {
1618
+ style: 'currency',
1619
+ currency: 'EUR',
1620
+ }).format(amount);
1621
+ },
1622
+ size: 120,
1623
+ },
1624
+ ];
1625
+
1626
+ const clickedRow = ref<Payment | null>(null);
1627
+ </script>
1628
+
1629
+ <template>
1630
+ <UiDataTable
1631
+ :data="data"
1632
+ :columns="columns"
1633
+ :on-row-click="(row) => (clickedRow = row)"
1634
+ />
1635
+ <div v-if="clickedRow" class="mt-4 rounded border p-3 text-sm">
1636
+ Clicked: {{ clickedRow.email }} - {{ clickedRow.status }}
1637
+ </div>
1638
+ </template>`;
1639
+
1640
+ export const WithRowClick: Story = {
1641
+ args: {
1642
+ data: payments,
1643
+ columns: sortableColumns,
1644
+ },
1645
+ parameters: {
1646
+ docs: {
1647
+ source: {
1648
+ code: withRowClickTemplateSource,
1649
+ },
1650
+ },
1651
+ },
1652
+ render: (args) => ({
1653
+ components: { UiDataTable },
1654
+ setup() {
1655
+ const clickedRow = ref<Payment | null>(null);
1656
+ return {
1657
+ args,
1658
+ clickedRow,
1659
+ handleRowClick: (row: Payment) => {
1660
+ clickedRow.value = row;
1661
+ },
1662
+ };
1663
+ },
1664
+ template: `
1665
+ <div>
1666
+ <UiDataTable v-bind="args" :on-row-click="handleRowClick" />
1667
+ <div v-if="clickedRow" class="mt-4 rounded border p-3 text-sm">
1668
+ Clicked: <strong>{{ clickedRow.email }}</strong> - {{ clickedRow.status }} - {{ new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(clickedRow.amount) }}
1669
+ </div>
1670
+ <div v-else class="text-muted-foreground mt-4 text-sm">Click a row to see its data</div>
1671
+ </div>
1672
+ `,
1673
+ }),
1674
+ };
@@ -22,6 +22,9 @@
22
22
  import type { UiDataTableProps } from './types';
23
23
  import { DATA_TABLE_LABELS, DEFAULT_COLUMN_SIZE } from './constants';
24
24
 
25
+ const INTERACTIVE_SELECTOR =
26
+ 'button, a, input, textarea, select, [role="button"], [role="link"], [role="menuitem"], [role="checkbox"], [role="switch"], [contenteditable="true"]';
27
+
25
28
  defineOptions({
26
29
  name: 'UiDataTable',
27
30
  });
@@ -68,6 +71,14 @@
68
71
  },
69
72
  });
70
73
 
74
+ function handleRowClick(event: MouseEvent | KeyboardEvent, original: TData) {
75
+ if (!props.onRowClick) return;
76
+ const target = event.target as HTMLElement;
77
+ if (target.closest?.(INTERACTIVE_SELECTOR)) return;
78
+ event.preventDefault();
79
+ props.onRowClick(original);
80
+ }
81
+
71
82
  defineExpose({
72
83
  table,
73
84
  });
@@ -102,6 +113,7 @@
102
113
  v-for="row in table.getRowModel().rows"
103
114
  :key="row.id"
104
115
  :data-state="row.getIsSelected() ? 'selected' : undefined"
116
+ :class="props.onRowClick ? 'cursor-pointer' : undefined"
105
117
  >
106
118
  <UiTableCell
107
119
  v-for="cell in row.getVisibleCells()"
@@ -111,6 +123,9 @@
111
123
  ? { width: `${cell.column.getSize()}px` }
112
124
  : undefined
113
125
  "
126
+ @click="handleRowClick($event, row.original)"
127
+ @keydown.enter="handleRowClick($event, row.original)"
128
+ @keydown.space="handleRowClick($event, row.original)"
114
129
  >
115
130
  <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
116
131
  </UiTableCell>
@@ -1,12 +1,6 @@
1
1
  <script setup lang="ts" generic="TData, TValue">
2
2
  import { UiButton } from '@/components/UiButton';
3
3
  import { UiIcon } from '@/components/UiIcon';
4
- import {
5
- UiDropdownMenu,
6
- UiDropdownMenuContent,
7
- UiDropdownMenuItem,
8
- UiDropdownMenuTrigger,
9
- } from '@/components/UiDropdownMenu';
10
4
  import { cn } from '@/lib/utils';
11
5
  import { computed } from 'vue';
12
6
  import { DATA_TABLE_LABELS } from './constants';
@@ -24,8 +18,6 @@
24
18
  const props = defineProps<UiDataTableColumnHeaderProps<TData, TValue>>();
25
19
 
26
20
  const labels = computed(() => ({
27
- sortAscending: props.labels?.sortAscending ?? DATA_TABLE_LABELS.sortAscending,
28
- sortDescending: props.labels?.sortDescending ?? DATA_TABLE_LABELS.sortDescending,
29
21
  sortedAscending: props.labels?.sortedAscending ?? DATA_TABLE_LABELS.sortedAscending,
30
22
  sortedDescending: props.labels?.sortedDescending ?? DATA_TABLE_LABELS.sortedDescending,
31
23
  clickToSort: props.labels?.clickToSort ?? DATA_TABLE_LABELS.clickToSort,
@@ -37,57 +29,39 @@
37
29
  if (sortState === 'desc') return `${props.title}, ${labels.value.sortedDescending}`;
38
30
  return `${props.title}, ${labels.value.clickToSort}`;
39
31
  });
32
+
33
+ function handleSort() {
34
+ const current = props.column.getIsSorted();
35
+ if (current === false) {
36
+ props.column.toggleSorting(false); // -> asc
37
+ } else if (current === 'asc') {
38
+ props.column.toggleSorting(true); // -> desc
39
+ } else {
40
+ props.column.clearSorting(); // -> unsorted
41
+ }
42
+ }
40
43
  </script>
41
44
 
42
45
  <template>
43
46
  <div v-if="column.getCanSort()" :class="cn('flex items-center space-x-2', $attrs.class ?? '')">
44
- <UiDropdownMenu>
45
- <UiDropdownMenuTrigger as-child>
46
- <UiButton
47
- variant="ghost"
48
- size="sm"
49
- class="h-8 data-[state=open]:bg-accent"
50
- :aria-label="sortLabel"
51
- >
52
- <span>{{ title }}</span>
53
- <UiIcon
54
- v-if="column.getIsSorted() === 'desc'"
55
- name="arrow-down"
56
- :size="16"
57
- class="ml-2"
58
- aria-hidden="true"
59
- />
60
- <UiIcon
61
- v-else-if="column.getIsSorted() === 'asc'"
62
- name="arrow-up"
63
- :size="16"
64
- class="ml-2"
65
- aria-hidden="true"
66
- />
67
- <UiIcon v-else name="chevrons-up-down" :size="16" class="ml-2" aria-hidden="true" />
68
- </UiButton>
69
- </UiDropdownMenuTrigger>
70
- <UiDropdownMenuContent align="start">
71
- <UiDropdownMenuItem @click="column.toggleSorting(false)">
72
- <UiIcon
73
- name="arrow-up"
74
- :size="14"
75
- class="mr-2 text-muted-foreground/70"
76
- aria-hidden="true"
77
- />
78
- {{ labels.sortAscending }}
79
- </UiDropdownMenuItem>
80
- <UiDropdownMenuItem @click="column.toggleSorting(true)">
81
- <UiIcon
82
- name="arrow-down"
83
- :size="14"
84
- class="mr-2 text-muted-foreground/70"
85
- aria-hidden="true"
86
- />
87
- {{ labels.sortDescending }}
88
- </UiDropdownMenuItem>
89
- </UiDropdownMenuContent>
90
- </UiDropdownMenu>
47
+ <UiButton variant="ghost" size="sm" class="h-8" :aria-label="sortLabel" @click="handleSort">
48
+ <span>{{ title }}</span>
49
+ <UiIcon
50
+ v-if="column.getIsSorted() === 'desc'"
51
+ name="arrow-down"
52
+ :size="16"
53
+ class="ml-2"
54
+ aria-hidden="true"
55
+ />
56
+ <UiIcon
57
+ v-else-if="column.getIsSorted() === 'asc'"
58
+ name="arrow-up"
59
+ :size="16"
60
+ class="ml-2"
61
+ aria-hidden="true"
62
+ />
63
+ <UiIcon v-else name="chevrons-up-down" :size="16" class="ml-2" aria-hidden="true" />
64
+ </UiButton>
91
65
  </div>
92
66
 
93
67
  <div v-else :class="$attrs.class">
@@ -1,6 +1,6 @@
1
- import { render, within } from '@testing-library/vue';
1
+ import { fireEvent, render, within } from '@testing-library/vue';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { describe, expect, test } from 'vitest';
3
+ import { describe, expect, test, vi } from 'vitest';
4
4
  import { h, ref, nextTick } from 'vue';
5
5
  import type { ColumnDef } from '@tanstack/vue-table';
6
6
  import UiDataTable from '../UiDataTable.vue';
@@ -137,35 +137,117 @@ describe('UiDataTable', () => {
137
137
  });
138
138
 
139
139
  describe('Sorting', () => {
140
- test('sorts data when sortable column header is clicked', async () => {
140
+ test('sorts ascending on first click', async () => {
141
141
  const user = userEvent.setup();
142
142
  const { getByRole, getAllByRole } = render(UiDataTable, {
143
143
  props: { columns: sortableColumns, data: testData },
144
144
  });
145
145
 
146
- // Click the Name column header button to open dropdown
146
+ // Click the Name column header button to sort ascending
147
147
  await user.click(getByRole('button', { name: /Name/i }));
148
- // Click ascending option
149
- await user.click(getByRole('menuitem', { name: /Asc/i }));
150
148
 
151
149
  const cells = getAllByRole('cell');
152
150
  // First data row should be Alice (alphabetically first)
153
151
  expect(cells[0]).toHaveTextContent('Alice');
154
152
  });
155
153
 
156
- test('sorts descending when desc option is selected', async () => {
154
+ test('sorts descending on second click', async () => {
157
155
  const user = userEvent.setup();
158
156
  const { getByRole, getAllByRole } = render(UiDataTable, {
159
157
  props: { columns: sortableColumns, data: testData },
160
158
  });
161
159
 
160
+ // First click: ascending
161
+ await user.click(getByRole('button', { name: /Name/i }));
162
+ // Second click: descending
162
163
  await user.click(getByRole('button', { name: /Name/i }));
163
- await user.click(getByRole('menuitem', { name: /Desc/i }));
164
164
 
165
165
  const cells = getAllByRole('cell');
166
166
  // First data row should be Charlie (alphabetically last)
167
167
  expect(cells[0]).toHaveTextContent('Charlie');
168
168
  });
169
+
170
+ test('clears sorting on third click', async () => {
171
+ const user = userEvent.setup();
172
+ const { getByRole, getAllByRole } = render(UiDataTable, {
173
+ props: { columns: sortableColumns, data: testData },
174
+ });
175
+
176
+ const initialCells = getAllByRole('cell');
177
+ const initialFirst = initialCells[0].textContent;
178
+
179
+ const headerButton = getByRole('button', { name: /Name/i });
180
+ // First click: ascending
181
+ await user.click(headerButton);
182
+ // Second click: descending
183
+ await user.click(headerButton);
184
+ // Third click: clear sorting (back to original order)
185
+ await user.click(headerButton);
186
+
187
+ const finalCells = getAllByRole('cell');
188
+ expect(finalCells[0]).toHaveTextContent(initialFirst ?? '');
189
+ });
190
+ });
191
+
192
+ describe('Row Click', () => {
193
+ test('calls onRowClick with row data when a row is clicked', async () => {
194
+ const onRowClick = vi.fn();
195
+ const { getByText } = render(UiDataTable, {
196
+ props: { columns: basicColumns, data: testData, onRowClick },
197
+ });
198
+
199
+ // Click cell content - handler is on <td>, event bubbles from text
200
+ await fireEvent.click(getByText('Alice'));
201
+
202
+ expect(onRowClick).toHaveBeenCalledWith(testData[0]);
203
+ });
204
+
205
+ test('adds cursor-pointer class when onRowClick is provided', () => {
206
+ const { container } = render(UiDataTable, {
207
+ props: { columns: basicColumns, data: testData, onRowClick: () => {} },
208
+ });
209
+
210
+ const dataRows = container.querySelectorAll('tbody tr');
211
+ expect(dataRows[0]).toHaveClass('cursor-pointer');
212
+ });
213
+
214
+ test('does not add cursor-pointer when onRowClick is not provided', () => {
215
+ const { container } = render(UiDataTable, {
216
+ props: { columns: basicColumns, data: testData },
217
+ });
218
+
219
+ const dataRows = container.querySelectorAll('tbody tr');
220
+ expect(dataRows[0]).not.toHaveClass('cursor-pointer');
221
+ });
222
+
223
+ test('does not fire onRowClick when clicking interactive elements inside a row', async () => {
224
+ const user = userEvent.setup();
225
+ const onRowClick = vi.fn();
226
+ const { getByLabelText } = render({
227
+ components: { UiDataTable },
228
+ template: '<UiDataTable :columns="columns" :data="data" :on-row-click="onRowClick" />',
229
+ setup() {
230
+ return { columns: selectableColumns, data: testData, onRowClick };
231
+ },
232
+ });
233
+
234
+ await nextTick();
235
+
236
+ // Click the checkbox (interactive element) - should NOT trigger onRowClick
237
+ await user.click(getByLabelText('Select row Alice'));
238
+ expect(onRowClick).not.toHaveBeenCalled();
239
+ });
240
+
241
+ test('activates onRowClick via keyboard on cell', async () => {
242
+ const onRowClick = vi.fn();
243
+ const { getByText } = render(UiDataTable, {
244
+ props: { columns: basicColumns, data: testData, onRowClick },
245
+ });
246
+
247
+ await fireEvent.keyDown(getByText('Alice').closest('td')!, { key: 'Enter' });
248
+
249
+ expect(onRowClick).toHaveBeenCalledWith(testData[0]);
250
+ });
169
251
  });
170
252
 
171
253
  describe('Filtering', () => {
@@ -1,4 +1,4 @@
1
- import { render, waitFor } from '@testing-library/vue';
1
+ import { render } from '@testing-library/vue';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { describe, expect, test, vi } from 'vitest';
4
4
  import UiDataTableColumnHeader from '../UiDataTableColumnHeader.vue';
@@ -10,6 +10,7 @@ function createMockColumn(options: { canSort?: boolean; isSorted?: false | 'asc'
10
10
  getCanSort: vi.fn(() => canSort),
11
11
  getIsSorted: vi.fn(() => isSorted),
12
12
  toggleSorting: vi.fn(),
13
+ clearSorting: vi.fn(),
13
14
  };
14
15
  }
15
16
 
@@ -34,13 +35,12 @@ describe('UiDataTableColumnHeader', () => {
34
35
  expect(queryByRole('button')).not.toBeInTheDocument();
35
36
  });
36
37
 
37
- test('renders as button with dropdown trigger when column is sortable', () => {
38
+ test('renders as button when column is sortable', () => {
38
39
  const column = createMockColumn({ canSort: true });
39
40
  const { getByRole } = render(UiDataTableColumnHeader, {
40
41
  props: { column, title: 'Name' },
41
42
  });
42
43
 
43
- // Button should be accessible by role and name
44
44
  expect(getByRole('button', { name: /Name/i })).toBeInTheDocument();
45
45
  });
46
46
 
@@ -55,36 +55,42 @@ describe('UiDataTableColumnHeader', () => {
55
55
  });
56
56
  });
57
57
 
58
- describe('Dropdown Menu', () => {
59
- test('triggers ascending sort when Asc option is clicked', async () => {
58
+ describe('Click-to-Sort', () => {
59
+ test('sorts ascending when unsorted column is clicked', async () => {
60
60
  const user = userEvent.setup();
61
- const column = createMockColumn();
61
+ const column = createMockColumn({ isSorted: false });
62
62
  const { getByRole } = render(UiDataTableColumnHeader, {
63
63
  props: { column, title: 'Name' },
64
64
  });
65
65
 
66
66
  await user.click(getByRole('button'));
67
- await waitFor(() => expect(getByRole('menu')).toBeInTheDocument());
68
-
69
- await user.click(getByRole('menuitem', { name: /Asc/i }));
70
67
 
71
68
  expect(column.toggleSorting).toHaveBeenCalledWith(false);
72
69
  });
73
70
 
74
- test('triggers descending sort when Desc option is clicked', async () => {
71
+ test('sorts descending when ascending column is clicked', async () => {
75
72
  const user = userEvent.setup();
76
- const column = createMockColumn();
73
+ const column = createMockColumn({ isSorted: 'asc' });
77
74
  const { getByRole } = render(UiDataTableColumnHeader, {
78
75
  props: { column, title: 'Name' },
79
76
  });
80
77
 
81
78
  await user.click(getByRole('button'));
82
- await waitFor(() => expect(getByRole('menu')).toBeInTheDocument());
83
-
84
- await user.click(getByRole('menuitem', { name: /Desc/i }));
85
79
 
86
80
  expect(column.toggleSorting).toHaveBeenCalledWith(true);
87
81
  });
82
+
83
+ test('clears sorting when descending column is clicked', async () => {
84
+ const user = userEvent.setup();
85
+ const column = createMockColumn({ isSorted: 'desc' });
86
+ const { getByRole } = render(UiDataTableColumnHeader, {
87
+ props: { column, title: 'Name' },
88
+ });
89
+
90
+ await user.click(getByRole('button'));
91
+
92
+ expect(column.clearSorting).toHaveBeenCalled();
93
+ });
88
94
  });
89
95
 
90
96
  describe('Accessibility', () => {
@@ -115,73 +121,30 @@ describe('UiDataTableColumnHeader', () => {
115
121
  });
116
122
 
117
123
  describe('i18n Support', () => {
118
- test('uses default labels when labels prop is not provided', async () => {
119
- const user = userEvent.setup();
120
- const column = createMockColumn();
121
- const { getByRole } = render(UiDataTableColumnHeader, {
122
- props: { column, title: 'Name' },
123
- });
124
-
125
- await user.click(getByRole('button'));
126
-
127
- await waitFor(() => {
128
- expect(getByRole('menuitem', { name: 'Asc' })).toBeInTheDocument();
129
- expect(getByRole('menuitem', { name: 'Desc' })).toBeInTheDocument();
130
- });
131
- });
132
-
133
- test('applies custom labels when provided', async () => {
134
- const user = userEvent.setup();
124
+ test('uses custom aria labels when provided', () => {
135
125
  const column = createMockColumn({ isSorted: 'asc' });
136
126
  const { getByRole } = render(UiDataTableColumnHeader, {
137
127
  props: {
138
128
  column,
139
129
  title: 'Name',
140
- labels: {
141
- sortAscending: 'Aufsteigend',
142
- sortDescending: 'Absteigend',
143
- sortedAscending: 'aufsteigend sortiert',
144
- },
130
+ labels: { sortedAscending: 'aufsteigend sortiert' },
145
131
  },
146
132
  });
147
133
 
148
- // Check custom aria-label
149
134
  expect(getByRole('button')).toHaveAttribute('aria-label', 'Name, aufsteigend sortiert');
150
-
151
- // Check custom menu labels
152
- await user.click(getByRole('button'));
153
-
154
- await waitFor(() => {
155
- expect(getByRole('menuitem', { name: 'Aufsteigend' })).toBeInTheDocument();
156
- expect(getByRole('menuitem', { name: 'Absteigend' })).toBeInTheDocument();
157
- });
158
135
  });
159
136
 
160
- test('merges partial custom labels with defaults', async () => {
161
- const user = userEvent.setup();
137
+ test('uses custom clickToSort label in aria-label', () => {
162
138
  const column = createMockColumn({ isSorted: false });
163
139
  const { getByRole } = render(UiDataTableColumnHeader, {
164
140
  props: {
165
141
  column,
166
- title: 'Status',
167
- labels: {
168
- sortAscending: 'Sort A-Z',
169
- // Other labels should fall back to defaults
170
- },
142
+ title: 'Email',
143
+ labels: { clickToSort: 'zum Sortieren klicken' },
171
144
  },
172
145
  });
173
146
 
174
- // clickToSort should use default
175
- expect(getByRole('button')).toHaveAttribute('aria-label', 'Status, click to sort');
176
-
177
- await user.click(getByRole('button'));
178
-
179
- await waitFor(() => {
180
- // Custom label
181
- expect(getByRole('menuitem', { name: 'Sort A-Z' })).toBeInTheDocument();
182
- // Default label (not overridden)
183
- expect(getByRole('menuitem', { name: 'Desc' })).toBeInTheDocument();
184
- });
147
+ expect(getByRole('button')).toHaveAttribute('aria-label', 'Email, zum Sortieren klicken');
185
148
  });
186
149
 
187
150
  test('uses custom sortedDescending label in aria-label', () => {
@@ -190,28 +153,11 @@ describe('UiDataTableColumnHeader', () => {
190
153
  props: {
191
154
  column,
192
155
  title: 'Amount',
193
- labels: {
194
- sortedDescending: 'absteigend sortiert',
195
- },
156
+ labels: { sortedDescending: 'absteigend sortiert' },
196
157
  },
197
158
  });
198
159
 
199
160
  expect(getByRole('button')).toHaveAttribute('aria-label', 'Amount, absteigend sortiert');
200
161
  });
201
-
202
- test('uses custom clickToSort label in aria-label', () => {
203
- const column = createMockColumn({ isSorted: false });
204
- const { getByRole } = render(UiDataTableColumnHeader, {
205
- props: {
206
- column,
207
- title: 'Email',
208
- labels: {
209
- clickToSort: 'zum Sortieren klicken',
210
- },
211
- },
212
- });
213
-
214
- expect(getByRole('button')).toHaveAttribute('aria-label', 'Email, zum Sortieren klicken');
215
- });
216
162
  });
217
163
  });
@@ -36,6 +36,11 @@ export type UiDataTableProps<TData, TValue> = {
36
36
  * @example "400px"
37
37
  */
38
38
  tableMinHeight?: string;
39
+ /**
40
+ * Callback when a data row is clicked. Receives the row's original data item.
41
+ * @example (item) => router.push(`/details/${item.id}`)
42
+ */
43
+ onRowClick?: (item: TData) => void;
39
44
  };
40
45
 
41
46
  /**