uikit-react-public 0.37.0 → 0.37.2

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.
@@ -44,6 +44,15 @@ export const FewPages: Story = {
44
44
  },
45
45
  };
46
46
 
47
+ export const Compact: Story = {
48
+ args: {
49
+ offset: 0,
50
+ limit: 20,
51
+ total: 60,
52
+ children: <Pagination.Controls variant='compact' />,
53
+ },
54
+ };
55
+
47
56
  export const ItemsInfo: Story = {
48
57
  args: {
49
58
  offset: 60,
@@ -1,262 +1,34 @@
1
- import { HTMLAttributes, memo, useContext, useCallback } from 'react';
2
- import { css, cx } from '@emotion/css';
3
- import useTheme from '../../theme/useTheme';
4
- import { PaginationContext } from './Pagination';
5
- import Button from '../Button';
6
- import getPaginationButtons from './getPaginationButtons';
7
- import { useMediaQuery } from '../../hooks';
1
+ import { HTMLAttributes, memo } from 'react';
2
+ import {
3
+ PaginationCompactControls,
4
+ PaginationStandardControls,
5
+ } from './subcomponents';
8
6
 
9
7
  export const NAME = 'ucl-ukit-pagination__controls';
10
8
  export const DEFAULT_MAX_BUTTONS = 9;
11
9
 
12
10
  export interface PaginationControlsProps extends HTMLAttributes<HTMLDivElement> {
11
+ variant?: 'default' | 'compact';
13
12
  maxNumberButtons?: number;
14
13
  testId?: string;
15
14
  }
16
15
 
17
16
  const PaginationControls = ({
17
+ variant = 'default',
18
18
  maxNumberButtons = DEFAULT_MAX_BUTTONS,
19
19
  testId = NAME,
20
20
  className,
21
+ ...props
21
22
  }: PaginationControlsProps) => {
22
- const [theme] = useTheme();
23
-
24
- const isTabletPlus = useMediaQuery(
25
- `(min-width: ${theme.breakpoints.tablet}px)`
26
- );
27
-
28
- const contextValue = useContext(PaginationContext);
29
-
30
- const handlePageChange = useCallback(
31
- (newPage: number) => {
32
- if (contextValue) {
33
- const { mode, limit, onPageChange, currentPage, trackedMaxPage } =
34
- contextValue;
35
-
36
- if (mode === 'tracked') {
37
- if (
38
- newPage < 1 ||
39
- newPage > trackedMaxPage ||
40
- newPage === currentPage
41
- ) {
42
- return;
43
- }
44
- if (onPageChange) {
45
- onPageChange(newPage);
46
- }
47
- return;
48
- }
49
-
50
- const newOffset = (newPage - 1) * limit;
51
- if (onPageChange) {
52
- onPageChange(newOffset);
53
- }
54
- }
55
- },
56
- [contextValue]
57
- );
58
-
59
- if (!contextValue) {
60
- return null;
61
- }
62
-
63
- const {
64
- mode,
65
- limit,
66
- total,
67
- currentPage,
68
- trackedMaxPage,
69
- hasPreviousPage,
70
- hasNextPage,
71
- onNextPage,
72
- onPreviousPage,
73
- } = contextValue;
74
-
75
- const totalPages =
76
- typeof total === 'number' ? Math.ceil(total / limit) : undefined;
77
-
78
- const paginationButtons =
79
- mode === 'tracked'
80
- ? Array.from({ length: trackedMaxPage }, (_, i) => i + 1)
81
- : getPaginationButtons(
82
- currentPage,
83
- totalPages ?? 0,
84
- isTabletPlus ? maxNumberButtons : 5
85
- );
86
-
87
- const baseStyle = css``;
88
-
89
- const style = cx(NAME, baseStyle, className);
90
-
91
- const listStyle = css`
92
- margin: 0;
93
- padding: 0;
94
- display: flex;
95
- justify-content: center;
96
- align-items: center;
97
- list-style: none;
98
- gap: ${theme.margin.m4};
99
- flex-wrap: nowrap;
100
-
101
- @media (min-width: ${theme.breakpoints.tablet}px) {
102
- gap: ${theme.margin.m8};
103
- }
104
- `;
105
-
106
- const buttonBaseStyle = css`
107
- @media (max-width: ${theme.breakpoints.tablet}px) {
108
- gap: ${theme.margin.m4};
109
- }
110
- `;
111
-
112
- const previousButtonStyle = cx(
113
- buttonBaseStyle,
114
- css`
115
- margin-right: ${theme.margin.m16};
116
- display: none;
117
-
118
- @media (min-width: ${theme.breakpoints.tablet}px) {
119
- display: inline-block;
120
- }
121
- `
122
- );
123
-
124
- const nextButtonStyle = cx(
125
- buttonBaseStyle,
126
- css`
127
- margin-left: ${theme.margin.m16};
128
- display: none;
129
-
130
- @media (min-width: ${theme.breakpoints.tablet}px) {
131
- display: inline-block;
132
- }
133
- `
134
- );
135
-
136
- const pageNumberButtonBaseStyle = cx(
137
- buttonBaseStyle,
138
- css`
139
- height: 40px;
140
- min-width: 40px;
141
- padding: 0;
142
-
143
- @media (min-width: ${theme.breakpoints.tablet}px) {
144
- height: 48px;
145
- min-width: 48px;
146
- }
147
- `
148
- );
149
-
150
- const pageNumberButtonStyle = cx(
151
- pageNumberButtonBaseStyle,
152
- css`
153
- border: none;
154
- color: ${theme.colour.text.secondary};
155
- `
156
- );
157
-
158
- const currentPageNumberButtonStyle = cx(
159
- pageNumberButtonBaseStyle,
160
- css`
161
- color: ${theme.colour.text.brand};
162
- border-color: ${theme.colour.border.brand};
163
- `
164
- );
165
-
166
- const buttonNumberLabelStyle = css`
167
- padding: 0 ${theme.padding.p8};
168
- `;
169
-
170
- return (
171
- <nav
172
- className={style}
173
- aria-label={
174
- typeof totalPages === 'number'
175
- ? `Pagination, ${totalPages} pages total`
176
- : 'Pagination'
177
- }
178
- data-testid={testId}
179
- >
180
- <ul className={listStyle}>
181
- <li>
182
- <Button
183
- className={previousButtonStyle}
184
- variant='tertiary'
185
- size={isTabletPlus ? 'default' : 'small'}
186
- aria-label='Go to previous page'
187
- onClick={() => {
188
- if (mode === 'tracked') {
189
- if (onPreviousPage) {
190
- onPreviousPage();
191
- } else {
192
- handlePageChange(currentPage - 1);
193
- }
194
- return;
195
- }
196
- handlePageChange(currentPage - 1);
197
- }}
198
- disabled={!hasPreviousPage}
199
- >
200
- Previous
201
- </Button>
202
- </li>
203
-
204
- {paginationButtons.map((page) => (
205
- <li key={page}>
206
- {page === '...1' || page === '...2' ? (
207
- <span aria-hidden>...</span>
208
- ) : (
209
- <>
210
- {page !== currentPage && (
211
- <Button
212
- className={pageNumberButtonStyle}
213
- variant='secondary'
214
- size={isTabletPlus ? 'default' : 'small'}
215
- aria-label={`Page ${page}`}
216
- onClick={() => handlePageChange(page)}
217
- >
218
- <span className={buttonNumberLabelStyle}>{page}</span>
219
- </Button>
220
- )}
221
- {page === currentPage && (
222
- <Button
223
- className={currentPageNumberButtonStyle}
224
- variant='secondary'
225
- size={isTabletPlus ? 'default' : 'small'}
226
- aria-label={`Page ${page}, current page`}
227
- aria-current='page'
228
- disabled
229
- onClick={() => handlePageChange(page)}
230
- >
231
- <span className={buttonNumberLabelStyle}>{page}</span>
232
- </Button>
233
- )}
234
- </>
235
- )}
236
- </li>
237
- ))}
238
-
239
- <li>
240
- <Button
241
- className={nextButtonStyle}
242
- variant='tertiary'
243
- aria-label='Go to next page'
244
- onClick={() => {
245
- if (mode === 'tracked') {
246
- if (onNextPage) {
247
- onNextPage();
248
- }
249
- return;
250
- }
251
- handlePageChange(currentPage + 1);
252
- }}
253
- disabled={!hasNextPage}
254
- >
255
- Next
256
- </Button>
257
- </li>
258
- </ul>
259
- </nav>
23
+ const controlsProps = { ...props, className, name: NAME, testId };
24
+
25
+ return variant === 'compact' ? (
26
+ <PaginationCompactControls {...controlsProps} />
27
+ ) : (
28
+ <PaginationStandardControls
29
+ {...controlsProps}
30
+ maxNumberButtons={maxNumberButtons}
31
+ />
260
32
  );
261
33
  };
262
34
 
@@ -0,0 +1,213 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { useState } from 'react';
5
+ import { ThemeContextProvider } from '../../../theme';
6
+ import announce from '../../../utils/announce';
7
+ import Pagination from '..';
8
+
9
+ vi.mock('../../../utils/announce');
10
+
11
+ const renderPagination = (
12
+ props: Partial<React.ComponentProps<typeof Pagination>> = {},
13
+ controlsProps: React.ComponentProps<typeof Pagination.Controls> = {
14
+ variant: 'compact',
15
+ }
16
+ ) =>
17
+ render(
18
+ <ThemeContextProvider>
19
+ <Pagination
20
+ offset={20}
21
+ limit={20}
22
+ total={60}
23
+ {...props}
24
+ >
25
+ <Pagination.Controls {...controlsProps} />
26
+ </Pagination>
27
+ </ThemeContextProvider>
28
+ );
29
+
30
+ describe('PaginationControls', () => {
31
+ test.each(['default', 'compact'] as const)(
32
+ 'forwards HTML attributes to the %s controls',
33
+ (variant) => {
34
+ renderPagination({}, { variant, title: `${variant} controls` });
35
+
36
+ expect(screen.getByTitle(`${variant} controls`)).toBeInTheDocument();
37
+ }
38
+ );
39
+
40
+ test('renders compact pagination controls', () => {
41
+ renderPagination();
42
+
43
+ expect(
44
+ screen.getByRole('button', { name: 'Go to previous page' })
45
+ ).toBeEnabled();
46
+ expect(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue(
47
+ '2'
48
+ );
49
+ expect(
50
+ screen.getByRole('textbox', { name: 'Page number' })
51
+ ).toHaveAccessibleDescription(
52
+ 'Page 2 of 3. Enter a page number and press Enter.'
53
+ );
54
+ expect(screen.getByText('of 3')).toBeInTheDocument();
55
+ expect(
56
+ screen.getByRole('button', { name: 'Go to next page' })
57
+ ).toBeEnabled();
58
+ expect(screen.queryByRole('button', { name: 'Page 1' })).toBeNull();
59
+ });
60
+
61
+ test('changes page using compact controls', async () => {
62
+ const user = userEvent.setup();
63
+ const onPageChange = vi.fn();
64
+
65
+ const ControlledPagination = () => {
66
+ const [offset, setOffset] = useState(20);
67
+
68
+ const handlePageChange = (newOffset: number) => {
69
+ onPageChange(newOffset);
70
+ setOffset(newOffset);
71
+ };
72
+
73
+ return (
74
+ <ThemeContextProvider>
75
+ <Pagination
76
+ offset={offset}
77
+ limit={20}
78
+ total={60}
79
+ onPageChange={handlePageChange}
80
+ >
81
+ <Pagination.Controls variant='compact' />
82
+ </Pagination>
83
+ </ThemeContextProvider>
84
+ );
85
+ };
86
+
87
+ render(<ControlledPagination />);
88
+
89
+ await user.click(
90
+ screen.getByRole('button', { name: 'Go to previous page' })
91
+ );
92
+ expect(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue(
93
+ '1'
94
+ );
95
+
96
+ await user.click(screen.getByRole('button', { name: 'Go to next page' }));
97
+ expect(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue(
98
+ '2'
99
+ );
100
+
101
+ expect(onPageChange).toHaveBeenNthCalledWith(1, 0);
102
+ expect(onPageChange).toHaveBeenNthCalledWith(2, 20);
103
+ });
104
+
105
+ test('changes page when a page number is entered', async () => {
106
+ const user = userEvent.setup();
107
+ const onPageChange = vi.fn();
108
+ renderPagination({ offset: 0, total: 100, onPageChange });
109
+
110
+ const currentPageInput = screen.getByRole('textbox', {
111
+ name: 'Page number',
112
+ });
113
+ await user.clear(currentPageInput);
114
+ await user.type(currentPageInput, '4{Enter}');
115
+
116
+ expect(onPageChange).toHaveBeenCalledWith(60);
117
+ expect(currentPageInput).not.toHaveFocus();
118
+ });
119
+
120
+ test('does not change page when the entered page is out of range', async () => {
121
+ const user = userEvent.setup();
122
+ const onPageChange = vi.fn();
123
+ renderPagination({ offset: 0, onPageChange });
124
+
125
+ const currentPageInput = screen.getByRole('textbox', {
126
+ name: 'Page number',
127
+ });
128
+ await user.clear(currentPageInput);
129
+ await user.type(currentPageInput, '4{Enter}');
130
+
131
+ expect(onPageChange).not.toHaveBeenCalled();
132
+ expect(currentPageInput).toHaveValue('4');
133
+ expect(currentPageInput).toHaveFocus();
134
+ expect(currentPageInput).toHaveAttribute('aria-invalid', 'true');
135
+ expect(announce).toHaveBeenCalledWith(
136
+ 'Enter a page number from 1 to 3.',
137
+ true
138
+ );
139
+ });
140
+
141
+ test('does not change page when the entered value contains non-digits', async () => {
142
+ const user = userEvent.setup();
143
+ const onPageChange = vi.fn();
144
+ renderPagination({ offset: 0, total: 2000, onPageChange });
145
+
146
+ const currentPageInput = screen.getByRole('textbox', {
147
+ name: 'Page number',
148
+ });
149
+ await user.clear(currentPageInput);
150
+ await user.type(currentPageInput, '1e2{Enter}');
151
+
152
+ expect(onPageChange).not.toHaveBeenCalled();
153
+ expect(currentPageInput).toHaveAttribute('aria-invalid', 'true');
154
+ expect(announce).toHaveBeenCalledWith(
155
+ 'Enter a page number from 1 to 100.',
156
+ true
157
+ );
158
+ });
159
+
160
+ test('cancels the entered page and exits the input on Escape', async () => {
161
+ const user = userEvent.setup();
162
+ const onPageChange = vi.fn();
163
+ renderPagination({ onPageChange });
164
+
165
+ const currentPageInput = screen.getByRole('textbox', {
166
+ name: 'Page number',
167
+ });
168
+ await user.clear(currentPageInput);
169
+ await user.type(currentPageInput, '3{Escape}');
170
+
171
+ expect(onPageChange).not.toHaveBeenCalled();
172
+ expect(currentPageInput).toHaveValue('2');
173
+ expect(currentPageInput).not.toHaveFocus();
174
+ });
175
+
176
+ test('cancels the entered page when the input loses focus', async () => {
177
+ const user = userEvent.setup();
178
+ const onPageChange = vi.fn();
179
+ renderPagination({ onPageChange });
180
+
181
+ const currentPageInput = screen.getByRole('textbox', {
182
+ name: 'Page number',
183
+ });
184
+ await user.clear(currentPageInput);
185
+ await user.type(currentPageInput, '3');
186
+ await user.tab();
187
+
188
+ expect(onPageChange).not.toHaveBeenCalled();
189
+ expect(currentPageInput).toHaveValue('2');
190
+ });
191
+
192
+ test('renders zero of zero when there are no pages', () => {
193
+ renderPagination({ offset: 0, total: 0 });
194
+
195
+ expect(
196
+ screen.getByRole('navigation', { name: 'Pagination, 0 pages total' })
197
+ ).toBeInTheDocument();
198
+ expect(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue(
199
+ '0'
200
+ );
201
+ expect(screen.getByRole('textbox', { name: 'Page number' })).toBeDisabled();
202
+ expect(
203
+ screen.getByRole('textbox', { name: 'Page number' })
204
+ ).toHaveAccessibleDescription('Page 0 of 0.');
205
+ expect(screen.getByText('of 0')).toBeInTheDocument();
206
+ expect(
207
+ screen.getByRole('button', { name: 'Go to previous page' })
208
+ ).toBeDisabled();
209
+ expect(
210
+ screen.getByRole('button', { name: 'Go to next page' })
211
+ ).toBeDisabled();
212
+ });
213
+ });