@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.
- package/dist/system/index.d.ts +5 -0
- package/dist/system/lib.js +1624 -1654
- package/docs/public-docs/getting-started-designers.md +19 -6
- package/package.json +3 -3
- package/src/patterns/UiDataTable/UiDataTable.stories.ts +98 -1
- package/src/patterns/UiDataTable/UiDataTable.vue +15 -0
- package/src/patterns/UiDataTable/UiDataTableColumnHeader.vue +29 -55
- package/src/patterns/UiDataTable/__tests__/UiDataTable.test.ts +90 -8
- package/src/patterns/UiDataTable/__tests__/UiDataTableColumnHeader.test.ts +27 -81
- package/src/patterns/UiDataTable/types.ts +5 -0
|
@@ -2,16 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
## Figma
|
|
5
|
+
## Figma design system
|
|
6
6
|
|
|
7
|
-
Figma components and
|
|
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
|
-
|
|
9
|
+
### Adding Components to your Figma file
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
<
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
59
|
-
test('
|
|
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('
|
|
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
|
|
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('
|
|
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: '
|
|
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
|
-
|
|
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
|
/**
|