@genspectrum/dashboard-components 0.17.0 → 0.18.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 (50) hide show
  1. package/custom-elements.json +47 -16
  2. package/dist/components.d.ts +21 -17
  3. package/dist/components.js +426 -3654
  4. package/dist/components.js.map +1 -1
  5. package/dist/style.css +2 -3375
  6. package/dist/util.d.ts +15 -15
  7. package/package.json +6 -5
  8. package/src/preact/aggregatedData/aggregate.tsx +3 -3
  9. package/src/preact/components/clearable-select.tsx +1 -1
  10. package/src/preact/components/color-scale-selector-dropdown.tsx +1 -1
  11. package/src/preact/components/confidence-interval-selector.tsx +1 -1
  12. package/src/preact/components/downshift-combobox.tsx +3 -3
  13. package/src/preact/components/fullscreen.tsx +6 -2
  14. package/src/preact/components/info.tsx +1 -1
  15. package/src/preact/components/mutation-type-selector.tsx +1 -1
  16. package/src/preact/components/percent-intput.tsx +3 -3
  17. package/src/preact/components/proportion-selector-dropdown.tsx +1 -1
  18. package/src/preact/components/scaling-selector.tsx +1 -1
  19. package/src/preact/components/select.tsx +1 -1
  20. package/src/preact/components/tabs.tsx +1 -1
  21. package/src/preact/dateRangeFilter/date-picker.tsx +1 -1
  22. package/src/preact/dateRangeFilter/date-range-filter.tsx +4 -4
  23. package/src/preact/mutationComparison/mutation-comparison.tsx +1 -1
  24. package/src/preact/mutations/mutations.tsx +2 -2
  25. package/src/preact/mutationsOverTime/mutations-over-time-grid.tsx +133 -84
  26. package/src/preact/mutationsOverTime/mutations-over-time.stories.tsx +46 -16
  27. package/src/preact/mutationsOverTime/mutations-over-time.tsx +4 -1
  28. package/src/preact/numberSequencesOverTime/number-sequences-over-time.tsx +1 -1
  29. package/src/preact/prevalenceOverTime/prevalence-over-time.tsx +1 -1
  30. package/src/preact/sequencesByLocation/sequences-by-location-map.tsx +1 -1
  31. package/src/preact/sequencesByLocation/sequences-by-location.tsx +3 -7
  32. package/src/preact/shared/tanstackTable/pagination.tsx +132 -0
  33. package/src/preact/shared/tanstackTable/tanstackTable.tsx +43 -0
  34. package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.stories.tsx +2 -1
  35. package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.tsx +3 -5
  36. package/src/styles/tailwind.css +14 -3
  37. package/src/web-components/input/gs-date-range-filter.stories.ts +2 -2
  38. package/src/web-components/input/gs-lineage-filter.stories.ts +1 -1
  39. package/src/web-components/input/gs-location-filter.stories.ts +1 -1
  40. package/src/web-components/input/gs-mutation-filter.stories.ts +1 -1
  41. package/src/web-components/input/gs-text-filter.stories.ts +1 -1
  42. package/src/web-components/visualization/gs-mutations-over-time.spec-d.ts +39 -0
  43. package/src/web-components/visualization/gs-mutations-over-time.stories.ts +4 -0
  44. package/src/web-components/visualization/gs-mutations-over-time.tsx +8 -31
  45. package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.spec-d.ts +24 -0
  46. package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.stories.ts +3 -3
  47. package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.tsx +5 -36
  48. package/standalone-bundle/dashboard-components.js +17318 -15507
  49. package/standalone-bundle/dashboard-components.js.map +1 -1
  50. package/standalone-bundle/style.css +1 -1
@@ -1,5 +1,6 @@
1
- import { Fragment, type FunctionComponent } from 'preact';
2
- import { useRef } from 'preact/hooks';
1
+ import { type PaginationState } from '@tanstack/table-core';
2
+ import { type FunctionComponent } from 'preact';
3
+ import { useMemo, useState } from 'preact/hooks';
3
4
 
