@furystack/shades-common-components 12.5.0 → 12.7.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/esm/components/data-grid/data-grid.d.ts +7 -1
  3. package/esm/components/data-grid/data-grid.d.ts.map +1 -1
  4. package/esm/components/data-grid/data-grid.js +1 -1
  5. package/esm/components/data-grid/data-grid.js.map +1 -1
  6. package/esm/components/data-grid/footer.d.ts +1 -0
  7. package/esm/components/data-grid/footer.d.ts.map +1 -1
  8. package/esm/components/data-grid/footer.js +8 -15
  9. package/esm/components/data-grid/footer.js.map +1 -1
  10. package/esm/components/data-grid/footer.spec.js +85 -47
  11. package/esm/components/data-grid/footer.spec.js.map +1 -1
  12. package/esm/components/grid.d.ts +3 -0
  13. package/esm/components/grid.d.ts.map +1 -1
  14. package/esm/components/grid.js +3 -0
  15. package/esm/components/grid.js.map +1 -1
  16. package/esm/components/inputs/autocomplete.d.ts +3 -0
  17. package/esm/components/inputs/autocomplete.d.ts.map +1 -1
  18. package/esm/components/inputs/autocomplete.js +3 -0
  19. package/esm/components/inputs/autocomplete.js.map +1 -1
  20. package/esm/components/list/list.d.ts +10 -0
  21. package/esm/components/list/list.d.ts.map +1 -1
  22. package/esm/components/list/list.js +23 -2
  23. package/esm/components/list/list.js.map +1 -1
  24. package/esm/components/list/list.spec.js +101 -0
  25. package/esm/components/list/list.spec.js.map +1 -1
  26. package/esm/components/markdown/markdown-editor.d.ts +16 -2
  27. package/esm/components/markdown/markdown-editor.d.ts.map +1 -1
  28. package/esm/components/markdown/markdown-editor.js +42 -8
  29. package/esm/components/markdown/markdown-editor.js.map +1 -1
  30. package/esm/components/markdown/markdown-editor.spec.js +190 -0
  31. package/esm/components/markdown/markdown-editor.spec.js.map +1 -1
  32. package/esm/components/markdown/markdown-input.d.ts +16 -0
  33. package/esm/components/markdown/markdown-input.d.ts.map +1 -1
  34. package/esm/components/markdown/markdown-input.js +44 -3
  35. package/esm/components/markdown/markdown-input.js.map +1 -1
  36. package/esm/components/markdown/markdown-input.spec.js +140 -0
  37. package/esm/components/markdown/markdown-input.spec.js.map +1 -1
  38. package/esm/components/markdown/markdown-validation.d.ts +25 -0
  39. package/esm/components/markdown/markdown-validation.d.ts.map +1 -0
  40. package/esm/components/markdown/markdown-validation.js +15 -0
  41. package/esm/components/markdown/markdown-validation.js.map +1 -0
  42. package/esm/components/suggest/index.d.ts +10 -2
  43. package/esm/components/suggest/index.d.ts.map +1 -1
  44. package/esm/components/suggest/index.js +21 -1
  45. package/esm/components/suggest/index.js.map +1 -1
  46. package/esm/components/suggest/index.spec.js +50 -0
  47. package/esm/components/suggest/index.spec.js.map +1 -1
  48. package/esm/components/wizard/index.d.ts +8 -0
  49. package/esm/components/wizard/index.d.ts.map +1 -1
  50. package/esm/components/wizard/index.js +90 -0
  51. package/esm/components/wizard/index.js.map +1 -1
  52. package/esm/components/wizard/index.spec.js +79 -2
  53. package/esm/components/wizard/index.spec.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/components/data-grid/data-grid.tsx +13 -2
  56. package/src/components/data-grid/footer.spec.tsx +104 -50
  57. package/src/components/data-grid/footer.tsx +25 -31
  58. package/src/components/grid.tsx +3 -0
  59. package/src/components/inputs/autocomplete.tsx +3 -0
  60. package/src/components/list/list.spec.tsx +173 -0
  61. package/src/components/list/list.tsx +56 -19
  62. package/src/components/markdown/markdown-editor.spec.tsx +261 -0
  63. package/src/components/markdown/markdown-editor.tsx +63 -10
  64. package/src/components/markdown/markdown-input.spec.tsx +205 -0
  65. package/src/components/markdown/markdown-input.tsx +61 -2
  66. package/src/components/markdown/markdown-validation.ts +33 -0
  67. package/src/components/suggest/index.spec.tsx +83 -0
  68. package/src/components/suggest/index.tsx +36 -3
  69. package/src/components/wizard/index.spec.tsx +118 -1
  70. package/src/components/wizard/index.tsx +125 -0
