@nexxtmove/ui 0.1.28 → 0.1.30

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.
@@ -0,0 +1,283 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { computed, ref, type ConcreteComponent } from 'vue'
3
+ import Table, { type TableColumn } from './Table.vue'
4
+
5
+ type RowKey = 'name' | 'email' | 'role' | 'status' | 'department' | 'lastLogin'
6
+
7
+ interface TableStoryProps {
8
+ columns: TableColumn<RowKey>[]
9
+ rows: Record<RowKey, string>[]
10
+ selectable?: boolean
11
+ }
12
+
13
+ const meta: Meta<TableStoryProps> = {
14
+ title: 'Components/Atoms/Table',
15
+ component: Table as ConcreteComponent<TableStoryProps>,
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj<TableStoryProps>
20
+
21
+ const columns: TableColumn<RowKey>[] = [
22
+ { key: 'name', label: 'Name', sortable: true },
23
+ { key: 'email', label: 'Email', sortable: true },
24
+ { key: 'department', label: 'Department' },
25
+ { key: 'role', label: 'Role', sortable: true, width: '0.5fr' },
26
+ { key: 'lastLogin', label: 'lastLogin' },
27
+ { key: 'status', label: 'Status', width: '0.5fr' },
28
+ ]
29
+
30
+ const allRows = [
31
+ {
32
+ name: 'Alice Johnson',
33
+ email: 'alice@example.com',
34
+ role: 'Admin',
35
+ status: 'Active',
36
+ department: 'HR',
37
+ lastLogin: '2026-03-25',
38
+ },
39
+ {
40
+ name: 'Bob Smith',
41
+ email: 'bob@example.com',
42
+ role: 'Editor',
43
+ status: 'Active',
44
+ department: 'Marketing',
45
+ lastLogin: '2026-03-27',
46
+ },
47
+ {
48
+ name: 'Carol White',
49
+ email: 'carol@example.com',
50
+ role: 'Viewer',
51
+ status: 'Inactive',
52
+ department: 'Finance',
53
+ lastLogin: '2026-02-12',
54
+ },
55
+ {
56
+ name: 'David Brown',
57
+ email: 'david@example.com',
58
+ role: 'Editor',
59
+ status: 'Active',
60
+ department: 'IT',
61
+ lastLogin: '2026-03-30',
62
+ },
63
+ {
64
+ name: 'Eve Turner',
65
+ email: 'eve@example.com',
66
+ role: 'Admin',
67
+ status: 'Active',
68
+ department: 'HR',
69
+ lastLogin: '2026-03-28',
70
+ },
71
+ {
72
+ name: 'Frank Miller',
73
+ email: 'frank@example.com',
74
+ role: 'Viewer',
75
+ status: 'Active',
76
+ department: 'Support',
77
+ lastLogin: '2026-03-26',
78
+ },
79
+ {
80
+ name: 'Grace Lee',
81
+ email: 'grace@example.com',
82
+ role: 'Editor',
83
+ status: 'Active',
84
+ department: 'Product',
85
+ lastLogin: '2026-03-29',
86
+ },
87
+ {
88
+ name: 'Henry Wilson',
89
+ email: 'henry@example.com',
90
+ role: 'Viewer',
91
+ status: 'Inactive',
92
+ department: 'Finance',
93
+ lastLogin: '2026-01-10',
94
+ },
95
+ {
96
+ name: 'Ivy Clark',
97
+ email: 'ivy@example.com',
98
+ role: 'Admin',
99
+ status: 'Active',
100
+ department: 'IT',
101
+ lastLogin: '2026-03-20',
102
+ },
103
+ {
104
+ name: 'Jack Davis',
105
+ email: 'jack@example.com',
106
+ role: 'Editor',
107
+ status: 'Active',
108
+ department: 'Marketing',
109
+ lastLogin: '2026-03-23',
110
+ },
111
+ {
112
+ name: 'Karen Harris',
113
+ email: 'karen@example.com',
114
+ role: 'Viewer',
115
+ status: 'Active',
116
+ department: 'Support',
117
+ lastLogin: '2026-03-19',
118
+ },
119
+ {
120
+ name: 'Leo King',
121
+ email: 'leo@example.com',
122
+ role: 'Editor',
123
+ status: 'Active',
124
+ department: 'Product',
125
+ lastLogin: '2026-03-28',
126
+ },
127
+ {
128
+ name: 'Mia Scott',
129
+ email: 'mia@example.com',
130
+ role: 'Admin',
131
+ status: 'Active',
132
+ department: 'HR',
133
+ lastLogin: '2026-03-30',
134
+ },
135
+ {
136
+ name: 'Nick Evans',
137
+ email: 'nick@example.com',
138
+ role: 'Viewer',
139
+ status: 'Inactive',
140
+ department: 'Finance',
141
+ lastLogin: '2026-02-05',
142
+ },
143
+ {
144
+ name: 'Olivia Foster',
145
+ email: 'olivia@example.com',
146
+ role: 'Editor',
147
+ status: 'Active',
148
+ department: 'Product',
149
+ lastLogin: '2026-03-27',
150
+ },
151
+ {
152
+ name: 'Paul Green',
153
+ email: 'paul@example.com',
154
+ role: 'Viewer',
155
+ status: 'Active',
156
+ department: 'Support',
157
+ lastLogin: '2026-03-18',
158
+ },
159
+ {
160
+ name: 'Quincy Adams',
161
+ email: 'quincy@example.com',
162
+ role: 'Editor',
163
+ status: 'Active',
164
+ department: 'Marketing',
165
+ lastLogin: '2026-03-25',
166
+ },
167
+ {
168
+ name: 'Rachel Lewis',
169
+ email: 'rachel@example.com',
170
+ role: 'Admin',
171
+ status: 'Active',
172
+ department: 'HR',
173
+ lastLogin: '2026-03-29',
174
+ },
175
+ {
176
+ name: 'Sam Nelson',
177
+ email: 'sam@example.com',
178
+ role: 'Viewer',
179
+ status: 'Inactive',
180
+ department: 'Finance',
181
+ lastLogin: '2026-01-15',
182
+ },
183
+ {
184
+ name: 'Tina Brooks',
185
+ email: 'tina@example.com',
186
+ role: 'Editor',
187
+ status: 'Active',
188
+ department: 'IT',
189
+ lastLogin: '2026-03-30',
190
+ },
191
+ ]
192
+
193
+ const sortRows = (rows: typeof allRows, key: string | undefined, direction: 'asc' | 'desc') => {
194
+ if (!key) return rows
195
+ return [...rows].sort((a, b) => {
196
+ const aVal = String(a[key as keyof typeof a] ?? '')
197
+ const bVal = String(b[key as keyof typeof b] ?? '')
198
+ const result = aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: 'base' })
199
+ return direction === 'asc' ? result : -result
200
+ })
201
+ }
202
+
203
+ export const Default: Story = {
204
+ args: { columns, rows: allRows },
205
+ render: (args) => ({
206
+ components: { Table },
207
+ setup() {
208
+ const sortKey = ref<string | undefined>(undefined)
209
+ const sortDirection = ref<'asc' | 'desc'>('asc')
210
+ const rows = computed(() =>
211
+ sortRows(args.rows as typeof allRows, sortKey.value, sortDirection.value),
212
+ )
213
+ const onSort = (key: string, direction: 'asc' | 'desc') => {
214
+ sortKey.value = key
215
+ sortDirection.value = direction
216
+ }
217
+ return { args, rows, sortKey, sortDirection, onSort }
218
+ },
219
+ template: `<Table :columns="args.columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="onSort" />`,
220
+ }),
221
+ }
222
+
223
+ export const WithSelection: Story = {
224
+ args: { columns, rows: allRows, selectable: true },
225
+ render: (args) => ({
226
+ components: { Table },
227
+ setup() {
228
+ const sortKey = ref<string | undefined>(undefined)
229
+ const sortDirection = ref<'asc' | 'desc'>('asc')
230
+ const selected = ref<number[]>([])
231
+ const rows = computed(() =>
232
+ sortRows(args.rows as typeof allRows, sortKey.value, sortDirection.value),
233
+ )
234
+ const onSort = (key: string, direction: 'asc' | 'desc') => {
235
+ sortKey.value = key
236
+ sortDirection.value = direction
237
+ }
238
+ return { args, rows, sortKey, sortDirection, onSort, selected }
239
+ },
240
+ template: `<Table :columns="args.columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" :selectable="args.selectable" v-model="selected" @sort="onSort" />`,
241
+ }),
242
+ }
243
+
244
+ export const WithSlots: Story = {
245
+ args: { columns, rows: allRows },
246
+ render: (args) => ({
247
+ components: { Table },
248
+ setup() {
249
+ const sortKey = ref<string | undefined>(undefined)
250
+ const sortDirection = ref<'asc' | 'desc'>('asc')
251
+ const rows = computed(() =>
252
+ sortRows(args.rows as typeof allRows, sortKey.value, sortDirection.value),
253
+ )
254
+ const onSort = (key: string, direction: 'asc' | 'desc') => {
255
+ sortKey.value = key
256
+ sortDirection.value = direction
257
+ }
258
+ return { args, rows, sortKey, sortDirection, onSort }
259
+ },
260
+ template: `
261
+ <Table :columns="args.columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="onSort">
262
+ <template #cell-name="{ value, row }">
263
+ <div class="flex flex-col">
264
+ <span class="font-medium">{{ value }}</span>
265
+ <span class="text-xs text-slate-400">{{ row.email }}</span>
266
+ </div>
267
+ </template>
268
+ <template #cell-status="{ value }">
269
+ <span
270
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
271
+ :class="value === 'Active' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'"
272
+ >
273
+ {{ value }}
274
+ </span>
275
+ </template>
276
+ </Table>
277
+ `,
278
+ }),
279
+ }
280
+
281
+ export const Empty: Story = {
282
+ args: { columns, rows: [] },
283
+ }
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Table from './Table.vue'
4
+
5
+ const columns = [
6
+ { key: 'name', label: 'Name', sortable: true },
7
+ { key: 'email', label: 'Email' },
8
+ ]
9
+
10
+ const rows = [
11
+ { name: 'Alice', email: 'alice@example.com' },
12
+ { name: 'Bob', email: 'bob@example.com' },
13
+ { name: 'Carol', email: 'carol@example.com' },
14
+ ]
15
+
16
+ describe('Table', () => {
17
+ describe('rendering', () => {
18
+ it('renders column headers', () => {
19
+ const wrapper = mount(Table, { props: { columns, rows } })
20
+ expect(wrapper.text()).toContain('Name')
21
+ expect(wrapper.text()).toContain('Email')
22
+ })
23
+
24
+ it('renders row data', () => {
25
+ const wrapper = mount(Table, { props: { columns, rows } })
26
+ expect(wrapper.text()).toContain('Alice')
27
+ expect(wrapper.text()).toContain('bob@example.com')
28
+ })
29
+
30
+ it('renders empty slot when rows is empty', () => {
31
+ const wrapper = mount(Table, { props: { columns, rows: [] } })
32
+ expect(wrapper.text()).toContain('Geen data beschikbaar')
33
+ })
34
+
35
+ it('renders custom empty slot content', () => {
36
+ const wrapper = mount(Table, {
37
+ props: { columns, rows: [] },
38
+ slots: { empty: 'Nothing here' },
39
+ })
40
+ expect(wrapper.text()).toContain('Nothing here')
41
+ })
42
+
43
+ it('renders cell slot content', () => {
44
+ const wrapper = mount(Table, {
45
+ props: { columns, rows },
46
+ slots: { 'cell-name': '<strong>custom</strong>' },
47
+ })
48
+ expect(wrapper.html()).toContain('<strong>custom</strong>')
49
+ })
50
+ })
51
+
52
+ describe('sorting', () => {
53
+ it('renders sort icon on sortable columns', () => {
54
+ const wrapper = mount(Table, { props: { columns, rows } })
55
+ const headers = wrapper.findAll('[role="columnheader"]')
56
+ const nameHeader = headers[0]
57
+ expect(nameHeader.find('i').exists()).toBe(true)
58
+ })
59
+
60
+ it('does not render sort icon on non-sortable columns', () => {
61
+ const wrapper = mount(Table, { props: { columns, rows } })
62
+ const headers = wrapper.findAll('[role="columnheader"]')
63
+ const emailHeader = headers[1]
64
+ expect(emailHeader.find('i').exists()).toBe(false)
65
+ })
66
+
67
+ it('emits sort event when clicking a sortable header', async () => {
68
+ const wrapper = mount(Table, { props: { columns, rows } })
69
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
70
+ await nameHeader.trigger('click')
71
+ expect(wrapper.emitted('sort')).toBeTruthy()
72
+ expect(wrapper.emitted('sort')![0]).toEqual(['name', 'asc'])
73
+ })
74
+
75
+ it('emits desc direction when clicking an already sorted column', async () => {
76
+ const wrapper = mount(Table, {
77
+ props: { columns, rows, sortKey: 'name', sortDirection: 'asc' },
78
+ })
79
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
80
+ await nameHeader.trigger('click')
81
+ expect(wrapper.emitted('sort')![0]).toEqual(['name', 'desc'])
82
+ })
83
+
84
+ it('does not emit sort when clicking a non-sortable header', async () => {
85
+ const wrapper = mount(Table, { props: { columns, rows } })
86
+ const emailHeader = wrapper.findAll('[role="columnheader"]')[1]
87
+ await emailHeader.trigger('click')
88
+ expect(wrapper.emitted('sort')).toBeFalsy()
89
+ })
90
+
91
+ it('sets aria-sort to ascending on the active sort column', () => {
92
+ const wrapper = mount(Table, {
93
+ props: { columns, rows, sortKey: 'name', sortDirection: 'asc' },
94
+ })
95
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
96
+ expect(nameHeader.attributes('aria-sort')).toBe('ascending')
97
+ })
98
+
99
+ it('sets aria-sort to descending on the active sort column', () => {
100
+ const wrapper = mount(Table, {
101
+ props: { columns, rows, sortKey: 'name', sortDirection: 'desc' },
102
+ })
103
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
104
+ expect(nameHeader.attributes('aria-sort')).toBe('descending')
105
+ })
106
+
107
+ it('sets aria-sort to none on an inactive sortable column', () => {
108
+ const wrapper = mount(Table, { props: { columns, rows } })
109
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
110
+ expect(nameHeader.attributes('aria-sort')).toBe('none')
111
+ })
112
+ })
113
+
114
+ describe('selection', () => {
115
+ it('does not render checkboxes when selectable is false', () => {
116
+ const wrapper = mount(Table, { props: { columns, rows } })
117
+ expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
118
+ })
119
+
120
+ it('renders a checkbox per row and one in the header when selectable', () => {
121
+ const wrapper = mount(Table, {
122
+ props: { columns, rows, selectable: true, modelValue: [] },
123
+ })
124
+ const checkboxes = wrapper.findAll('input[type="checkbox"]')
125
+ expect(checkboxes).toHaveLength(rows.length + 1)
126
+ })
127
+
128
+ it('emits update:modelValue with all indices when select-all is clicked', async () => {
129
+ const wrapper = mount(Table, {
130
+ props: { columns, rows, selectable: true, modelValue: [] },
131
+ })
132
+ const headerCheckbox = wrapper.find('input[type="checkbox"]')
133
+ await headerCheckbox.setValue(true)
134
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[0, 1, 2]])
135
+ })
136
+
137
+ it('emits update:modelValue with empty array when deselecting all', async () => {
138
+ const wrapper = mount(Table, {
139
+ props: { columns, rows, selectable: true, modelValue: [0, 1, 2] },
140
+ })
141
+ const headerCheckbox = wrapper.find('input[type="checkbox"]')
142
+ await headerCheckbox.setValue(false)
143
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[]])
144
+ })
145
+
146
+ it('emits update:modelValue when a row checkbox is changed', async () => {
147
+ const wrapper = mount(Table, {
148
+ props: { columns, rows, selectable: true, modelValue: [] },
149
+ })
150
+ const rowCheckboxes = wrapper.findAll('input[type="checkbox"]').slice(1)
151
+ await rowCheckboxes[0].setValue(true)
152
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[0]])
153
+ })
154
+
155
+ it('removes index from selection when an already-selected row checkbox is changed', async () => {
156
+ const wrapper = mount(Table, {
157
+ props: { columns, rows, selectable: true, modelValue: [0, 1] },
158
+ })
159
+ const rowCheckboxes = wrapper.findAll('input[type="checkbox"]').slice(1)
160
+ await rowCheckboxes[0].setValue(false)
161
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[1]])
162
+ })
163
+ })
164
+
165
+ describe('row click selection', () => {
166
+ it('selects a row when clicking on it', async () => {
167
+ const wrapper = mount(Table, {
168
+ props: { columns, rows, selectable: true, modelValue: [] },
169
+ })
170
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
171
+ await dataRows[0].trigger('click')
172
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[0]])
173
+ })
174
+
175
+ it('does not emit selection when clicking a button inside a row', async () => {
176
+ const wrapper = mount(Table, {
177
+ props: { columns, rows, selectable: true, modelValue: [] },
178
+ slots: { 'cell-name': '<button>action</button>' },
179
+ })
180
+ const button = wrapper.find('button')
181
+ await button.trigger('click')
182
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
183
+ })
184
+
185
+ it('does not emit selection when clicking a link inside a row', async () => {
186
+ const wrapper = mount(Table, {
187
+ props: { columns, rows, selectable: true, modelValue: [] },
188
+ slots: { 'cell-name': '<a href="#">link</a>' },
189
+ })
190
+ const link = wrapper.find('a')
191
+ await link.trigger('click')
192
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
193
+ })
194
+
195
+ it('does not select when selectable is false', async () => {
196
+ const wrapper = mount(Table, { props: { columns, rows } })
197
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
198
+ await dataRows[0].trigger('click')
199
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
200
+ })
201
+ })
202
+
203
+ describe('keyboard navigation', () => {
204
+ it('moves focus to next row on ArrowDown', async () => {
205
+ const wrapper = mount(Table, { props: { columns, rows }, attachTo: document.body })
206
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
207
+ await dataRows[0].trigger('keydown', { key: 'ArrowDown' })
208
+ expect(document.activeElement).toBe(dataRows[1].element)
209
+ wrapper.unmount()
210
+ })
211
+
212
+ it('moves focus to previous row on ArrowUp', async () => {
213
+ const wrapper = mount(Table, { props: { columns, rows }, attachTo: document.body })
214
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
215
+ await dataRows[1].trigger('keydown', { key: 'ArrowUp' })
216
+ expect(document.activeElement).toBe(dataRows[0].element)
217
+ wrapper.unmount()
218
+ })
219
+ })
220
+ })