4
5
  import { type MutationOverTimeDataMap } from './MutationOverTimeData';
5
6
  import { type MutationOverTimeMutationValue } from '../../query/queryMutationsOverTime';
@@ -10,108 +11,154 @@ import { AnnotatedMutation } from '../components/annotated-mutation';
10
11
  import { type ColorScale, getColorWithingScale, getTextColorForScale } from '../components/color-scale-selector';
11
12
  import Tooltip, { type TooltipPosition } from '../components/tooltip';
12
13
  import { formatProportion } from '../shared/table/formatProportion';
14
+ import { type PageSizes, Pagination } from '../shared/tanstackTable/pagination';
15
+ import {
16
+ createColumnHelper,
17
+ flexRender,
18
+ getCoreRowModel,
19
+ getPaginationRowModel,
20
+ usePreactTable,
21
+ } from '../shared/tanstackTable/tanstackTable';
13
22
 
14
23
  export interface MutationsOverTimeGridProps {
15
24
  data: MutationOverTimeDataMap;
16
25
  colorScale: ColorScale;
17
- maxNumberOfGridRows?: number;
18
26
  sequenceType: SequenceType;
27
+ pageSizes: PageSizes;
19
28
  }
20
29
 
21
- const MAX_NUMBER_OF_GRID_ROWS = 100;
22
- const MUTATION_CELL_WIDTH_REM = 8;
30
+ type RowType = { mutation: Substitution | Deletion; values: (MutationOverTimeMutationValue | undefined)[] };
23
31
 