@@ -64,9 +64,9 @@ describe('DataGridFooter', () => {
64
64
  await flushUpdates()
65
65
 
66
66
  const footer = document.querySelector('shade-data-grid-footer')
67
- const selects = footer?.querySelectorAll('select')
67
+ const rowsPerPageSelect = footer?.querySelector('.pager-section select')
68
68
 
69
- expect(selects?.length).toBeGreaterThan(0)
69
+ expect(rowsPerPageSelect).not.toBeNull()
70
70
  })
71
71
  })
72
72
 
@@ -97,7 +97,7 @@ describe('DataGridFooter', () => {
97
97
  })
98
98
  })
99
99
 
100
- it('should show page selector when pagination is enabled', async () => {
100
+ it('should show Pagination component when pagination is enabled', async () => {
101
101
  await usingAsync(new Injector(), async (injector) => {
102
102
  const rootElement = document.getElementById('root') as HTMLDivElement
103
103
  const service = createService([], 100)
@@ -112,12 +112,12 @@ describe('DataGridFooter', () => {
112
112
  await flushUpdates()
113
113
 
114
114
  const footer = document.querySelector('shade-data-grid-footer')
115
- const pager = footer?.querySelector('.pager')
116
- expect(pager?.textContent).toContain('Page')
115
+ const pagination = footer?.querySelector('shade-pagination')
116
+ expect(pagination).not.toBeNull()
117
117
  })
118
118
  })
119
119
 
120
- it('should hide page selector when showing all items (Infinity)', async () => {
120
+ it('should hide Pagination when showing all items (Infinity)', async () => {
121
121
  await usingAsync(new Injector(), async (injector) => {
122
122
  const rootElement = document.getElementById('root') as HTMLDivElement
123
123
  const service = createService([], 50)
@@ -132,14 +132,12 @@ describe('DataGridFooter', () => {
132
132
  await flushUpdates()
133
133
 
134
134
  const footer = document.querySelector('shade-data-grid-footer')
135
- const pager = footer?.querySelector('.pager')
136
- const sections = Array.from(pager?.querySelectorAll('.pager-section') ?? [])
137
- const pageSection = sections.find((s) => s.textContent?.includes('Page'))
138
- expect(pageSection).toBeUndefined()
135
+ const pagination = footer?.querySelector('shade-pagination')
136
+ expect(pagination).toBeNull()
139
137
  })
140
138
  })
141
139
 
142
- it('should render correct number of page options based on data count and items per page', async () => {
140
+ it('should render page buttons in Pagination based on data count and items per page', async () => {
143
141
  await usingAsync(new Injector(), async (injector) => {
144
142
  const rootElement = document.getElementById('root') as HTMLDivElement
145
143
  const service = createService([], 100)
@@ -154,19 +152,17 @@ describe('DataGridFooter', () => {
154
152
  await flushUpdates()
155
153
 
156
154
  const footer = document.querySelector('shade-data-grid-footer')
157
- const selects = Array.from(footer?.querySelectorAll('select') ?? [])
158
- const pageSelect = selects.find((s) => {
159
- const parent = s.closest('.pager-section')
160
- return parent?.textContent?.includes('Page')
161
- })
155
+ const pagination = footer?.querySelector('shade-pagination')
156
+ expect(pagination).not.toBeNull()
162
157
 
163
- expect(pageSelect).toBeDefined()
164
- const options = pageSelect?.querySelectorAll('option')
165
- expect(options?.length).toBe(4)
158
+ const pageButtons = Array.from(pagination?.querySelectorAll('.pagination-item') ?? []).filter((btn) =>
159
+ btn.getAttribute('aria-label')?.startsWith('Go to page'),
160
+ )
161
+ expect(pageButtons.length).toBe(4)
166
162
  })
167
163
  })
168
164
 
169
- it('should update findOptions when page is changed', async () => {
165
+ it('should update findOptions when page is changed via Pagination', async () => {
170
166
  await usingAsync(new Injector(), async (injector) => {
171
167
  const rootElement = document.getElementById('root') as HTMLDivElement
172
168
  const service = createService([], 100)
@@ -181,21 +177,16 @@ describe('DataGridFooter', () => {
181
177
  await flushUpdates()
182
178
 
183
179
  const footer = document.querySelector('shade-data-grid-footer')
184
- const selects = Array.from(footer?.querySelectorAll('select') ?? [])
185
- const pageSelect = selects.find((s) => {
186
- const parent = s.closest('.pager-section')
187
- return parent?.textContent?.includes('Page')
188
- })
189
-
190
- expect(pageSelect).toBeDefined()
180
+ const pagination = footer?.querySelector('shade-pagination')
181
+ const nextButton = pagination?.querySelector('[aria-label="Go to next page"]') as HTMLButtonElement | null
191
182
 
192
- pageSelect!.value = '2'
193
- pageSelect!.dispatchEvent(new Event('change', { bubbles: true }))
183
+ expect(nextButton).not.toBeNull()
184
+ nextButton!.click()
194
185
 
195
186
  await flushUpdates()
196
187
 
197
188
  const updatedOptions = findOptions.getValue()
198
- expect(updatedOptions.skip).toBe(20)
189
+ expect(updatedOptions.skip).toBe(10)
199
190
  })
200
191
  })
201
192
 
@@ -266,7 +257,7 @@ describe('DataGridFooter', () => {
266
257
  })
267
258
  })
268
259
 
269
- it('should select the correct current page in the page selector', async () => {
260
+ it('should highlight the correct current page in Pagination', async () => {
270
261
  await usingAsync(new Injector(), async (injector) => {
271
262
  const rootElement = document.getElementById('root') as HTMLDivElement
272
263
  const service = createService([], 100)
@@ -281,14 +272,11 @@ describe('DataGridFooter', () => {
281
272
  await flushUpdates()
282
273
 
283
274
  const footer = document.querySelector('shade-data-grid-footer')
284
- const selects = Array.from(footer?.querySelectorAll('select') ?? [])
285
- const pageSelect = selects.find((s) => {
286
- const parent = s.closest('.pager-section')
287
- return parent?.textContent?.includes('Page')
288
- })
275
+ const pagination = footer?.querySelector('shade-pagination')
276
+ const selectedButton = pagination?.querySelector('.pagination-item[data-selected]')
289
277
 
290
- expect(pageSelect).toBeDefined()
291
- expect(pageSelect?.value).toBe('3')
278
+ expect(selectedButton).not.toBeNull()
279
+ expect(selectedButton?.textContent?.trim()).toBe('4')
292
280
  })
293
281
  })
294
282
 
@@ -321,7 +309,7 @@ describe('DataGridFooter', () => {
321
309
  it('should react to data count changes', async () => {
322
310
  await usingAsync(new Injector(), async (injector) => {
323
311
  const rootElement = document.getElementById('root') as HTMLDivElement
324
- const service = createService([], 50)
312
+ const service = createService([], 30)
325
313
  const findOptions = createFindOptions(10, 0)
326
314
 
327
315
  initializeShadeRoot({
@@ -333,26 +321,92 @@ describe('DataGridFooter', () => {
333
321
  await flushUpdates()
334
322
 
335
323
  let footer = document.querySelector('shade-data-grid-footer')
336
- let selects = Array.from(footer?.querySelectorAll('select') ?? [])
337
- let pageSelect = selects.find((s) => s.closest('.pager-section')?.textContent?.includes('Page'))
338
- let pageOptions = pageSelect?.querySelectorAll('option')
324
+ let pagination = footer?.querySelector('shade-pagination')
325
+ let pageButtons = Array.from(pagination?.querySelectorAll('.pagination-item') ?? []).filter((btn) =>
326
+ btn.getAttribute('aria-label')?.startsWith('Go to page'),
327
+ )
328
+ expect(pageButtons.length).toBe(3)
339
329
 
340
- expect(pageOptions?.length).toBe(5)
341
-
342
- service.data.setValue({ entries: [], count: 100 })
330
+ service.data.setValue({ entries: [], count: 50 })
343
331
 
344
332
  await flushUpdates()
345
333
 
346
334
  footer = document.querySelector('shade-data-grid-footer')
347
- selects = Array.from(footer?.querySelectorAll('select') ?? [])
348
- pageSelect = selects.find((s) => s.closest('.pager-section')?.textContent?.includes('Page'))
349
- pageOptions = pageSelect?.querySelectorAll('option')
350
-
351
- expect(pageOptions?.length).toBe(10)
335
+ pagination = footer?.querySelector('shade-pagination')
336
+ pageButtons = Array.from(pagination?.querySelectorAll('.pagination-item') ?? []).filter((btn) =>
337
+ btn.getAttribute('aria-label')?.startsWith('Go to page'),
338
+ )
339
+ expect(pageButtons.length).toBe(5)
352
340
  })
353
341
  })
354
342
 
355
343
  it('should export dataGridItemsPerPage constant', () => {
356
344
  expect(dataGridItemsPerPage).toEqual([10, 20, 25, 50, 100, Infinity])
357
345
  })
346
+
347
+ it('should render custom paginationOptions', async () => {
348
+ await usingAsync(new Injector(), async (injector) => {
349
+ const rootElement = document.getElementById('root') as HTMLDivElement
350
+ const service = createService()
351
+ const findOptions = createFindOptions()
352
+
353
+ initializeShadeRoot({
354
+ injector,
355
+ rootElement,
356
+ jsxElement: <DataGridFooter service={service} findOptions={findOptions} paginationOptions={[5, 15, 30]} />,
357
+ })
358
+
359
+ await flushUpdates()
360
+
361
+ const footer = document.querySelector('shade-data-grid-footer')
362
+ const itemsPerPageSelect = footer?.querySelector('.pager-section select')
363
+
364
+ expect(itemsPerPageSelect).not.toBeNull()
365
+ const options = itemsPerPageSelect?.querySelectorAll('option')
366
+ expect(options?.length).toBe(3)
367
+ expect(options?.[0]?.textContent).toBe('5')
368
+ expect(options?.[1]?.textContent).toBe('15')
369
+ expect(options?.[2]?.textContent).toBe('30')
370
+ })
371
+ })
372
+
373
+ it('should hide the rows-per-page selector when only one paginationOption is provided', async () => {
374
+ await usingAsync(new Injector(), async (injector) => {
375
+ const rootElement = document.getElementById('root') as HTMLDivElement
376
+ const service = createService([], 50)
377
+ const findOptions = createFindOptions(10, 0)
378
+
379
+ initializeShadeRoot({
380
+ injector,
381
+ rootElement,
382
+ jsxElement: <DataGridFooter service={service} findOptions={findOptions} paginationOptions={[10]} />,
383
+ })
384
+
385
+ await flushUpdates()
386
+
387
+ const footer = document.querySelector('shade-data-grid-footer')
388
+ const pagerSection = footer?.querySelector('.pager-section')
389
+ expect(pagerSection).toBeNull()
390
+ })
391
+ })
392
+
393
+ it('should use default dataGridItemsPerPage when paginationOptions is not provided', async () => {
394
+ await usingAsync(new Injector(), async (injector) => {
395
+ const rootElement = document.getElementById('root') as HTMLDivElement
396
+ const service = createService()
397
+ const findOptions = createFindOptions()
398
+
399
+ initializeShadeRoot({
400
+ injector,
401
+ rootElement,
402
+ jsxElement: <DataGridFooter service={service} findOptions={findOptions} />,
403
+ })
404
+
405
+ await flushUpdates()
406
+
407
+ const footer = document.querySelector('shade-data-grid-footer')
408
+ const options = footer?.querySelectorAll('.pager-section select option')
409
+ expect(options?.length).toBe(dataGridItemsPerPage.length)
410
+ })
411
+ })
358
412
  })
@@ -3,12 +3,14 @@ import { Shade, createComponent } from '@furystack/shades'
3
3
  import type { ObservableValue } from '@furystack/utils'
4
4
  import type { CollectionService } from '../../services/collection-service.js'
5
5
  import { cssVariableTheme } from '../../services/css-variable-theme.js'
6
+ import { Pagination } from '../pagination.js'
6
7
 
7
8
  export const dataGridItemsPerPage = [10, 20, 25, 50, 100, Infinity]
8
9
 
9
10
  export const DataGridFooter: <T>(props: {
10
11
  service: CollectionService<T>
11
12
  findOptions: ObservableValue<FindOptions<T, Array<keyof T>>>
13
+ paginationOptions?: number[]
12
14
  }) => JSX.Element = Shade({
13
15
  shadowDomName: 'shade-data-grid-footer',
14
16
  css: {
@@ -41,7 +43,7 @@ export const DataGridFooter: <T>(props: {
41
43
  },
42
44
  },
43
45
  render: ({ props, useObservable }) => {
44
- const { service, findOptions } = props
46
+ const { service, findOptions, paginationOptions = dataGridItemsPerPage } = props
45
47
  const [currentData] = useObservable('dataUpdater', service.data)
46
48
  const [currentOptions, setCurrentOptions] = useObservable('optionsUpdater', findOptions, {
47
49
  filter: (newValue, oldValue) => {
@@ -54,49 +56,41 @@ export const DataGridFooter: <T>(props: {
54
56
  const currentPage = Math.ceil(skip) / (top || 1)
55
57
  const currentEntriesPerPage = top
56
58
 
57
- const pages = new Array(Math.ceil(currentData.count / (currentOptions.top || Infinity)))
58
- .fill(0)
59
- .map((_, index) => index)
59
+ const pageCount = Math.ceil(currentData.count / (currentOptions.top || Infinity))
60
60
 
61
61
  return (
62
62
  <div className="pager">
63
- {currentEntriesPerPage !== Infinity && (
63
+ {currentEntriesPerPage !== Infinity && pageCount > 1 && (
64
+ <Pagination
65
+ count={pageCount}
66
+ page={currentPage + 1}
67
+ size="small"
68
+ onPageChange={(newPage) => {
69
+ setCurrentOptions({ ...currentOptions, skip: (currentOptions.top || 0) * (newPage - 1) })
70
+ }}
71
+ />
72
+ )}
73
+ {paginationOptions.length > 1 && (
64
74
  <div className="pager-section">
65
- <span>Page</span>
75
+ <span>Rows per page</span>
66
76
  <select
67
77
  onchange={(ev) => {
68
- const value = parseInt((ev.target as HTMLInputElement).value, 10)
69
- setCurrentOptions({ ...currentOptions, skip: (currentOptions.top || 0) * value })
78
+ const value = parseInt((ev.currentTarget as HTMLInputElement).value, 10)
79
+ setCurrentOptions({
80
+ ...currentOptions,
81
+ top: value,
82
+ skip: currentPage * value,
83
+ })
70
84
  }}
71
85
  >
72
- {pages.map((index) => (
73
- <option value={index.toString()} selected={currentPage === index}>
74
- {(index + 1).toString()}
86
+ {paginationOptions.map((no) => (
87
+ <option value={no.toString()} selected={no === currentEntriesPerPage}>
88
+ {no === Infinity ? 'All' : no.toString()}
75
89
  </option>
76
90
  ))}
77
91
  </select>
78
- <span>of {pages.length}</span>
79
92
  </div>
80
93
  )}
81
- <div className="pager-section">
82
- <span>Rows per page</span>
83
- <select
84
- onchange={(ev) => {
85
- const value = parseInt((ev.currentTarget as HTMLInputElement).value, 10)
86
- setCurrentOptions({
87
- ...currentOptions,
88
- top: value,
89
- skip: currentPage * value,
90
- })
91
- }}
92
- >
93
- {dataGridItemsPerPage.map((no) => (
94
- <option value={no.toString()} selected={no === currentEntriesPerPage}>
95
- {no === Infinity ? 'All' : no.toString()}
96
- </option>
97
- ))}
98
- </select>
99
- </div>
100
94
  </div>
101
95
  )
102
96
  },
@@ -23,6 +23,9 @@ export type RowCells<T, Columns extends string> = {
23
23
  [TKey in Columns | 'default']?: (element: T, column: Columns) => JSX.Element
24
24
  }
25
25
 
26
+ /**
27
+ * @deprecated Use `DataGrid` instead. This component will be removed in a future version.
28
+ */
26
29
  export const Grid: <T, Column extends string>(props: GridProps<T, Column>, children: ChildrenList) => JSX.Element<any> =
27
30
  Shade({
28
31
  shadowDomName: 'shade-grid',
@@ -2,6 +2,9 @@ import { Shade, createComponent } from '@furystack/shades'
2
2
  import type { TextInputProps } from './input.js'
3
3
  import { Input } from './input.js'
4
4
 
5
+ /**
6
+ * @deprecated Use `Suggest` with the `suggestions` prop instead. This component will be removed in a future version.
7
+ */
5
8
  export const Autocomplete = Shade<{
6
9
  inputProps?: TextInputProps
7
10
  suggestions: string[]
@@ -783,6 +783,179 @@ describe('List', () => {
783
783
  })
784
784
  })
785
785
 
786
+ describe('pagination', () => {
787
+ const manyItems: TestItem[] = Array.from({ length: 25 }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}` }))
788
+
789
+ it('should render only current page items when pagination is provided', async () => {
790
+ await usingAsync(new Injector(), async (injector) => {
791
+ const rootElement = document.getElementById('root') as HTMLDivElement
792
+ const service = createTestService()
793
+
794
+ initializeShadeRoot({
795
+ injector,
796
+ rootElement,
797
+ jsxElement: (
798
+ <List<TestItem>
799
+ items={manyItems}
800
+ listService={service}
801
+ renderItem={(item) => <span>{item.name}</span>}
802
+ pagination={{ itemsPerPage: 10, page: 1, onPageChange: () => {} }}
803
+ />
804
+ ),
805
+ })
806
+
807
+ await flushUpdates()
808
+
809
+ const list = document.querySelector('shade-list')
810
+ const listItems = list?.querySelectorAll('shade-list-item')
811
+ expect(listItems?.length).toBe(10)
812
+
813
+ service[Symbol.dispose]()
814
+ })
815
+ })
816
+
817
+ it('should render the Pagination component', async () => {
818
+ await usingAsync(new Injector(), async (injector) => {
819
+ const rootElement = document.getElementById('root') as HTMLDivElement
820
+ const service = createTestService()
821
+
822
+ initializeShadeRoot({
823
+ injector,
824
+ rootElement,
825
+ jsxElement: (
826
+ <List<TestItem>
827
+ items={manyItems}
828
+ listService={service}
829
+ renderItem={(item) => <span>{item.name}</span>}
830
+ pagination={{ itemsPerPage: 10, page: 1, onPageChange: () => {} }}
831
+ />
832
+ ),
833
+ })
834
+
835
+ await flushUpdates()
836
+
837
+ const pagination = document.querySelector('shade-list shade-pagination')
838
+ expect(pagination).not.toBeNull()
839
+
840
+ service[Symbol.dispose]()
841
+ })
842
+ })
843
+
844
+ it('should show last page items correctly', async () => {
845
+ await usingAsync(new Injector(), async (injector) => {
846
+ const rootElement = document.getElementById('root') as HTMLDivElement
847
+ const service = createTestService()
848
+
849
+ initializeShadeRoot({
850
+ injector,
851
+ rootElement,
852
+ jsxElement: (
853
+ <List<TestItem>
854
+ items={manyItems}
855
+ listService={service}
856
+ renderItem={(item) => <span>{item.name}</span>}
857
+ pagination={{ itemsPerPage: 10, page: 3, onPageChange: () => {} }}
858
+ />
859
+ ),
860
+ })
861
+
862
+ await flushUpdates()
863
+
864
+ const list = document.querySelector('shade-list')
865
+ const listItems = list?.querySelectorAll('shade-list-item')
866
+ expect(listItems?.length).toBe(5)
867
+
868
+ service[Symbol.dispose]()
869
+ })
870
+ })
871
+
872
+ it('should not render Pagination when all items fit on one page', async () => {
873
+ await usingAsync(new Injector(), async (injector) => {
874
+ const rootElement = document.getElementById('root') as HTMLDivElement
875
+ const service = createTestService()
876
+
877
+ initializeShadeRoot({
878
+ injector,
879
+ rootElement,
880
+ jsxElement: (
881
+ <List<TestItem>
882
+ items={testItems}
883
+ listService={service}
884
+ renderItem={(item) => <span>{item.name}</span>}
885
+ pagination={{ itemsPerPage: 10, page: 1, onPageChange: () => {} }}
886
+ />
887
+ ),
888
+ })
889
+
890
+ await flushUpdates()
891
+
892
+ const pagination = document.querySelector('shade-list shade-pagination')
893
+ expect(pagination).toBeNull()
894
+
895
+ service[Symbol.dispose]()
896
+ })
897
+ })
898
+
899
+ it('should call onPageChange when a pagination button is clicked', async () => {
900
+ const onPageChange = vi.fn()
901
+ await usingAsync(new Injector(), async (injector) => {
902
+ const rootElement = document.getElementById('root') as HTMLDivElement
903
+ const service = createTestService()
904
+
905
+ initializeShadeRoot({
906
+ injector,
907
+ rootElement,
908
+ jsxElement: (
909
+ <List<TestItem>
910
+ items={manyItems}
911
+ listService={service}
912
+ renderItem={(item) => <span>{item.name}</span>}
913
+ pagination={{ itemsPerPage: 10, page: 1, onPageChange }}
914
+ />
915
+ ),
916
+ })
917
+
918
+ await flushUpdates()
919
+
920
+ const nextButton = document.querySelector(
921
+ 'shade-list shade-pagination [aria-label="Go to next page"]',
922
+ ) as HTMLButtonElement
923
+ expect(nextButton).not.toBeNull()
924
+ nextButton.click()
925
+
926
+ expect(onPageChange).toHaveBeenCalledWith(2)
927
+
928
+ service[Symbol.dispose]()
929
+ })
930
+ })
931
+
932
+ it('should render all items when pagination is not provided', async () => {
933
+ await usingAsync(new Injector(), async (injector) => {
934
+ const rootElement = document.getElementById('root') as HTMLDivElement
935
+ const service = createTestService()
936
+
937
+ initializeShadeRoot({
938
+ injector,
939
+ rootElement,
940
+ jsxElement: (
941
+ <List<TestItem> items={manyItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
942
+ ),
943
+ })
944
+
945
+ await flushUpdates()
946
+
947
+ const list = document.querySelector('shade-list')
948
+ const listItems = list?.querySelectorAll('shade-list-item')
949
+ expect(listItems?.length).toBe(25)
950
+
951
+ const pagination = document.querySelector('shade-list shade-pagination')
952
+ expect(pagination).toBeNull()
953
+
954
+ service[Symbol.dispose]()
955
+ })
956
+ })
957
+ })
958
+
786
959
  describe('keyboard listener cleanup', () => {
787
960
  it('should remove keyboard listener when component is disconnected', async () => {
788
961
  await usingAsync(new Injector(), async (injector) => {
@@ -2,6 +2,7 @@ import type { ChildrenList, PartialElement } from '@furystack/shades'
2
2
  import { createComponent, Shade } from '@furystack/shades'
3
3
  import { ClickAwayService } from '../../services/click-away-service.js'
4
4
  import type { ListService } from '../../services/list-service.js'
5
+ import { Pagination } from '../pagination.js'
5
6
  import { ListItem } from './list-item.js'
6
7
 
7
8
  export type ListItemState = {
@@ -9,6 +10,15 @@ export type ListItemState = {
9
10
  isSelected: boolean
10
11
  }
11
12
 
13
+ export type ListPaginationProps = {
14
+ /** Number of items to display per page */
15
+ itemsPerPage: number
16
+ /** Current page (1-indexed) */
17
+ page: number
18
+ /** Callback fired when the page changes */
19
+ onPageChange: (page: number) => void
20
+ }
21
+
12
22
  export type ListProps<T> = {
13
23
  items: T[]
14
24
  listService: ListService<T>
@@ -18,6 +28,8 @@ export type ListProps<T> = {
18
28
  variant?: 'contained' | 'outlined'
19
29
  onItemActivate?: (item: T) => void
20
30
  onSelectionChange?: (selected: T[]) => void
31
+ /** Optional pagination configuration. When provided, items are sliced and a Pagination control is rendered. */
32
+ pagination?: ListPaginationProps
21
33
  } & PartialElement<HTMLDivElement>
22
34
 
23
35
  export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Element<any> = Shade({
@@ -26,6 +38,11 @@ export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Eleme
26
38
  display: 'block',
27
39
  width: '100%',
28
40
  overflow: 'auto',
41
+ '& .shade-list-pagination': {
42
+ display: 'flex',
43
+ justifyContent: 'center',
44
+ padding: '8px 0',
45
+ },
29
46
  },
30
47
  render: ({ props, useDisposable, useHostProps, useRef }) => {
31
48
  const wrapperRef = useRef<HTMLDivElement>('listWrapper')
@@ -45,7 +62,20 @@ export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Eleme
45
62
  return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener) }
46
63
  })
47
64
 
48
- props.listService.items.setValue(props.items)
65
+ const { pagination } = props
66
+ let visibleItems: typeof props.items
67
+ let pageCount = 1
68
+
69
+ if (pagination) {
70
+ const { itemsPerPage, page } = pagination
71
+ pageCount = Math.ceil(props.items.length / itemsPerPage)
72
+ const startIndex = (page - 1) * itemsPerPage
73
+ visibleItems = props.items.slice(startIndex, startIndex + itemsPerPage)
74
+ } else {
75
+ visibleItems = props.items
76
+ }
77
+
78
+ props.listService.items.setValue(visibleItems)
49
79
 
50
80
  useDisposable(
51
81
  'clickAway',
@@ -69,24 +99,31 @@ export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Eleme
69
99
  })
70
100
 
71
101
  return (
72
- <div
73
- ref={wrapperRef}
74
- role="listbox"
75
- ariaMultiSelectable="true"
76
- className="shade-list-wrapper"
77
- onclick={() => props.listService.hasFocus.setValue(true)}
78
- >
79
- {props.items.map((item) => (
80
- <ListItem
81
- item={item}
82
- listService={props.listService}
83
- renderItem={props.renderItem}
84
- renderIcon={props.renderIcon}
85
- renderSecondaryActions={props.renderSecondaryActions}
86
- onActivate={props.onItemActivate}
87
- />
88
- ))}
89
- </div>
102
+ <>
103
+ <div
104
+ ref={wrapperRef}
105
+ role="listbox"
106
+ ariaMultiSelectable="true"
107
+ className="shade-list-wrapper"
108
+ onclick={() => props.listService.hasFocus.setValue(true)}
109
+ >
110
+ {visibleItems.map((item) => (
111
+ <ListItem
112
+ item={item}
113
+ listService={props.listService}
114
+ renderItem={props.renderItem}
115
+ renderIcon={props.renderIcon}
116
+ renderSecondaryActions={props.renderSecondaryActions}
117
+ onActivate={props.onItemActivate}
118
+ />
119
+ ))}
120
+ </div>
121
+ {pagination && pageCount > 1 && (
122
+ <div className="shade-list-pagination">
123
+ <Pagination count={pageCount} page={pagination.page} onPageChange={pagination.onPageChange} size="small" />
124
+ </div>
125
+ )}
126
+ </>
90
127
  )
91
128
  },
92
129
  })