@vobs/table 1.0.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.
@@ -0,0 +1,536 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { state } from '@vobs/reactivity'
3
+ import { addEventListener, createDOMRenderer, createElement, createVobs, setAttribute, setRenderer } from '@vobs/vobs'
4
+ import {
5
+ createColumnSettingsPersistence,
6
+ createLocalColumnSettingsPersistence,
7
+ KitColumnSettings,
8
+ KitDataTable
9
+ } from './index'
10
+ import type {
11
+ DataTableColumn,
12
+ DataTableColumnSettings,
13
+ DataTableColumnSettingsPersistence,
14
+ DataTableColumnSettingsStorage,
15
+ DataTableQuery
16
+ } from './types'
17
+
18
+ interface User { id: number; name: string; role: string }
19
+
20
+ const columns: readonly DataTableColumn<User>[] = [
21
+ { id: 'name', label: 'Name', key: 'name', sortable: true, filterable: true },
22
+ { id: 'role', label: 'Role', key: 'role', sortable: true }
23
+ ]
24
+
25
+ describe('@vobs/table', () => {
26
+ beforeEach(() => setRenderer(createDOMRenderer()))
27
+
28
+ it('支持客户端筛选、排序和分页,并通过 query 回调交给宿主控制', () => {
29
+ const container = document.createElement('div')
30
+ const queries: DataTableQuery[] = []
31
+ const app = createVobs({
32
+ render: () => KitDataTable<User>({
33
+ columns,
34
+ rows: [
35
+ { id: 1, name: 'Ada', role: 'Admin' },
36
+ { id: 2, name: 'Lin', role: 'Viewer' }
37
+ ],
38
+ pageSize: 1,
39
+ sort: null,
40
+ onQueryChange: query => queries.push(query)
41
+ })
42
+ })
43
+ app.mount(container)
44
+
45
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(1)
46
+ expect(container.querySelector('.vobs-data-table__filters')).toBeNull()
47
+ expect(container.querySelector('.vobs-data-table__filter')).toBeNull()
48
+ expect(container.textContent).toContain('Ada')
49
+ ;(container.querySelector('button.vobs-data-table__sort') as HTMLButtonElement).click()
50
+ expect(queries[0]?.sort).toEqual({ columnId: 'name', direction: 'asc' })
51
+ ;(container.querySelector('.vobs-data-table__page-button:not([disabled])') as HTMLButtonElement).click()
52
+ expect(queries[1]?.page).toBe(2)
53
+ app.destroy()
54
+ })
55
+
56
+ it('客户端排序会实际改变行顺序,并支持升序、降序和取消排序', async () => {
57
+ const container = document.createElement('div')
58
+ const app = createVobs({
59
+ render: () => KitDataTable<User>({
60
+ columns,
61
+ rows: [
62
+ { id: 1, name: 'Zoe', role: 'Admin' },
63
+ { id: 2, name: 'Ada', role: 'Viewer' },
64
+ { id: 3, name: 'Lin', role: 'Editor' }
65
+ ]
66
+ })
67
+ })
68
+ app.mount(container)
69
+
70
+ const names = (): string[] => [...container.querySelectorAll('td[data-column-id="name"]')]
71
+ .map(cell => cell.textContent ?? '')
72
+ const sortButton = container.querySelector('button.vobs-data-table__sort') as HTMLButtonElement
73
+ const header = container.querySelector('th[data-column-id="name"]') as HTMLTableCellElement
74
+
75
+ expect(names()).toEqual(['Zoe', 'Ada', 'Lin'])
76
+ sortButton.click()
77
+ await Promise.resolve()
78
+ expect(names()).toEqual(['Ada', 'Lin', 'Zoe'])
79
+ expect(header.getAttribute('aria-sort')).toBe('ascending')
80
+ expect(header.getAttribute('data-sort-direction')).toBe('asc')
81
+
82
+ sortButton.click()
83
+ await Promise.resolve()
84
+ expect(names()).toEqual(['Zoe', 'Lin', 'Ada'])
85
+ expect(header.getAttribute('aria-sort')).toBe('descending')
86
+
87
+ sortButton.click()
88
+ await Promise.resolve()
89
+ expect(names()).toEqual(['Zoe', 'Ada', 'Lin'])
90
+ expect(header.hasAttribute('aria-sort')).toBe(false)
91
+ expect(header.hasAttribute('data-sort-direction')).toBe(false)
92
+ app.destroy()
93
+ })
94
+
95
+ it('客户端排序支持数字、日期、空值置后和自定义 sortValue', async () => {
96
+ interface Metric {
97
+ readonly id: number
98
+ readonly score: number | null
99
+ readonly createdAt: string
100
+ }
101
+ const metricColumns: readonly DataTableColumn<Metric>[] = [
102
+ { id: 'score', label: 'Score', key: 'score', sortable: true, sortType: 'number', sortValue: row => row.score },
103
+ { id: 'createdAt', label: 'Created', sortable: true, sortType: 'date', key: 'createdAt' }
104
+ ]
105
+ const container = document.createElement('div')
106
+ const app = createVobs({
107
+ render: () => KitDataTable<Metric>({
108
+ columns: metricColumns,
109
+ rows: [
110
+ { id: 1, score: 10, createdAt: '2026-01-02' },
111
+ { id: 2, score: null, createdAt: '2025-12-01' },
112
+ { id: 3, score: 2, createdAt: '2026-01-01' }
113
+ ]
114
+ })
115
+ })
116
+ app.mount(container)
117
+
118
+ const scores = (): string[] => [...container.querySelectorAll('td[data-column-id="score"]')]
119
+ .map(cell => cell.textContent ?? '')
120
+ const scoreButton = container.querySelector('th[data-column-id="score"] button') as HTMLButtonElement
121
+ scoreButton.click()
122
+ await Promise.resolve()
123
+ expect(scores()).toEqual(['2', '10', ''])
124
+ scoreButton.click()
125
+ await Promise.resolve()
126
+ expect(scores()).toEqual(['10', '2', ''])
127
+
128
+ const dates = (): string[] => [...container.querySelectorAll('td[data-column-id="createdAt"]')]
129
+ .map(cell => cell.textContent ?? '')
130
+ const dateButton = container.querySelector('th[data-column-id="createdAt"] button') as HTMLButtonElement
131
+ dateButton.click()
132
+ await Promise.resolve()
133
+ expect(dates()).toEqual(['2025-12-01', '2026-01-01', '2026-01-02'])
134
+ dateButton.click()
135
+ await Promise.resolve()
136
+ expect(dates()).toEqual(['2026-01-02', '2026-01-01', '2025-12-01'])
137
+ app.destroy()
138
+ })
139
+
140
+ it('服务端排序模式只发出 sort 查询,不重排当前页数据', () => {
141
+ const queries: DataTableQuery[] = []
142
+ const serverColumns: readonly DataTableColumn<User>[] = [
143
+ { id: 'name', label: 'Name', key: 'name', sortable: true, sortKey: 'display_name' },
144
+ { id: 'role', label: 'Role', key: 'role' }
145
+ ]
146
+ const container = document.createElement('div')
147
+ const app = createVobs({
148
+ render: () => KitDataTable<User>({
149
+ columns: serverColumns,
150
+ rows: [
151
+ { id: 1, name: 'Zoe', role: 'Admin' },
152
+ { id: 2, name: 'Ada', role: 'Viewer' }
153
+ ],
154
+ total: 20,
155
+ sortingMode: 'server',
156
+ onQueryChange: query => queries.push(query)
157
+ })
158
+ })
159
+ app.mount(container)
160
+
161
+ const names = (): string[] => [...container.querySelectorAll('td[data-column-id="name"]')]
162
+ .map(cell => cell.textContent ?? '')
163
+ ;(container.querySelector('button.vobs-data-table__sort') as HTMLButtonElement).click()
164
+
165
+ expect(names()).toEqual(['Zoe', 'Ada'])
166
+ expect(queries[0]?.sort).toEqual({ columnId: 'name', direction: 'asc', sortKey: 'display_name' })
167
+ app.destroy()
168
+ })
169
+
170
+ it('接受外部 filters 和列 filter 谓词,但不渲染表格内筛选输入', () => {
171
+ const container = document.createElement('div')
172
+ const app = createVobs({
173
+ render: () => KitDataTable<User>({
174
+ rows: [
175
+ { id: 1, name: 'Ada', role: 'Admin' },
176
+ { id: 2, name: 'Lin', role: 'Viewer' }
177
+ ],
178
+ filters: { name: 'Ad' },
179
+ columns: columns.map(column => column.id === 'name'
180
+ ? { ...column, filter: (row, value) => row.name.includes(String(value)) }
181
+ : column)
182
+ })
183
+ })
184
+ app.mount(container)
185
+ expect(container.querySelector('.vobs-data-table__filter')).toBeNull()
186
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(1)
187
+ expect(container.querySelector('tbody')?.textContent).toContain('Ada')
188
+ expect(container.querySelector('tbody')?.textContent).not.toContain('Lin')
189
+ app.destroy()
190
+ })
191
+
192
+ it('默认固定表头,并允许关闭固定表头', () => {
193
+ const container = document.createElement('div')
194
+ const app = createVobs({
195
+ render: () => KitDataTable<User>({
196
+ columns,
197
+ rows: [{ id: 1, name: 'Ada', role: 'Admin' }]
198
+ })
199
+ })
200
+ app.mount(container)
201
+ expect(container.querySelector('.vobs-data-table')?.classList.contains('vobs-data-table--sticky-header')).toBe(true)
202
+ app.destroy()
203
+
204
+ const disabledContainer = document.createElement('div')
205
+ const disabledApp = createVobs({
206
+ render: () => KitDataTable<User>({
207
+ columns,
208
+ rows: [{ id: 1, name: 'Ada', role: 'Admin' }],
209
+ stickyHeader: false
210
+ })
211
+ })
212
+ disabledApp.mount(disabledContainer)
213
+ expect(disabledContainer.querySelector('.vobs-data-table')?.classList.contains('vobs-data-table--sticky-header')).toBe(false)
214
+ disabledApp.destroy()
215
+ })
216
+
217
+ it('使用列设置应用列显隐、顺序和宽度', () => {
218
+ const container = document.createElement('div')
219
+ const app = createVobs({
220
+ render: () => KitDataTable<User>({
221
+ columns,
222
+ rows: [{ id: 1, name: 'Ada', role: 'Admin' }],
223
+ columnSettings: {
224
+ visibleColumnIds: ['role', 'name'],
225
+ columnOrder: ['role', 'name'],
226
+ columnWidths: { name: 160 }
227
+ }
228
+ })
229
+ })
230
+ app.mount(container)
231
+
232
+ expect([...container.querySelectorAll('thead th')].map(cell => cell.getAttribute('data-column-id')))
233
+ .toEqual(['role', 'name'])
234
+ expect(container.querySelector('thead th[data-column-id="name"]')?.getAttribute('style'))
235
+ .toContain('width: 160px')
236
+ expect(container.querySelector('tbody td[data-column-id="name"]')?.getAttribute('style'))
237
+ .toContain('width: 160px')
238
+ app.destroy()
239
+ })
240
+
241
+ it('使用紧凑摘要显示当前显示数、每页数、当前页和总页数', () => {
242
+ const container = document.createElement('div')
243
+ const app = createVobs({
244
+ render: () => KitDataTable<User>({
245
+ columns,
246
+ rows: [
247
+ { id: 1, name: 'Ada', role: 'Admin' },
248
+ { id: 2, name: 'Lin', role: 'Viewer' },
249
+ { id: 3, name: 'Grace', role: 'Reviewer' },
250
+ { id: 4, name: 'Margaret', role: 'Maintainer' },
251
+ { id: 5, name: 'Alan', role: 'Contributor' }
252
+ ],
253
+ total: 15,
254
+ pageSize: 10
255
+ })
256
+ })
257
+ app.mount(container)
258
+
259
+ expect(container.querySelector('.vobs-data-table__summary')?.textContent).toBe('5/10 - 1/2')
260
+ app.destroy()
261
+ })
262
+
263
+ it('按 simple、standard、all 逐步增加页码和跳转控件', () => {
264
+ const renderTable = (paginationMode: 'simple' | 'standard' | 'all') => KitDataTable<User>({
265
+ columns,
266
+ rows: Array.from({ length: 8 }, (_, id) => ({ id, name: `User ${id}`, role: 'Viewer' })),
267
+ pageSize: 1,
268
+ paginationMode
269
+ })
270
+
271
+ for (const [mode, expectedPageButtons, expectedNumberButtons, expectsJump] of [
272
+ ['simple', 2, 0, false],
273
+ ['standard', 2, 3, false],
274
+ ['all', 2, 3, true]
275
+ ] as const) {
276
+ const container = document.createElement('div')
277
+ const app = createVobs({ render: () => renderTable(mode) })
278
+ app.mount(container)
279
+
280
+ expect(container.querySelectorAll('.vobs-data-table__pagination > .vobs-data-table__page-button')).toHaveLength(expectedPageButtons)
281
+ expect(container.querySelectorAll('.vobs-data-table__pagination-pages .vobs-data-table__page-button')).toHaveLength(expectedNumberButtons)
282
+ expect(container.querySelector('.vobs-data-table__pagination-jump') !== null).toBe(expectsJump)
283
+ expect(container.querySelector('.vobs-data-table__page-size-select')).not.toBeNull()
284
+ app.destroy()
285
+ }
286
+ })
287
+
288
+ it('切换每页数时回到第 1 页并通过 query 回调通知宿主', () => {
289
+ const container = document.createElement('div')
290
+ const queries: DataTableQuery[] = []
291
+ const app = createVobs({
292
+ render: () => KitDataTable<User>({
293
+ columns,
294
+ rows: Array.from({ length: 6 }, (_, id) => ({ id, name: `User ${id}`, role: 'Viewer' })),
295
+ page: 2,
296
+ pageSize: 1,
297
+ pageSizeOptions: [1, 2, 4],
298
+ onQueryChange: query => queries.push(query)
299
+ })
300
+ })
301
+ app.mount(container)
302
+
303
+ const select = container.querySelector('.vobs-data-table__page-size-select') as HTMLSelectElement
304
+ select.value = '4'
305
+ select.dispatchEvent(new Event('change', { bubbles: true }))
306
+ expect(queries[0]).toMatchObject({ page: 1, pageSize: 4 })
307
+ app.destroy()
308
+ })
309
+
310
+ it('显式设置分页模式后,即使只有一页也保留每页数控件', () => {
311
+ const container = document.createElement('div')
312
+ const app = createVobs({
313
+ render: () => KitDataTable<User>({
314
+ columns,
315
+ rows: [{ id: 1, name: 'Ada', role: 'Admin' }],
316
+ pageSize: 10,
317
+ paginationMode: 'simple'
318
+ })
319
+ })
320
+ app.mount(container)
321
+
322
+ expect(container.querySelector('.vobs-data-table__page-size-select')).not.toBeNull()
323
+ app.destroy()
324
+ })
325
+
326
+ it('all 模式可以通过页码输入跳转', () => {
327
+ const container = document.createElement('div')
328
+ const queries: DataTableQuery[] = []
329
+ const app = createVobs({
330
+ render: () => KitDataTable<User>({
331
+ columns,
332
+ rows: Array.from({ length: 4 }, (_, id) => ({ id, name: `User ${id}`, role: 'Viewer' })),
333
+ pageSize: 1,
334
+ paginationMode: 'all',
335
+ onQueryChange: query => queries.push(query)
336
+ })
337
+ })
338
+ app.mount(container)
339
+
340
+ const input = container.querySelector('.vobs-data-table__jump-input') as HTMLInputElement
341
+ expect(input.type).toBe('text')
342
+ expect(input.inputMode).toBe('numeric')
343
+ expect(input.pattern).toBe('[0-9]*')
344
+ input.value = '3'
345
+ container.querySelector('.vobs-data-table__pagination-jump')?.dispatchEvent(
346
+ new Event('submit', { bubbles: true, cancelable: true })
347
+ )
348
+ expect(queries[queries.length - 1]?.page).toBe(3)
349
+ app.destroy()
350
+ })
351
+
352
+ it('pagination 插槽可以完全接管右侧区域并使用分页上下文', () => {
353
+ const container = document.createElement('div')
354
+ const queries: DataTableQuery[] = []
355
+ const app = createVobs({
356
+ render: () => KitDataTable<User>({
357
+ columns,
358
+ rows: Array.from({ length: 4 }, (_, id) => ({ id, name: `User ${id}`, role: 'Viewer' })),
359
+ pageSize: 1,
360
+ pagination: context => {
361
+ const button = createElement('button')
362
+ setAttribute(button, 'type', 'button')
363
+ setAttribute(button, 'data-testid', 'custom-pagination')
364
+ addEventListener(button, 'click', () => context.goToPage(2))
365
+ return button
366
+ },
367
+ onQueryChange: query => queries.push(query)
368
+ })
369
+ })
370
+ app.mount(container)
371
+
372
+ expect(container.querySelector('.vobs-data-table__pagination')).toBeNull()
373
+ ;(container.querySelector('[data-testid="custom-pagination"]') as HTMLButtonElement).click()
374
+ expect(queries[queries.length - 1]?.page).toBe(2)
375
+ app.destroy()
376
+ })
377
+
378
+ it('列设置至少保留一列,并通过回调返回可见列 id', () => {
379
+ const container = document.createElement('div')
380
+ const onChange = vi.fn()
381
+ const app = createVobs({
382
+ render: () => KitColumnSettings({ columns, visibleColumnIds: ['name'], onChange })
383
+ })
384
+ app.mount(container)
385
+ const role = container.querySelectorAll('input')[1] as HTMLInputElement
386
+ role.checked = true
387
+ role.dispatchEvent(new Event('change', { bubbles: true }))
388
+ expect(onChange).toHaveBeenCalledWith(['name', 'role'])
389
+ app.destroy()
390
+ })
391
+
392
+ it('列设置支持恢复、保存顺序和宽度,并可重置', () => {
393
+ const saved: {
394
+ visibleColumnIds: string[]
395
+ columnOrder: string[]
396
+ columnWidths: Record<string, string | number>
397
+ } = {
398
+ visibleColumnIds: ['role', 'name'],
399
+ columnOrder: ['role', 'name'],
400
+ columnWidths: { name: '160px' }
401
+ }
402
+ const persistence: DataTableColumnSettingsPersistence = {
403
+ load: vi.fn(() => saved),
404
+ save: vi.fn((value: DataTableColumnSettings) => {
405
+ saved.visibleColumnIds = [...value.visibleColumnIds]
406
+ saved.columnOrder = [...value.columnOrder]
407
+ saved.columnWidths = { ...value.columnWidths }
408
+ }),
409
+ reset: vi.fn(() => {
410
+ saved.visibleColumnIds = ['name', 'role']
411
+ saved.columnOrder = ['name', 'role']
412
+ saved.columnWidths = {}
413
+ })
414
+ }
415
+ const settingsChanges: string[][] = []
416
+ const container = document.createElement('div')
417
+ const app = createVobs({
418
+ render: () => KitColumnSettings<User>({
419
+ columns,
420
+ persistence,
421
+ onSettingsChange: value => settingsChanges.push([...value.columnOrder])
422
+ })
423
+ })
424
+ app.mount(container)
425
+
426
+ expect([...container.querySelectorAll('.vobs-column-settings__layout-label')].map(label => label.textContent))
427
+ .toEqual(['Role', 'Name'])
428
+ expect((container.querySelector(
429
+ '.vobs-column-settings__layout-option[data-column-id="name"] .vobs-column-settings__width'
430
+ ) as HTMLInputElement).value).toBe('160px')
431
+
432
+ ;(container.querySelector('.vobs-column-settings__move--up:not(:disabled)') as HTMLButtonElement).click()
433
+ expect(persistence.save).toHaveBeenCalled()
434
+ expect(settingsChanges.at(-1)).toEqual(['name', 'role'])
435
+
436
+ const width = container.querySelector(
437
+ '.vobs-column-settings__layout-option[data-column-id="name"] .vobs-column-settings__width'
438
+ ) as HTMLInputElement
439
+ width.value = '180px'
440
+ width.dispatchEvent(new Event('change', { bubbles: true }))
441
+ expect(persistence.save).toHaveBeenLastCalledWith(expect.objectContaining({
442
+ columnWidths: { name: '180px' }
443
+ }))
444
+
445
+ ;(container.querySelector('.vobs-column-settings__reset') as HTMLButtonElement).click()
446
+ expect(persistence.reset).toHaveBeenCalledOnce()
447
+ expect(settingsChanges.at(-1)).toEqual(['name', 'role'])
448
+ app.destroy()
449
+ })
450
+
451
+ it('受控列设置变化会同步到同页数据表', async () => {
452
+ const settings = state<DataTableColumnSettings>({
453
+ visibleColumnIds: ['name', 'role'],
454
+ columnOrder: ['name', 'role'],
455
+ columnWidths: {}
456
+ })
457
+ const container = document.createElement('div')
458
+ const app = createVobs({
459
+ render: () => {
460
+ const root = createElement('div')
461
+ root.append(
462
+ KitColumnSettings<User>({
463
+ columns,
464
+ get settings() { return settings.value },
465
+ onSettingsChange: value => { settings.value = value }
466
+ }) as Node,
467
+ KitDataTable<User>({
468
+ columns,
469
+ rows: [{ id: 1, name: 'Ada', role: 'Admin' }],
470
+ get columnSettings() { return settings.value }
471
+ }) as Node
472
+ )
473
+ return root
474
+ }
475
+ })
476
+ app.mount(container)
477
+
478
+ const width = container.querySelector('.vobs-column-settings__width') as HTMLInputElement
479
+ width.value = '180px'
480
+ width.dispatchEvent(new Event('change', { bubbles: true }))
481
+ await Promise.resolve()
482
+ expect(container.querySelector('thead th[data-column-id="name"]')?.getAttribute('style'))
483
+ .toContain('width: 180px')
484
+ app.destroy()
485
+ })
486
+
487
+ it('列设置持久化工厂支持存取和重置', () => {
488
+ const values = new Map<string, unknown>()
489
+ const get = vi.fn((key: string) => values.get(key) ?? null)
490
+ const set = vi.fn((key: string, value: unknown) => { values.set(key, value) })
491
+ const remove = vi.fn((key: string) => { values.delete(key) })
492
+ const storage: DataTableColumnSettingsStorage = {
493
+ get<T>(key: string): T | null {
494
+ return get(key) as T | null
495
+ },
496
+ set<T>(key: string, value: T): void {
497
+ set(key, value)
498
+ },
499
+ remove
500
+ }
501
+ const persistence = createColumnSettingsPersistence(storage, 'users.columns')
502
+ const settings = {
503
+ visibleColumnIds: ['name'],
504
+ columnOrder: ['name', 'role'],
505
+ columnWidths: { name: 160 }
506
+ }
507
+
508
+ persistence.save(settings)
509
+ expect(persistence.load()).toEqual(settings)
510
+ persistence.reset?.()
511
+ expect(persistence.load()).toBeNull()
512
+ expect(set).toHaveBeenCalledWith('users.columns', settings)
513
+ expect(remove).toHaveBeenCalledWith('users.columns')
514
+ })
515
+
516
+ it('本地持久化在损坏值和无浏览器存储时回退为空设置', () => {
517
+ const values = new Map<string, string>([['users.columns', '{bad json']])
518
+ const storage = {
519
+ getItem: (key: string) => values.get(key) ?? null,
520
+ setItem: (key: string, value: string) => { values.set(key, value) },
521
+ removeItem: (key: string) => { values.delete(key) }
522
+ }
523
+ const persistence = createLocalColumnSettingsPersistence('users.columns', storage)
524
+ expect(persistence.load()).toBeUndefined()
525
+
526
+ const settings = {
527
+ visibleColumnIds: ['name'],
528
+ columnOrder: ['name'],
529
+ columnWidths: {}
530
+ }
531
+ persistence.save(settings)
532
+ expect(persistence.load()).toEqual(settings)
533
+ persistence.reset?.()
534
+ expect(persistence.load()).toBeUndefined()
535
+ })
536
+ })