@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.
Files changed (39) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +38 -14
  3. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  4. package/dist/MultiSelector/hooks.js +3 -1
  5. package/dist/Pagination/Pagination.d.ts +8 -5
  6. package/dist/Pagination/Pagination.d.ts.map +1 -1
  7. package/dist/Pagination/Pagination.js +21 -8
  8. package/dist/Switch/Switch.d.ts +1 -1
  9. package/dist/Switch/Switch.d.ts.map +1 -1
  10. package/dist/Switch/Switch.js +6 -0
  11. package/dist/astryx.css +1 -0
  12. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  13. package/dist/i18n/generated-locales/en.generated.js +19 -0
  14. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  15. package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
  16. package/locales/en.json +76 -0
  17. package/locales/pseudo.json +57 -0
  18. package/package.json +4 -4
  19. package/src/Button/Button.doc.mjs +2 -2
  20. package/src/Button/Button.test.tsx +155 -9
  21. package/src/Button/Button.tsx +39 -16
  22. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  23. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  24. package/src/IconButton/IconButton.doc.mjs +2 -2
  25. package/src/IconButton/IconButton.test.tsx +3 -1
  26. package/src/MultiSelector/hooks.ts +3 -1
  27. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  28. package/src/Pagination/Pagination.doc.mjs +7 -7
  29. package/src/Pagination/Pagination.spec.md +9 -9
  30. package/src/Pagination/Pagination.test.tsx +242 -0
  31. package/src/Pagination/Pagination.tsx +27 -11
  32. package/src/Switch/Switch.test.tsx +28 -0
  33. package/src/Switch/Switch.tsx +6 -0
  34. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  35. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  36. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  37. package/src/i18n/generated-locales/en.generated.ts +21 -0
  38. package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
  39. 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. Only applies to the 'input' variant; omitted when the page count is unknown (cursor/hasMore pagination).",
154
- default: 'true',
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','dots' 显示点指示器,'input' 显示带前置标签的可编辑页码框——'Page [ n ] / N'——默认两侧带首页/末页按钮,'none' 仅显示上一页/下一页按钮。",
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
- "是否显示首页/末页(«/»)双箭头按钮,位于上一页/下一页两侧。仅适用于 'input' 变体;当页数未知(游标/hasMore 分页)时省略。",
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 in the 'input' variant. Omitted when total unknown.",
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 | 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. | Current and total page text. |
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. |
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
  // ---------------------------------------------------------------------------