@astryxdesign/core 0.7.1 → 0.7.2-canary.b69b2df
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.
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +38 -14
- package/dist/MultiSelector/hooks.d.ts.map +1 -1
- package/dist/MultiSelector/hooks.js +3 -1
- package/dist/Pagination/Pagination.d.ts +8 -5
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +21 -8
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +6 -0
- package/dist/astryx.css +1 -0
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +19 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
- package/locales/en.json +76 -0
- package/locales/pseudo.json +57 -0
- package/package.json +4 -4
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.test.tsx +155 -9
- package/src/Button/Button.tsx +39 -16
- package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
- package/src/Button/__tests__/Button.a11y.states.ts +0 -10
- package/src/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- package/src/MultiSelector/hooks.ts +3 -1
- package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
- package/src/Pagination/Pagination.doc.mjs +7 -7
- package/src/Pagination/Pagination.spec.md +9 -9
- package/src/Pagination/Pagination.test.tsx +242 -0
- package/src/Pagination/Pagination.tsx +27 -11
- package/src/Switch/Switch.test.tsx +28 -0
- package/src/Switch/Switch.tsx +6 -0
- package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
- package/src/i18n/InternationalizationProvider.spec.md +252 -0
- package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
- package/src/i18n/generated-locales/en.generated.ts +21 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
- package/src/i18n/useTranslator.test.tsx +37 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').HookDoc} */
|
|
4
|
+
export const docs = {
|
|
5
|
+
name: 'useMultiCombobox',
|
|
6
|
+
displayName: 'useMultiCombobox',
|
|
7
|
+
group: 'MultiSelector',
|
|
8
|
+
keywords: [
|
|
9
|
+
'combobox',
|
|
10
|
+
'multi-select',
|
|
11
|
+
'multiselect',
|
|
12
|
+
'listbox',
|
|
13
|
+
'grid',
|
|
14
|
+
'toggle',
|
|
15
|
+
'highlight',
|
|
16
|
+
'active option',
|
|
17
|
+
'aria-activedescendant',
|
|
18
|
+
'keyboard',
|
|
19
|
+
'headless',
|
|
20
|
+
'apg',
|
|
21
|
+
'accessibility',
|
|
22
|
+
'a11y',
|
|
23
|
+
],
|
|
24
|
+
params: [
|
|
25
|
+
{
|
|
26
|
+
name: 'options',
|
|
27
|
+
type: 'UseMultiComboboxOptions',
|
|
28
|
+
description: 'Configuration object.',
|
|
29
|
+
required: true,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'options.selectableItems',
|
|
33
|
+
type: 'MultiSelectorOptionData[]',
|
|
34
|
+
description:
|
|
35
|
+
'The options in the same order as they render. Options with `disabled` cannot be toggled and are skipped while moving through the open list; ArrowDown or ArrowUp that opens the list still lands on the first or last option.',
|
|
36
|
+
required: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'options.isOpen',
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
description: 'Whether the list is shown. The caller owns this state.',
|
|
42
|
+
required: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'options.onOpen',
|
|
46
|
+
type: '() => unknown',
|
|
47
|
+
description:
|
|
48
|
+
'Called to show the list. Return `false` to refuse; the hook then leaves the highlight alone.',
|
|
49
|
+
required: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'options.onClose',
|
|
53
|
+
type: '() => void',
|
|
54
|
+
description: 'Called to hide the list. The highlight resets first.',
|
|
55
|
+
required: true,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'options.onToggle',
|
|
59
|
+
type: '(itemValue: string) => void',
|
|
60
|
+
description:
|
|
61
|
+
'Called with the value of the option to add or remove. Toggling keeps the list open.',
|
|
62
|
+
required: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'options.listboxId',
|
|
66
|
+
type: 'string',
|
|
67
|
+
description:
|
|
68
|
+
"The listbox (or grid) element's id. Option and action-cell ids are derived from it.",
|
|
69
|
+
required: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'options.hasSearch',
|
|
73
|
+
type: 'boolean',
|
|
74
|
+
description:
|
|
75
|
+
'Whether the list has its own search field. Space then types instead of toggling, opening by a press or Enter does not pre-highlight (ArrowDown and ArrowUp still open on the first and last option), Delete and Backspace edit the query, and type-to-select is off. Home and End still jump to the first and last option, so a search input that keeps them for the caret should stop them before this handler and rely on PageUp and PageDown.',
|
|
76
|
+
default: 'false',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'options.onClear',
|
|
80
|
+
type: '() => void',
|
|
81
|
+
description:
|
|
82
|
+
'Clears every selected value. When provided, Delete or Backspace on the closed control clears them, so clearing is not pointer-only. Ignored while the list is open or with `hasSearch`.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'options.hasValue',
|
|
86
|
+
type: 'boolean',
|
|
87
|
+
description:
|
|
88
|
+
'Whether anything is selected. The Delete and Backspace clear is skipped when false.',
|
|
89
|
+
default: 'false',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'options.isGrid',
|
|
93
|
+
type: 'boolean',
|
|
94
|
+
description:
|
|
95
|
+
"Whether each row pairs an option cell with an action cell. The inline-end arrow then moves the highlight to the row's action and the inline-start arrow back, following visual direction in right-to-left layouts, and Enter on the action runs `onActivateAction` instead of toggling.",
|
|
96
|
+
default: 'false',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'options.rowHasAction',
|
|
100
|
+
type: '(index: number) => boolean',
|
|
101
|
+
description:
|
|
102
|
+
'In a grid, whether the row at `index` has an action cell to move to.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'options.onActivateAction',
|
|
106
|
+
type: '(index: number) => void',
|
|
107
|
+
description:
|
|
108
|
+
"In a grid, runs the control in the highlighted row's action cell. The selection does not change.",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'options.isDisabled',
|
|
112
|
+
type: 'boolean',
|
|
113
|
+
description: 'Whether the control ignores clicks and keys.',
|
|
114
|
+
default: 'false',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
returns: [
|
|
118
|
+
{
|
|
119
|
+
name: 'highlightedIndex',
|
|
120
|
+
type: 'number',
|
|
121
|
+
description: 'Index of the active row, or -1 when none is active.',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'setHighlightedIndex',
|
|
125
|
+
type: '(index: number) => void',
|
|
126
|
+
description:
|
|
127
|
+
"Sets the active row, for example after filtering. The highlight lands on the row's option cell.",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'highlightedCell',
|
|
131
|
+
type: "'option' | 'action'",
|
|
132
|
+
description:
|
|
133
|
+
"Which cell of the active row is highlighted; `'action'` only in a grid.",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'activeDescendantId',
|
|
137
|
+
type: 'string | undefined',
|
|
138
|
+
description:
|
|
139
|
+
"The id to pass as the control's `aria-activedescendant`: the active option, or its action cell in a grid. Undefined while the list is closed or nothing is active.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'getItemId',
|
|
143
|
+
type: '(index: number) => string',
|
|
144
|
+
description: 'The id to render on the option at `index`.',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'getActionCellId',
|
|
148
|
+
type: '(index: number) => string',
|
|
149
|
+
description:
|
|
150
|
+
'In a grid, the id to render on the action cell of row `index`.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'onTriggerClick',
|
|
154
|
+
type: '() => void',
|
|
155
|
+
description: 'Toggles the list from a press on the control.',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'onKeyDown',
|
|
159
|
+
type: '(e: React.KeyboardEvent) => void',
|
|
160
|
+
description:
|
|
161
|
+
'Key handler for the control that keeps focus. ArrowDown and ArrowUp open the list or move the active row; Enter (and Space without search) toggles the active option and keeps the list open; Escape and Tab close it; Home, End, PageUp and PageDown jump to the first or last option; without search, typed characters move to the first option whose label starts with them.',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'onItemMouseEnter',
|
|
165
|
+
type: "(item: MultiSelectorOptionData, index: number, cell?: 'option' | 'action') => void",
|
|
166
|
+
description:
|
|
167
|
+
'Makes the hovered option, or its action cell, active without scrolling the list or moving focus.',
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
usage: {
|
|
171
|
+
description:
|
|
172
|
+
'Tracks the active option of a multi-select combobox and handles its keys, while DOM focus stays on the control. It powers MultiSelector; unlike useCombobox, toggling an option keeps the list open, and rows can carry an action cell. It renders nothing and sets no attributes: the caller renders the control and the list, and wires the ARIA attributes from what it returns.',
|
|
173
|
+
bestPractices: [
|
|
174
|
+
{
|
|
175
|
+
guidance: true,
|
|
176
|
+
description:
|
|
177
|
+
"Render each option with `id={getItemId(index)}` and pass `activeDescendantId` as the control's `aria-activedescendant`.",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
guidance: true,
|
|
181
|
+
description:
|
|
182
|
+
'Use `isGrid` only when rows carry a control next to the option; give each action cell `id={getActionCellId(index)}`.',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
guidance: true,
|
|
186
|
+
description:
|
|
187
|
+
'Announce the number of results through useAnnounce when the options change for a new query.',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
guidance: false,
|
|
191
|
+
description:
|
|
192
|
+
'Close the list after each toggle; a multi-select stays open so several options can be picked in a row.',
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
relatedComponents: ['MultiSelector'],
|
|
197
|
+
relatedHooks: ['useCombobox', 'useAnnounce'],
|
|
198
|
+
importPath: '@astryxdesign/core/MultiSelector',
|
|
199
|
+
category: 'focus',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
|
|
203
|
+
export const docsDense = {
|
|
204
|
+
description:
|
|
205
|
+
'Multi-select combobox state + keys; focus stays on control. Powers MultiSelector. Toggle keeps list open; rows may carry an action cell (grid). Renders nothing, sets no attrs: caller renders + wires ARIA.',
|
|
206
|
+
paramDescriptions: {
|
|
207
|
+
options: 'config.',
|
|
208
|
+
'options.selectableItems':
|
|
209
|
+
'options in render order; disabled ones not toggleable, skipped while moving in open list (ArrowDown/Up opening still lands first / last).',
|
|
210
|
+
'options.isOpen': 'list shown; caller owns state.',
|
|
211
|
+
'options.onOpen':
|
|
212
|
+
'show list; return false to refuse (highlight untouched).',
|
|
213
|
+
'options.onClose': 'hide list; highlight resets first.',
|
|
214
|
+
'options.onToggle': 'add / remove value; list stays open.',
|
|
215
|
+
'options.listboxId':
|
|
216
|
+
'listbox / grid id; option + action-cell ids derive from it.',
|
|
217
|
+
'options.hasSearch':
|
|
218
|
+
'own search field: Space types, press / Enter opening does not pre-highlight (ArrowDown/Up still first / last), Delete / Backspace edit query, no type-to-select. Home / End still jump; stop them first to keep them for the caret, use PageUp / PageDown.',
|
|
219
|
+
'options.onClear':
|
|
220
|
+
'clear all; Delete / Backspace on closed control. ignored when open or hasSearch.',
|
|
221
|
+
'options.hasValue': 'anything selected; clear skipped when false.',
|
|
222
|
+
'options.isGrid':
|
|
223
|
+
'rows = option + action cell; inline-end / inline-start arrows move between them (RTL-aware); Enter on action -> onActivateAction.',
|
|
224
|
+
'options.rowHasAction': 'grid: row at index has an action cell.',
|
|
225
|
+
'options.onActivateAction':
|
|
226
|
+
"grid: run highlighted row's action; selection unchanged.",
|
|
227
|
+
'options.isDisabled': 'ignore clicks + keys.',
|
|
228
|
+
},
|
|
229
|
+
returnDescriptions: {
|
|
230
|
+
highlightedIndex: 'active row index, -1 = none.',
|
|
231
|
+
setHighlightedIndex: 'set active row (lands on option cell).',
|
|
232
|
+
highlightedCell: "'option' | 'action' (action only in grid).",
|
|
233
|
+
activeDescendantId:
|
|
234
|
+
'control aria-activedescendant: active option or its action cell; undefined when closed / none.',
|
|
235
|
+
getItemId: 'id for option at index.',
|
|
236
|
+
getActionCellId: 'grid: id for action cell of row index.',
|
|
237
|
+
onTriggerClick: 'toggle list from a press on control.',
|
|
238
|
+
onKeyDown:
|
|
239
|
+
'control keydown: ArrowDown/Up open or move; Enter (Space w/o search) toggles, stays open; Escape / Tab close; Home / End / PageUp / PageDown jump; typed chars jump by label prefix (w/o search).',
|
|
240
|
+
onItemMouseEnter:
|
|
241
|
+
'hover makes option / action cell active; no scroll, no focus move.',
|
|
242
|
+
},
|
|
243
|
+
usage: {
|
|
244
|
+
description:
|
|
245
|
+
'Headless multi-select combobox: active option + keys, focus stays on control; toggle keeps list open. Caller renders + wires ARIA.',
|
|
246
|
+
bestPractices: [
|
|
247
|
+
{
|
|
248
|
+
guidance: true,
|
|
249
|
+
description:
|
|
250
|
+
'Option id = getItemId(index); control aria-activedescendant = activeDescendantId.',
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
guidance: true,
|
|
254
|
+
description:
|
|
255
|
+
'isGrid only when rows carry a control; action cell id = getActionCellId(index).',
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
guidance: true,
|
|
259
|
+
description: 'Announce result count via useAnnounce on new query.',
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
guidance: false,
|
|
263
|
+
description: 'Close after each toggle; multi-select stays open.',
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
};
|
|
@@ -137,7 +137,7 @@ export const docs = {
|
|
|
137
137
|
name: 'variant',
|
|
138
138
|
type: "'pages' | 'count' | 'compact' | 'dots' | 'input' | 'none'",
|
|
139
139
|
description:
|
|
140
|
-
"Visual variant controlling what appears between prev/next buttons. 'pages' shows page number buttons with ellipsis, 'count' shows 'X-Y of Z' text, 'compact' shows 'Page X of Y', 'dots' shows dot indicators, 'input' shows an editable page-number box with a leading label ('Page [ n ] / N') flanked by first/last buttons by default (the box needs a known total to clamp against, so it is disabled in cursor/hasMore mode), 'none' shows just prev/next buttons.",
|
|
140
|
+
"Visual variant controlling what appears between prev/next buttons. 'pages' shows page number buttons with ellipsis, 'count' shows 'X-Y of Z' text, 'compact' shows 'Page X of Y' (add first/last buttons with hasFirstLast), 'dots' shows dot indicators, 'input' shows an editable page-number box with a leading label ('Page [ n ] / N') flanked by first/last buttons by default (the box needs a known total to clamp against, so it is disabled in cursor/hasMore mode), 'none' shows just prev/next buttons.",
|
|
141
141
|
default: "'pages'",
|
|
142
142
|
},
|
|
143
143
|
{
|
|
@@ -150,8 +150,8 @@ export const docs = {
|
|
|
150
150
|
name: 'hasFirstLast',
|
|
151
151
|
type: 'boolean',
|
|
152
152
|
description:
|
|
153
|
-
"Whether to show first/last («/») double-chevron buttons flanking prev/next.
|
|
154
|
-
default:
|
|
153
|
+
"Whether to show first/last («/») double-chevron buttons flanking prev/next. Applies to the 'input' variant (shown unless false) and the 'compact' variant (shown only when true); other variants ignore it. Omitted when the page count is unknown (cursor/hasMore pagination).",
|
|
154
|
+
default: "true for 'input'; false for 'compact'",
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
name: 'step',
|
|
@@ -288,7 +288,7 @@ export const docsZh = {
|
|
|
288
288
|
name: 'variant',
|
|
289
289
|
type: "'pages' | 'count' | 'compact' | 'dots' | 'input' | 'none'",
|
|
290
290
|
description:
|
|
291
|
-
"控制上一页/下一页按钮之间显示内容的视觉变体。'pages' 显示带省略号的页码按钮,'count' 显示 'X-Y of Z' 文本,'compact' 显示 'Page X of Y'
|
|
291
|
+
"控制上一页/下一页按钮之间显示内容的视觉变体。'pages' 显示带省略号的页码按钮,'count' 显示 'X-Y of Z' 文本,'compact' 显示 'Page X of Y'(可通过 hasFirstLast 添加首页/末页按钮),'dots' 显示点指示器,'input' 显示带前置标签的可编辑页码框——'Page [ n ] / N'——默认两侧带首页/末页按钮,'none' 仅显示上一页/下一页按钮。",
|
|
292
292
|
default: "'pages'",
|
|
293
293
|
},
|
|
294
294
|
{
|
|
@@ -301,8 +301,8 @@ export const docsZh = {
|
|
|
301
301
|
name: 'hasFirstLast',
|
|
302
302
|
type: 'boolean',
|
|
303
303
|
description:
|
|
304
|
-
"
|
|
305
|
-
default: 'true',
|
|
304
|
+
"是否显示首页/末页(«/»)双箭头按钮,位于上一页/下一页两侧。适用于 'input' 变体(除非设为 false 否则显示)和 'compact' 变体(仅在设为 true 时显示);其他变体忽略此项。当页数未知(游标/hasMore 分页)时省略。",
|
|
305
|
+
default: "'input' 为 true;'compact' 为 false",
|
|
306
306
|
},
|
|
307
307
|
{
|
|
308
308
|
name: 'step',
|
|
@@ -400,7 +400,7 @@ export const docsDense = {
|
|
|
400
400
|
onPageSizeChange: 'Called on page size change. Auto resets to page 1.',
|
|
401
401
|
variant: "Display between prev/next. 'input' = 'Page [n] / N' + first/last.",
|
|
402
402
|
pageLabel: "Noun before the 'input' box (e.g. 'Page' or 'Row'). Navigation stays page-based. Default localized 'Page'.",
|
|
403
|
-
hasFirstLast: "Show first/last («/») buttons
|
|
403
|
+
hasFirstLast: "Show first/last («/») buttons. 'input' default true; 'compact' default false (opt in). Other variants ignore. Omitted when total unknown.",
|
|
404
404
|
step: 'Pages the prev/next buttons advance per click. Clamped to range; < 1 or non-integer falls back to 1.',
|
|
405
405
|
siblingCount: "Page buttons each side of current; only variant='pages'.",
|
|
406
406
|
size: 'Control size.',
|
|
@@ -92,15 +92,15 @@ and usage remain documented in `Pagination.doc.mjs`.
|
|
|
92
92
|
|
|
93
93
|
### Representative states
|
|
94
94
|
|
|
95
|
-
| State | Required invariant
|
|
96
|
-
| ----------------------------- |
|
|
97
|
-
| Numbered, known page count | Page number buttons render; Ellipsis appears only where pages are omitted.
|
|
98
|
-
| Count with known item total | Count readout renders between Previous/next buttons.
|
|
99
|
-
| Compact with known page count | Compact readout renders between Previous/next buttons.
|
|
100
|
-
| Dots with known page count | One Dot renders for each page and active state stays on the Dot target.
|
|
101
|
-
| Input with known page count | Page input label, Page input, and Page input total render; First/last may render.
|
|
102
|
-
| Input with unknown page count | Page input label and disabled Page input remain; total and First/last are absent.
|
|
103
|
-
| None | Previous/next buttons remain without a presentation-specific part between them.
|
|
95
|
+
| State | Required invariant | Allowed variation |
|
|
96
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
97
|
+
| Numbered, known page count | Page number buttons render; Ellipsis appears only where pages are omitted. | Page count, sibling range, and active page. |
|
|
98
|
+
| Count with known item total | Count readout renders between Previous/next buttons. | Range and total text. |
|
|
99
|
+
| Compact with known page count | Compact readout renders between Previous/next buttons; First/last render only when opted in. | Current and total page text and first/last opt-in. |
|
|
100
|
+
| Dots with known page count | One Dot renders for each page and active state stays on the Dot target. | Dot count and active Dot. |
|
|
101
|
+
| Input with known page count | Page input label, Page input, and Page input total render; First/last may render. | Label text and first/last opt-out. |
|
|
102
|
+
| Input with unknown page count | Page input label and disabled Page input remain; total and First/last are absent. | Cursor-backed current page. |
|
|
103
|
+
| None | Previous/next buttons remain without a presentation-specific part between them. | Enabled state follows current paging availability. |
|
|
104
104
|
|
|
105
105
|
The optional Page-size selector may precede the paging controls in any current
|
|
106
106
|
presentation when page-size options are provided.
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '@testing-library/react';
|
|
21
21
|
import userEvent from '@testing-library/user-event';
|
|
22
22
|
import {Pagination, generatePageRange} from './Pagination';
|
|
23
|
+
import {InternationalizationProvider} from '../i18n';
|
|
23
24
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
24
25
|
|
|
25
26
|
afterEach(() => {
|
|
@@ -119,6 +120,28 @@ describe('Pagination', () => {
|
|
|
119
120
|
).toBeInTheDocument();
|
|
120
121
|
});
|
|
121
122
|
|
|
123
|
+
it('does not introduce Stack theme targets around navigation controls', () => {
|
|
124
|
+
const {rerender} = render(
|
|
125
|
+
<Pagination page={3} onChange={() => {}} totalPages={5} />,
|
|
126
|
+
);
|
|
127
|
+
expect(
|
|
128
|
+
screen.getByRole('navigation').querySelector('.astryx-stack'),
|
|
129
|
+
).toBeNull();
|
|
130
|
+
|
|
131
|
+
rerender(
|
|
132
|
+
<Pagination
|
|
133
|
+
page={3}
|
|
134
|
+
onChange={() => {}}
|
|
135
|
+
totalPages={5}
|
|
136
|
+
variant="compact"
|
|
137
|
+
hasFirstLast
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
expect(
|
|
141
|
+
screen.getByRole('navigation').querySelector('.astryx-stack'),
|
|
142
|
+
).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
122
145
|
it('gives the prev/next carets a hover tooltip matching their accessible name', () => {
|
|
123
146
|
// The carets are icon-only, so sighted users need a visible label on
|
|
124
147
|
// hover, not just the accessible name. The tooltip reuses the same
|
|
@@ -291,6 +314,225 @@ describe('Pagination', () => {
|
|
|
291
314
|
);
|
|
292
315
|
expect(screen.getByText('Page 3 of 10')).toBeInTheDocument();
|
|
293
316
|
});
|
|
317
|
+
|
|
318
|
+
describe('first/last buttons', () => {
|
|
319
|
+
function navButtonNames(): string[] {
|
|
320
|
+
return within(screen.getByRole('navigation'))
|
|
321
|
+
.getAllByRole('button')
|
|
322
|
+
.map(button => button.getAttribute('aria-label') ?? '');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it('omits first/last when hasFirstLast is unset', () => {
|
|
326
|
+
render(
|
|
327
|
+
<Pagination
|
|
328
|
+
page={5}
|
|
329
|
+
onChange={() => {}}
|
|
330
|
+
totalPages={10}
|
|
331
|
+
variant="compact"
|
|
332
|
+
/>,
|
|
333
|
+
);
|
|
334
|
+
expect(navButtonNames()).toEqual([
|
|
335
|
+
'Go to previous page',
|
|
336
|
+
'Go to next page',
|
|
337
|
+
]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('omits first/last when hasFirstLast is false', () => {
|
|
341
|
+
render(
|
|
342
|
+
<Pagination
|
|
343
|
+
page={5}
|
|
344
|
+
onChange={() => {}}
|
|
345
|
+
totalPages={10}
|
|
346
|
+
variant="compact"
|
|
347
|
+
hasFirstLast={false}
|
|
348
|
+
/>,
|
|
349
|
+
);
|
|
350
|
+
expect(navButtonNames()).toEqual([
|
|
351
|
+
'Go to previous page',
|
|
352
|
+
'Go to next page',
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('flanks prev/next with first/last when hasFirstLast is true', () => {
|
|
357
|
+
render(
|
|
358
|
+
<Pagination
|
|
359
|
+
page={5}
|
|
360
|
+
onChange={() => {}}
|
|
361
|
+
totalPages={10}
|
|
362
|
+
variant="compact"
|
|
363
|
+
hasFirstLast
|
|
364
|
+
/>,
|
|
365
|
+
);
|
|
366
|
+
expect(navButtonNames()).toEqual([
|
|
367
|
+
'Go to first page',
|
|
368
|
+
'Go to previous page',
|
|
369
|
+
'Go to next page',
|
|
370
|
+
'Go to last page',
|
|
371
|
+
]);
|
|
372
|
+
// The readout stays between prev and next.
|
|
373
|
+
const readout = within(screen.getByRole('navigation')).getByText(
|
|
374
|
+
'Page 5 of 10',
|
|
375
|
+
);
|
|
376
|
+
const prev = screen.getByRole('button', {name: 'Go to previous page'});
|
|
377
|
+
const next = screen.getByRole('button', {name: 'Go to next page'});
|
|
378
|
+
expect(
|
|
379
|
+
prev.compareDocumentPosition(readout) &
|
|
380
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
381
|
+
).toBeTruthy();
|
|
382
|
+
expect(
|
|
383
|
+
next.compareDocumentPosition(readout) &
|
|
384
|
+
Node.DOCUMENT_POSITION_PRECEDING,
|
|
385
|
+
).toBeTruthy();
|
|
386
|
+
// Compact never gains an editable box.
|
|
387
|
+
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('jumps to the first and last page through onChange and changeAction', async () => {
|
|
391
|
+
const user = userEvent.setup();
|
|
392
|
+
const onChange = vi.fn();
|
|
393
|
+
const changeAction = vi.fn();
|
|
394
|
+
render(
|
|
395
|
+
<Pagination
|
|
396
|
+
page={5}
|
|
397
|
+
onChange={onChange}
|
|
398
|
+
changeAction={changeAction}
|
|
399
|
+
totalItems={95}
|
|
400
|
+
pageSize={10}
|
|
401
|
+
variant="compact"
|
|
402
|
+
hasFirstLast
|
|
403
|
+
/>,
|
|
404
|
+
);
|
|
405
|
+
await user.click(
|
|
406
|
+
screen.getByRole('button', {name: 'Go to first page'}),
|
|
407
|
+
);
|
|
408
|
+
expect(onChange).toHaveBeenLastCalledWith(1);
|
|
409
|
+
expect(changeAction).toHaveBeenLastCalledWith(1);
|
|
410
|
+
await waitFor(() => {
|
|
411
|
+
expect(politeRegion()).toHaveTextContent('Page 1 of 10');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
await user.click(screen.getByRole('button', {name: 'Go to last page'}));
|
|
415
|
+
// 95 items at 10 per page is 10 pages.
|
|
416
|
+
expect(onChange).toHaveBeenLastCalledWith(10);
|
|
417
|
+
expect(changeAction).toHaveBeenLastCalledWith(10);
|
|
418
|
+
await waitFor(() => {
|
|
419
|
+
expect(politeRegion()).toHaveTextContent('Page 10 of 10');
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('disables first on the first page and last on the last page', () => {
|
|
424
|
+
const {rerender} = render(
|
|
425
|
+
<Pagination
|
|
426
|
+
page={1}
|
|
427
|
+
onChange={() => {}}
|
|
428
|
+
totalPages={10}
|
|
429
|
+
variant="compact"
|
|
430
|
+
hasFirstLast
|
|
431
|
+
/>,
|
|
432
|
+
);
|
|
433
|
+
expect(
|
|
434
|
+
screen.getByRole('button', {name: 'Go to first page'}),
|
|
435
|
+
).toBeDisabled();
|
|
436
|
+
expect(
|
|
437
|
+
screen.getByRole('button', {name: 'Go to last page'}),
|
|
438
|
+
).not.toBeDisabled();
|
|
439
|
+
|
|
440
|
+
rerender(
|
|
441
|
+
<Pagination
|
|
442
|
+
page={10}
|
|
443
|
+
onChange={() => {}}
|
|
444
|
+
totalPages={10}
|
|
445
|
+
variant="compact"
|
|
446
|
+
hasFirstLast
|
|
447
|
+
/>,
|
|
448
|
+
);
|
|
449
|
+
expect(
|
|
450
|
+
screen.getByRole('button', {name: 'Go to first page'}),
|
|
451
|
+
).not.toBeDisabled();
|
|
452
|
+
expect(
|
|
453
|
+
screen.getByRole('button', {name: 'Go to last page'}),
|
|
454
|
+
).toBeDisabled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('omits first/last when the total page count is unknown', async () => {
|
|
458
|
+
const user = userEvent.setup();
|
|
459
|
+
const onChange = vi.fn();
|
|
460
|
+
render(
|
|
461
|
+
<Pagination
|
|
462
|
+
page={2}
|
|
463
|
+
onChange={onChange}
|
|
464
|
+
hasMore
|
|
465
|
+
variant="compact"
|
|
466
|
+
hasFirstLast
|
|
467
|
+
/>,
|
|
468
|
+
);
|
|
469
|
+
expect(navButtonNames()).toEqual([
|
|
470
|
+
'Go to previous page',
|
|
471
|
+
'Go to next page',
|
|
472
|
+
]);
|
|
473
|
+
await user.click(screen.getByRole('button', {name: 'Go to next page'}));
|
|
474
|
+
expect(onChange).toHaveBeenLastCalledWith(3);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('mirrors the first/last chevrons under RTL like prev/next', () => {
|
|
478
|
+
render(
|
|
479
|
+
<InternationalizationProvider locale="en" dir="rtl">
|
|
480
|
+
<div dir="rtl">
|
|
481
|
+
<Pagination
|
|
482
|
+
page={5}
|
|
483
|
+
onChange={() => {}}
|
|
484
|
+
totalPages={10}
|
|
485
|
+
variant="compact"
|
|
486
|
+
hasFirstLast
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
</InternationalizationProvider>,
|
|
490
|
+
);
|
|
491
|
+
// jsdom applies no CSS transforms, so assert the first/last icons carry
|
|
492
|
+
// every class the mirrored prev/next icons share (the mirror included).
|
|
493
|
+
const iconClasses = (name: string) =>
|
|
494
|
+
new Set(
|
|
495
|
+
screen
|
|
496
|
+
.getByRole('button', {name})
|
|
497
|
+
.querySelector('.astryx-icon')
|
|
498
|
+
?.getAttribute('class')
|
|
499
|
+
?.split(/\s+/) ?? [],
|
|
500
|
+
);
|
|
501
|
+
const prev = iconClasses('Go to previous page');
|
|
502
|
+
const shared = [...iconClasses('Go to next page')].filter(name =>
|
|
503
|
+
prev.has(name),
|
|
504
|
+
);
|
|
505
|
+
expect(shared.length).toBeGreaterThan(0);
|
|
506
|
+
for (const name of ['Go to first page', 'Go to last page']) {
|
|
507
|
+
const classes = iconClasses(name);
|
|
508
|
+
expect(shared.filter(c => !classes.has(c))).toEqual([]);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe('hasFirstLast on other variants', () => {
|
|
515
|
+
it.each(['pages', 'count', 'dots', 'none'] as const)(
|
|
516
|
+
'does not add first/last to the %s variant',
|
|
517
|
+
variant => {
|
|
518
|
+
render(
|
|
519
|
+
<Pagination
|
|
520
|
+
page={3}
|
|
521
|
+
onChange={() => {}}
|
|
522
|
+
totalItems={50}
|
|
523
|
+
pageSize={10}
|
|
524
|
+
variant={variant}
|
|
525
|
+
hasFirstLast
|
|
526
|
+
/>,
|
|
527
|
+
);
|
|
528
|
+
expect(
|
|
529
|
+
screen.queryByRole('button', {name: 'Go to first page'}),
|
|
530
|
+
).not.toBeInTheDocument();
|
|
531
|
+
expect(
|
|
532
|
+
screen.queryByRole('button', {name: 'Go to last page'}),
|
|
533
|
+
).not.toBeInTheDocument();
|
|
534
|
+
},
|
|
535
|
+
);
|
|
294
536
|
});
|
|
295
537
|
|
|
296
538
|
// ---------------------------------------------------------------------------
|