24
32
  const MutationsOverTimeGrid: FunctionComponent<MutationsOverTimeGridProps> = ({
25
33
  data,
26
34
  colorScale,
27
- maxNumberOfGridRows,
28
35
  sequenceType,
36
+ pageSizes,
29
37
  }) => {
30
- const currentMaxNumberOfGridRows = maxNumberOfGridRows ?? MAX_NUMBER_OF_GRID_ROWS;
31
- const allMutations = data.getFirstAxisKeys();
32
- const shownMutations = allMutations.slice(0, currentMaxNumberOfGridRows);
33
-
34
- const dates = data.getSecondAxisKeys();
35
-
36
- const gridRef = useRef<HTMLDivElement>(null);
38
+ const tableData = useMemo(() => {
39
+ const allMutations = data.getFirstAxisKeys();
40
+ return data.getAsArray().map((row, index) => {
41
+ return { mutation: allMutations[index], values: [...row] };
42
+ });
43
+ }, [data]);
44
+
45
+ const [pagination, setPagination] = useState<PaginationState>({
46
+ pageIndex: 0,
47
+ pageSize: typeof pageSizes === 'number' ? pageSizes : (pageSizes.at(0) ?? 10),
48
+ });
49
+
50
+ const columns = useMemo(() => {
51
+ const columnHelper = createColumnHelper<RowType>();
52
+ const dates = data.getSecondAxisKeys();
53
+
54
+ const mutationHeader = columnHelper.accessor((row) => row.mutation, {
55
+ id: 'mutation',
56
+ header: () => <span>Mutation</span>,
57
+ cell: ({ getValue }) => {
58
+ const value = getValue();
59
+ return (
60
+ <div className={'text-center'}>
61
+ <AnnotatedMutation mutation={value} sequenceType={sequenceType} />
62
+ </div>
63
+ );
64
+ },
65
+ });
66
+
67
+ const dateHeaders = dates.map((date, index) => {
68
+ return columnHelper.accessor((row) => row.values[index], {
69
+ id: `date-${index}`,
70
+ header: () => (
71
+ <div className='@container min-w-[0.05rem]'>
72
+ <p {...styleGridHeader(index, dates.length)}>{date.dateString}</p>
73
+ </div>
74
+ ),
75
+ cell: ({ getValue, row, column, table }) => {
76
+ const value = getValue();
77
+ const rowIndex = row.index;
78
+ const columnIndex = column.getIndex();
79
+ const numberOfRows = table.getRowModel().rows.length;
80
+ const numberOfColumns = table.getAllColumns().length;
81
+
82
+ return (
83
+ <div className={'text-center'}>
84
+ <ProportionCell
85
+ value={value ?? null}
86
+ date={date}
87
+ mutation={row.original.mutation}
88
+ tooltipPosition={getTooltipPosition(
89
+ rowIndex -
90
+ table.getState().pagination.pageIndex * table.getState().pagination.pageSize,
91
+ numberOfRows,
92
+ columnIndex,
93
+ numberOfColumns,
94
+ )}
95
+ colorScale={colorScale}
96
+ />
97
+ </div>
98
+ );
99
+ },
100
+ });
101
+ });
102
+
103
+ return [mutationHeader, ...dateHeaders];
104
+ }, [colorScale, data, sequenceType]);
105
+
106
+ const table = usePreactTable({
107
+ data: tableData,
108
+ columns,
109
+ getCoreRowModel: getCoreRowModel(),
110
+ getPaginationRowModel: getPaginationRowModel(),
111
+ debugTable: true,
112
+ onPaginationChange: setPagination,
113
+ state: {
114
+ pagination,
115
+ },
116
+ });
37
117
 
38
118
  return (
39
- <>
40
- {allMutations.length > currentMaxNumberOfGridRows && (
41
- <div className='pl-2'>
42
- Showing {currentMaxNumberOfGridRows} of {allMutations.length} mutations. You can narrow the filter
43
- to reduce the number of mutations.
44
- </div>
45
- )}
46
- {allMutations.length === 0 ? (
47
- <div className={'flex justify-center'}>No data available for your filters.</div>
48
- ) : (
49
- <div
50
- ref={gridRef}
51
- style={{
52
- display: 'grid',
53
- gridTemplateRows: `repeat(${shownMutations.length}, 24px)`,
54
- gridTemplateColumns: `${MUTATION_CELL_WIDTH_REM}rem repeat(${dates.length}, minmax(0.05rem, 1fr))`,
55
- }}
56
- className='text-center'
57
- >
58
- {dates.map((date, columnIndex) => (
59
- <div
60
- className='@container font-semibold'
61
- style={{ gridRowStart: 1, gridColumnStart: columnIndex + 2 }}
62
- key={date.dateString}
63
- >
64
- <p {...styleGridHeader(columnIndex, dates)}>{date.dateString}</p>
65
- </div>
119
+ <div className='w-full'>
120
+ <table className={'w-full'}>
121
+ <thead>
122
+ {table.getHeaderGroups().map((headerGroup) => (
123
+ <tr key={headerGroup.id}>
124
+ {headerGroup.headers.map((header) => (
125
+ <th key={header.id} colSpan={header.colSpan} style={{ width: `${header.getSize()}px` }}>
126
+ {header.isPlaceholder
127
+ ? null
128
+ : flexRender(header.column.columnDef.header, header.getContext())}
129
+ </th>
130
+ ))}
131
+ </tr>
66
132
  ))}
67
- {shownMutations.map((mutation, rowIndex) => {
68
- return (
69
- <Fragment key={`fragment-${mutation.code}`}>
70
- <div
71
- key={`mutation-${mutation.code}`}
72
- style={{ gridRowStart: rowIndex + 2, gridColumnStart: 1 }}
73
- className='flex items-center justify-center'
74
- >
75
- <AnnotatedMutation mutation={mutation} sequenceType={sequenceType} />
76
- </div>
77
- {dates.map((date, columnIndex) => {
78
- const value = data.get(mutation, date) ?? null;
79
- const tooltipPosition = getTooltipPosition(
80
- rowIndex,
81
- shownMutations.length,
82
- columnIndex,
83
- dates.length,
84
- );
85
- return (
86
- <div
87
- style={{ gridRowStart: rowIndex + 2, gridColumnStart: columnIndex + 2 }}
88
- key={`${mutation.code}-${date.dateString}`}
89
- >
90
- <ProportionCell
91
- value={value}
92
- date={date}
93
- mutation={mutation}
94
- tooltipPosition={tooltipPosition}
95
- colorScale={colorScale}
96
- />
97
- </div>
98
- );
99
- })}
100
- </Fragment>
101
- );
102
- })}
103
- </div>
104
- )}
105
- </>
133
+ </thead>
134
+ <tbody>
135
+ {table.getRowModel().rows.map((row) => (
136
+ <tr key={row.id}>
137
+ {row.getVisibleCells().map((cell) => (
138
+ <td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
139
+ ))}
140
+ </tr>
141
+ ))}
142
+ {table.getRowModel().rows.length === 0 && (
143
+ <td colSpan={table.getFlatHeaders().length}>
144
+ <div className={'text-center'}>No data available for your filters.</div>
145
+ </td>
146
+ )}
147
+ </tbody>
148
+ </table>
149
+ <div className={'mt-2'}>
150
+ <Pagination table={table} pageSizes={pageSizes} />
151
+ </div>
152
+ </div>
106
153
  );
107
154
  };
108
155
 
109
- function styleGridHeader(columnIndex: number, dates: Temporal[]) {
156
+ function styleGridHeader(columnIndex: number, numDateColumns: number) {
110
157
  if (columnIndex === 0) {
111
158
  return { className: 'overflow-visible text-nowrap' };
112
159
  }
113
160
 
114
- if (columnIndex === dates.length - 1) {
161
+ if (columnIndex === numDateColumns - 1) {
115
162
  return { className: 'overflow-visible text-nowrap', style: { direction: 'rtl' } };
116
163
  }
117
164
 
@@ -168,9 +215,11 @@ const ProportionCell: FunctionComponent<{
168
215
  }}
169
216
  className={`w-full h-full hover:font-bold text-xs group @container`}
170
217
  >
171
- <span className='invisible @[2rem]:visible'>
172
- {value === null ? '' : formatProportion(value.proportion, 0)}
173
- </span>
218
+ {value === null ? (
219
+ <span className={'invisible'}>No data</span>
220
+ ) : (
221
+ <span className='invisible @[2rem]:visible'>{formatProportion(value.proportion, 0)}</span>
222
+ )}
174
223
  </div>
175
224
  </Tooltip>
176
225
  </div>
@@ -1,5 +1,6 @@
1
1
  import { type Meta, type StoryObj } from '@storybook/preact';
2
2
  import { expect, userEvent, waitFor } from '@storybook/test';
3
+ import { type Canvas } from '@storybook/types';
3
4
 
4
5
  import { MutationsOverTime, type MutationsOverTimeProps } from './mutations-over-time';
5
6
  import { LAPIS_URL } from '../../constants';
@@ -32,6 +33,7 @@ const meta: Meta<MutationsOverTimeProps> = {
32
33
  lapisDateField: { control: 'text' },
33
34
  displayMutations: { control: 'object' },
34
35
  initialMeanProportionInterval: { control: 'object' },
36
+ pageSizes: { control: 'object' },
35
37
  },
36
38
  parameters: {
37
39
  fetchMock: {},
@@ -75,42 +77,67 @@ export const Default: StoryObj<MutationsOverTimeProps> = {
75
77
  granularity: 'month',
76
78
  lapisDateField: 'date',
77
79
  initialMeanProportionInterval: { min: 0.05, max: 0.9 },
80
+ pageSizes: [10, 20, 30, 40, 50],
78
81
  },
79
82
  play: async ({ canvasElement }) => {
80
83
  await expectMutationAnnotation(canvasElement, 'C44T');
81
84
  },
82
85
  };
83
86
 
84
- // This test uses mock data: showMessagWhenTooManyMutations.ts (through mutationOverTimeWorker.mock.ts)
85
- export const ShowsMessageWhenTooManyMutations: StoryObj<MutationsOverTimeProps> = {
87
+ export const ShowsNoDataWhenNoMutationsAreInFilter: StoryObj<MutationsOverTimeProps> = {
86
88
  ...Default,
87
89
  args: {
88
90
  ...Default.args,
89
- lapisFilter: { dateFrom: '2023-01-01', dateTo: '2023-12-31' },
91
+ lapisFilter: { dateFrom: '1800-01-01', dateTo: '1800-01-02' },
92
+ height: '700px',
90
93
  granularity: 'year',
91
94
  },
92
95
  play: async ({ canvas }) => {
93
- await waitFor(() => expect(canvas.getByText('Showing 100 of 137 mutations.', { exact: false })).toBeVisible(), {
96
+ await waitFor(() => expect(canvas.getByText('No data available.', { exact: false })).toBeVisible(), {
94
97
  timeout: 10000,
95
98
  });
96
99
  },
97
100
  };
98
101
 
99
- export const ShowsNoDataWhenNoMutationsAreInFilter: StoryObj<MutationsOverTimeProps> = {
102
+ export const UsesPagination: StoryObj<MutationsOverTimeProps> = {
100
103
  ...Default,
101
- args: {
102
- ...Default.args,
103
- lapisFilter: { dateFrom: '1800-01-01', dateTo: '1800-01-02' },
104
- height: '700px',
105
- granularity: 'year',
106
- },
107
- play: async ({ canvas }) => {
108
- await waitFor(() => expect(canvas.getByText('No data available.', { exact: false })).toBeVisible(), {
109
- timeout: 10000,
104
+ play: async ({ canvas, step }) => {
105
+ const mutationOnFirstPage = 'C44T';
106
+ const mutationOnSecondPage = 'T21653-';
107
+ await expectMutationOnPage(canvas, mutationOnFirstPage);
108
+
109
+ await step('Navigate to next page', async () => {
110
+ canvas.getByRole('button', { name: 'Next page' }).click();
111
+
112
+ await expectMutationOnPage(canvas, mutationOnSecondPage);
113
+ });
114
+
115
+ await step('Use goto page input', async () => {
116
+ const gotoPageInput = canvas.getByRole('spinbutton', { name: 'Enter page number to go to' });
117
+ await userEvent.clear(gotoPageInput);
118
+ await userEvent.type(gotoPageInput, '1');
119
+ await userEvent.tab();
120
+
121
+ await expectMutationOnPage(canvas, mutationOnFirstPage);
122
+ });
123
+
124
+ await step('Change number of rows per page', async () => {
125
+ const pageSizeSelector = canvas.getByLabelText('Select number of rows per page');
126
+ await userEvent.selectOptions(pageSizeSelector, '20');
127
+
128
+ await expectMutationOnPage(canvas, mutationOnFirstPage);
129
+ await expectMutationOnPage(canvas, mutationOnSecondPage);
110
130
  });
111
131
  },
112
132
  };
113
133
 
134
+ async function expectMutationOnPage(canvas: Canvas, mutation: string) {
135
+ await waitFor(async () => {
136
+ const mutationOnFirstPage = canvas.getAllByText(mutation)[0];
137
+ await expect(mutationOnFirstPage).toBeVisible();
138
+ });
139
+ }
140
+
114
141
  export const ShowsNoDataMessageWhenThereAreNoDatesInFilter: StoryObj<MutationsOverTimeProps> = {
115
142
  ...Default,
116
143
  args: {
@@ -162,8 +189,11 @@ export const ShowsNoDataForStrictInitialProportionInterval: StoryObj<MutationsOv
162
189
  initialMeanProportionInterval: { min: 0.4, max: 0.41 },
163
190
  },
164
191
  play: async ({ canvas }) => {
165
- await waitFor(() =>
166
- expect(canvas.getByText('No data available for your filters.', { exact: false })).toBeVisible(),
192
+ await waitFor(
193
+ () => expect(canvas.getByText('No data available for your filters.', { exact: false })).toBeVisible(),
194
+ {
195
+ timeout: 10000,
196
+ },
167
197
  );
168
198
  },
169
199
  };
@@ -33,6 +33,7 @@ import { ProportionSelectorDropdown } from '../components/proportion-selector-dr
33
33
  import { ResizeContainer } from '../components/resize-container';
34
34
  import { type DisplayedSegment, SegmentSelector, useDisplayedSegments } from '../components/segment-selector';
35
35
  import Tabs from '../components/tabs';
36
+ import { pageSizesSchema } from '../shared/tanstackTable/pagination';
36
37
  import { useWebWorker } from '../webWorkers/useWebWorker';
37
38
 
38
39
  const mutationsOverTimeViewSchema = z.literal(views.grid);
@@ -51,6 +52,7 @@ const mutationOverTimeSchema = z.object({
51
52
  }),
52
53
  width: z.string(),
53
54
  height: z.string().optional(),
55
+ pageSizes: pageSizesSchema,
54
56
  });
55
57
  export type MutationsOverTimeProps = z.infer<typeof mutationOverTimeSchema>;
56
58
 
@@ -166,6 +168,7 @@ const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
166
168
  data={filteredData}
167
169
  colorScale={colorScale}
168
170
  sequenceType={originalComponentProps.sequenceType}
171
+ pageSizes={originalComponentProps.pageSizes}
169
172
  />
170
173
  ),
171
174
  };
@@ -237,7 +240,7 @@ const Toolbar: FunctionComponent<ToolbarProps> = ({
237
240
  labelPrefix='Mean proportion'
238
241
  />
239
242
  <CsvDownloadButton
240
- className='mx-1 btn btn-xs'
243
+ className='btn btn-xs'
241
244
  getData={() => getDownloadData(filteredData)}
242
245
  filename='mutations_over_time.csv'
243
246
  />
@@ -167,7 +167,7 @@ const Toolbar = ({ activeTab, data, yAxisScaleType, setYAxisScaleType, originalC
167
167
  />
168
168
  )}
169
169
  <CsvDownloadButton
170
- className='mx-1 btn btn-xs'
170
+ className='btn btn-xs'
171
171
  getData={() => getNumberOfSequencesOverTimeTableData(data, originalComponentProps.granularity)}
172
172
  filename='number_of_sequences_over_time.csv'
173
173
  />
@@ -218,7 +218,7 @@ const Toolbar: FunctionComponent<ToolbarProps> = ({
218
218
  />
219
219
  )}
220
220
  <CsvDownloadButton
221
- className='mx-1 btn btn-xs'
221
+ className='btn btn-xs'
222
222
  getData={() => getPrevalenceOverTimeTableData(data, granularity)}
223
223
  filename='prevalence_over_time.csv'
224
224
  />
@@ -106,7 +106,7 @@ const DataMatchInformation: FunctionComponent<DataMatchInformationProps> = ({
106
106
 
107
107
  return (
108
108
  <Modal
109
- buttonClassName='text-sm absolute bottom-0 px-1 z-[1001] bg-white rounded border'
109
+ buttonClassName='text-sm absolute bottom-0 px-1 z-1001 bg-white rounded-sm border border-gray-200'
110
110
  modalContent={
111
111
  <>
112
112
  <InfoHeadline1>Sequences By Location - Map View</InfoHeadline1>
@@ -149,15 +149,11 @@ type ToolbarProps = {
149
149
 
150
150
  const Toolbar: FunctionComponent<ToolbarProps> = ({ originalComponentProps, tableData }) => {
151
151
  return (
152
- <div class='flex flex-row'>
153
- <CsvDownloadButton
154
- className='mx-1 btn btn-xs'
155
- getData={() => tableData}
156
- filename='sequences_by_location.csv'
157
- />
152
+ <>
153
+ <CsvDownloadButton className='btn btn-xs' getData={() => tableData} filename='sequences_by_location.csv' />
158
154
  <SequencesByLocationMapInfo originalComponentProps={originalComponentProps} />
159
155
  <Fullscreen />
160
- </div>
156
+ </>
161
157
  );
162
158
  };
163
159
 
@@ -0,0 +1,132 @@
1
+ import type { Table } from '@tanstack/table-core';
2
+ import z from 'zod';
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ type PaginationProps = { table: Table<any> };
6
+ export const pageSizesSchema = z.union([z.array(z.number()), z.number()]);
7
+ export type PageSizes = z.infer<typeof pageSizesSchema>;
8
+
9
+ export function Pagination({
10
+ table,
11
+ pageSizes,
12
+ }: PaginationProps & {
13
+ pageSizes: PageSizes;
14
+ }) {
15
+ return (
16
+ <div className='flex items-center gap-4 justify-end flex-wrap'>
17
+ <PageSizeSelector table={table} pageSizes={pageSizes} />
18
+ <PageIndicator table={table} />
19
+ <GotoPageSelector table={table} />
20
+ <SelectPageButtons table={table} />
21
+ </div>
22
+ );
23
+ }
24
+
25
+ function PageIndicator({ table }: PaginationProps) {
26
+ if (table.getRowModel().rows.length <= 1) {
27
+ return null;
28
+ }
29
+
30
+ return (
31
+ <span className='flex items-center gap-1'>
32
+ <div>Page</div>
33
+ <strong>
34
+ {table.getState().pagination.pageIndex + 1} of {table.getPageCount().toLocaleString()}
35
+ </strong>
36
+ </span>
37
+ );
38
+ }
39
+
40
+ function PageSizeSelector({
41
+ table,
42
+ pageSizes,
43
+ }: PaginationProps & {
44
+ pageSizes: PageSizes;
45
+ }) {
46
+ if (typeof pageSizes === 'number' || pageSizes.length <= 1) {
47
+ return null;
48
+ }
49
+
50
+ return (
51
+ <label className='flex items-center gap-2'>
52
+ <div className={'text-nowrap'}>Rows per page:</div>
53
+ <select
54
+ className={'select'}
55
+ value={table.getState().pagination.pageSize}
56
+ onChange={(e) => {
57
+ table.setPageSize(Number(e.currentTarget?.value));
58
+ }}
59
+ aria-label='Select number of rows per page'
60
+ >
61
+ {pageSizes.map((pageSize) => (
62
+ <option key={pageSize} value={pageSize}>
63
+ {pageSize}
64
+ </option>
65
+ ))}
66
+ </select>
67
+ </label>
68
+ );
69
+ }
70
+
71
+ function GotoPageSelector({ table }: PaginationProps) {
72
+ if (table.getRowModel().rows.length === 0) {
73
+ return null;
74
+ }
75
+
76
+ return (
77
+ <label className='flex items-center'>
78
+ Go to page:
79
+ <input
80
+ type='number'
81
+ min='1'
82
+ max={table.getPageCount()}
83
+ defaultValue={table.getState().pagination.pageIndex + 1}
84
+ onChange={(e) => {
85
+ const page = e.currentTarget.value ? Number(e.currentTarget.value) - 1 : 0;
86
+ table.setPageIndex(page);
87
+ }}
88
+ className='input'
89
+ aria-label='Enter page number to go to'
90
+ />
91
+ </label>
92
+ );
93
+ }
94
+
95
+ function SelectPageButtons({ table }: PaginationProps) {
96
+ return (
97
+ <div className={'join'} role='group' aria-label='Pagination controls'>
98
+ <button
99
+ className='btn btn-outline join-item btn-sm'
100
+ onClick={() => table.firstPage()}
101
+ disabled={!table.getCanPreviousPage()}
102
+ aria-label='First page'
103
+ >
104
+ <div className='iconify mdi--chevron-left-first' />
105
+ </button>
106
+ <button
107
+ className='btn btn-outline join-item btn-sm'
108
+ onClick={() => table.previousPage()}
109
+ disabled={!table.getCanPreviousPage()}
110
+ aria-label='Previous page'
111
+ >
112
+ <div className='iconify mdi--chevron-left' />
113
+ </button>
114
+ <button
115
+ className='btn btn-outline join-item btn-sm'
116
+ onClick={() => table.nextPage()}
117
+ disabled={!table.getCanNextPage()}
118
+ aria-label='Next page'
119
+ >
120
+ <div className='iconify mdi--chevron-right' />
121
+ </button>
122
+ <button
123
+ className='btn btn-outline join-item btn-sm'
124
+ onClick={() => table.lastPage()}
125
+ disabled={!table.getCanNextPage()}
126
+ aria-label='Last page'
127
+ >
128
+ <div className='iconify mdi--chevron-right-last' />
129
+ </button>
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,43 @@
1
+ import { createTable, type RowData, type TableOptions, type TableOptionsResolved } from '@tanstack/table-core';
2
+ import { type ComponentType, h, type VNode } from 'preact';
3
+ import { useState } from 'preact/hooks';
4
+
5
+ export * from '@tanstack/table-core';
6
+
7
+ // Adapted from https://github.com/TanStack/table/blob/55ea94863b6b6e6d17bd51ecda61c6a6a1262c88/packages/preact-table/src/FlexRender.tsx
8
+
9
+ export type Renderable<TProps> = VNode<TProps> | ComponentType<TProps> | undefined | null | string | number | boolean;
10
+
11
+ export function flexRender<TProps extends object>(Comp: Renderable<TProps>, props: TProps) {
12
+ return !Comp ? null : typeof Comp === 'function' ? <Comp {...props} /> : Comp;
13
+ }
14
+
15
+ export function usePreactTable<TData extends RowData>(options: TableOptions<TData>) {
16
+ const resolvedOptions: TableOptionsResolved<TData> = {
17
+ state: {},
18
+ onStateChange: () => {},
19
+ renderFallbackValue: null,
20
+ ...options,
21
+ };
22
+
23
+ const [tableRef] = useState(() => ({
24
+ current: createTable<TData>(resolvedOptions),
25
+ }));
26
+
27
+ const [state, setState] = useState(() => tableRef.current.initialState);
28
+
29
+ tableRef.current.setOptions((prev) => ({
30
+ ...prev,
31
+ ...options,
32
+ state: {
33
+ ...state,
34
+ ...options.state,
35
+ },
36
+ onStateChange: (updater) => {
37
+ setState(updater);
38
+ options.onStateChange?.(updater);
39
+ },
40
+ }));
41
+
42
+ return tableRef.current;
43
+ }
@@ -18,6 +18,7 @@ const meta: Meta<WastewaterMutationsOverTimeProps> = {
18
18
  options: ['nucleotide', 'amino acid'],
19
19
  control: { type: 'radio' },
20
20
  },
21
+ pageSizes: { control: 'object' },
21
22
  },
22
23
  parameters: {
23
24
  fetchMock: {},
@@ -42,7 +43,7 @@ export const Default: StoryObj<WastewaterMutationsOverTimeProps> = {
42
43
  width: '100%',
43
44
  lapisFilter: {},
44
45
  sequenceType: 'nucleotide',
45
- maxNumberOfGridRows: 100,
46
+ pageSizes: [10, 20, 30, 40, 50],
46
47
  },
47
48
  parameters: {
48
49
  fetchMock: {