@astryxdesign/core 0.7.1 → 0.7.2-canary.c68f5e8

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 (35) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +38 -14
  3. package/dist/Pagination/Pagination.d.ts +8 -5
  4. package/dist/Pagination/Pagination.d.ts.map +1 -1
  5. package/dist/Pagination/Pagination.js +21 -8
  6. package/dist/Switch/Switch.d.ts +1 -1
  7. package/dist/Switch/Switch.d.ts.map +1 -1
  8. package/dist/Switch/Switch.js +6 -0
  9. package/dist/astryx.css +1 -0
  10. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  11. package/dist/i18n/generated-locales/en.generated.js +19 -0
  12. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  13. package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
  14. package/locales/en.json +76 -0
  15. package/locales/pseudo.json +57 -0
  16. package/package.json +4 -4
  17. package/src/Button/Button.doc.mjs +2 -2
  18. package/src/Button/Button.test.tsx +155 -9
  19. package/src/Button/Button.tsx +39 -16
  20. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  21. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  22. package/src/IconButton/IconButton.doc.mjs +2 -2
  23. package/src/IconButton/IconButton.test.tsx +3 -1
  24. package/src/Pagination/Pagination.doc.mjs +7 -7
  25. package/src/Pagination/Pagination.spec.md +9 -9
  26. package/src/Pagination/Pagination.test.tsx +242 -0
  27. package/src/Pagination/Pagination.tsx +27 -11
  28. package/src/Switch/Switch.test.tsx +28 -0
  29. package/src/Switch/Switch.tsx +6 -0
  30. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  31. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  32. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  33. package/src/i18n/generated-locales/en.generated.ts +21 -0
  34. package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
  35. package/src/i18n/useTranslator.test.tsx +37 -0
@@ -11,7 +11,13 @@
11
11
 
12
12
  import {readFileSync} from 'node:fs';
13
13
  import {describe, it, expect, vi} from 'vitest';
14
- import {render, screen, fireEvent, act} from '@testing-library/react';
14
+ import {
15
+ render,
16
+ screen,
17
+ fireEvent,
18
+ createEvent,
19
+ act,
20
+ } from '@testing-library/react';
15
21
  import userEvent from '@testing-library/user-event';
16
22
  import {Button} from './Button';
17
23
  import * as stylex from '@stylexjs/stylex';
@@ -127,8 +133,9 @@ describe('Button', () => {
127
133
  it('shows isLoading state with spinner', () => {
128
134
  render(<Button label="Submit" isLoading />);
129
135
  const button = screen.getByRole('button');
130
- // Button should be disabled when loading
131
- expect(button).toBeDisabled();
136
+ // Busy is aria-disabled (focusable), not native disabled — see #4871.
137
+ expect(button).not.toBeDisabled();
138
+ expect(button).toHaveAttribute('aria-disabled', 'true');
132
139
  expect(button.className).toContain('styles.inactive');
133
140
  expect(button.className).not.toContain('styles.disabled');
134
141
  });
@@ -143,7 +150,7 @@ describe('Button', () => {
143
150
 
144
151
  it('sets aria-busy synchronously while clickAction is pending', async () => {
145
152
  // The spinner reveal is visually delayed (CSS animation-delay), but the
146
- // loading DOM state — aria-busy and disabled — must not be delayed.
153
+ // loading DOM state — aria-busy and aria-disabled — must not be delayed.
147
154
  const user = userEvent.setup();
148
155
  let resolveAction: (() => void) | undefined;
149
156
  const clickAction = vi.fn(
@@ -157,7 +164,9 @@ describe('Button', () => {
157
164
 
158
165
  await user.click(button);
159
166
  expect(button).toHaveAttribute('aria-busy', 'true');
160
- expect(button).toBeDisabled();
167
+ // Busy is aria-disabled (focusable), not native disabled — see #4871.
168
+ expect(button).not.toBeDisabled();
169
+ expect(button).toHaveAttribute('aria-disabled', 'true');
161
170
 
162
171
  await act(async () => {
163
172
  resolveAction?.();
@@ -165,6 +174,7 @@ describe('Button', () => {
165
174
  });
166
175
  expect(button).not.toHaveAttribute('aria-busy', 'true');
167
176
  expect(button).not.toBeDisabled();
177
+ expect(button).not.toHaveAttribute('aria-disabled');
168
178
  });
169
179
 
170
180
  it('renders the loading spinner with the inherit shade for every variant (#2717)', () => {
@@ -310,9 +320,10 @@ describe('Button', () => {
310
320
  );
311
321
  // endContent should still be in the DOM
312
322
  expect(screen.getByTestId('end')).toBeInTheDocument();
313
- // Button should be disabled and have aria-busy
323
+ // Busy is aria-disabled (focusable), not native disabled — see #4871.
314
324
  const button = screen.getByRole('button');
315
- expect(button).toBeDisabled();
325
+ expect(button).not.toBeDisabled();
326
+ expect(button).toHaveAttribute('aria-disabled', 'true');
316
327
  expect(button).toHaveAttribute('aria-busy', 'true');
317
328
  });
318
329
 
@@ -516,6 +527,111 @@ describe('Button', () => {
516
527
  expect(handleKeyDown).toHaveBeenCalledTimes(1);
517
528
  });
518
529
 
530
+ it('uses aria-disabled instead of disabled while isLoading, so the button stays focusable (#4871)', () => {
531
+ render(<Button label="Save" isLoading />);
532
+ const button = screen.getByRole('button');
533
+ // Native disabled would move focus to <body> the moment the action
534
+ // starts, losing the keyboard user's place — busy must stay
535
+ // aria-disabled + focusable per the "Disabled vs Busy" convention.
536
+ expect(button).not.toHaveAttribute('disabled');
537
+ expect(button).toHaveAttribute('aria-disabled', 'true');
538
+ expect(button).toHaveAttribute('aria-busy', 'true');
539
+ button.focus();
540
+ expect(button).toHaveFocus();
541
+ });
542
+
543
+ it('does not fire onClick while aria-disabled via isLoading', async () => {
544
+ const user = userEvent.setup();
545
+ const handleClick = vi.fn();
546
+ render(<Button label="Save" isLoading onClick={handleClick} />);
547
+ await user.click(screen.getByRole('button'));
548
+ expect(handleClick).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it('does not let a click on a busy button reach a wrapping ancestor handler', async () => {
552
+ // A native `disabled` element never dispatches a click, so it never
553
+ // reaches an ancestor either. aria-disabled (busy-only) is a real,
554
+ // interactive element that still dispatches one — preventDefault alone
555
+ // only cancels the button's own default action, not propagation, so
556
+ // an unrelated wrapper (a clickable row/card/backdrop) would otherwise
557
+ // still fire.
558
+ const user = userEvent.setup();
559
+ const ancestorClick = vi.fn();
560
+ render(
561
+ <div onClick={ancestorClick}>
562
+ <Button label="Save" isLoading />
563
+ </div>,
564
+ );
565
+ await user.click(screen.getByRole('button'));
566
+ expect(ancestorClick).not.toHaveBeenCalled();
567
+ });
568
+
569
+ it('does not let a click on a busy-only anchor Button reach a wrapping ancestor handler', async () => {
570
+ const user = userEvent.setup();
571
+ const ancestorClick = vi.fn();
572
+ render(
573
+ <div onClick={ancestorClick}>
574
+ <Button label="Docs" href="https://example.com" isLoading />
575
+ </div>,
576
+ );
577
+ await user.click(screen.getByRole('link'));
578
+ expect(ancestorClick).not.toHaveBeenCalled();
579
+ });
580
+
581
+ it('suppresses activation keys but passes other keys while aria-disabled via isLoading', async () => {
582
+ const user = userEvent.setup();
583
+ const handleKeyDown = vi.fn();
584
+ render(<Button label="Save" isLoading onKeyDown={handleKeyDown} />);
585
+ const button = screen.getByRole('button');
586
+ button.focus();
587
+ await user.keyboard('{Enter}');
588
+ expect(handleKeyDown).not.toHaveBeenCalled();
589
+
590
+ await user.keyboard('{Escape}');
591
+ expect(handleKeyDown).toHaveBeenCalledTimes(1);
592
+ });
593
+
594
+ it('suppresses activation keys but passes other keys on a busy-only anchor Button', async () => {
595
+ // The button-rendered path suppresses Enter/Space while aria-disabled;
596
+ // the anchor-rendered path (href set, busy-only) shares the same
597
+ // handleKeyDown but only wires onClick, so a consumer onKeyDown still
598
+ // received Enter/Space on a busy link.
599
+ const user = userEvent.setup();
600
+ const handleKeyDown = vi.fn();
601
+ render(
602
+ <Button
603
+ label="Docs"
604
+ href="https://example.com"
605
+ isLoading
606
+ onKeyDown={handleKeyDown}
607
+ />,
608
+ );
609
+ const link = screen.getByRole('link');
610
+ link.focus();
611
+ await user.keyboard('{Enter}');
612
+ expect(handleKeyDown).not.toHaveBeenCalled();
613
+
614
+ await user.keyboard('{Escape}');
615
+ expect(handleKeyDown).toHaveBeenCalledTimes(1);
616
+ });
617
+
618
+ it('keeps native disabled (not aria-disabled) when isDisabled and not busy, even without a tooltip', () => {
619
+ render(<Button label="Save" isDisabled />);
620
+ const button = screen.getByRole('button');
621
+ expect(button).toHaveAttribute('disabled');
622
+ expect(button).not.toHaveAttribute('aria-disabled');
623
+ });
624
+
625
+ it('keeps native disabled when isDisabled and isLoading are both true (isDisabled wins)', () => {
626
+ render(<Button label="Save" isDisabled isLoading />);
627
+ const button = screen.getByRole('button');
628
+ // "isDisabled behavior unchanged: that stays native disabled" — busy-only
629
+ // aria-disabled is only for the case where busy is the SOLE reason the
630
+ // button can't be clicked, not when it's also genuinely isDisabled.
631
+ expect(button).toHaveAttribute('disabled');
632
+ expect(button).not.toHaveAttribute('aria-disabled');
633
+ });
634
+
519
635
  it('has a live region that announces loading state', () => {
520
636
  const {rerender} = render(<Button label="Submit" />);
521
637
  const button = screen.getByRole('button');
@@ -663,8 +779,6 @@ describe('Button', () => {
663
779
  });
664
780
 
665
781
  it('exposes aria-busy on the link-rendered button while loading', () => {
666
- // Non-interruptible loading disables the button, which falls back to
667
- // <button> rendering — so an anchor only shows loading when interruptible.
668
782
  render(
669
783
  <Button
670
784
  label="Docs"
@@ -682,6 +796,38 @@ describe('Button', () => {
682
796
  const link = screen.getByRole('link');
683
797
  expect(link).not.toHaveAttribute('aria-busy');
684
798
  });
799
+
800
+ it('stays an anchor while busy-only, not a disabled <button> fallback (#4871)', () => {
801
+ // Non-interruptible busy is exactly the isBusyOnlyDisabled case: not
802
+ // isDisabled, blocked only because a fire-once action is in flight.
803
+ // Swapping the element to <button disabled> here would drop focus the
804
+ // same way the native disabled attribute did (credit @AKnassa, #4879).
805
+ render(<Button label="Docs" href="https://example.com" isLoading />);
806
+ const link = screen.getByRole('link', {name: 'Docs'});
807
+ expect(link.tagName).toBe('A');
808
+ expect(link).toHaveAttribute('href', 'https://example.com');
809
+ expect(link).toHaveAttribute('aria-busy', 'true');
810
+ expect(link).toHaveAttribute('aria-disabled', 'true');
811
+ expect(link).not.toHaveAttribute('disabled');
812
+ });
813
+
814
+ it('keeps a busy-only anchor focusable and guards its navigation', () => {
815
+ render(<Button label="Docs" href="https://example.com" isLoading />);
816
+ const link = screen.getByRole('link', {name: 'Docs'});
817
+ link.focus();
818
+ expect(link).toHaveFocus();
819
+
820
+ const event = createEvent.click(link);
821
+ fireEvent(link, event);
822
+ expect(event.defaultPrevented).toBe(true);
823
+ });
824
+
825
+ it('falls back to <button disabled> for a truly disabled href Button', () => {
826
+ render(<Button label="Docs" href="https://example.com" isDisabled />);
827
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
828
+ const button = screen.getByRole('button', {name: 'Docs'});
829
+ expect(button).toBeDisabled();
830
+ });
685
831
  });
686
832
 
687
833
  describe('Button pressed state (touch)', () => {
@@ -594,26 +594,37 @@ export function Button({
594
594
  // the consumer is deliberately showing it.
595
595
  const delaySpinner = isPending || isInterruptible;
596
596
  const groupDisabled = buttonGroup?.isDisabled ?? false;
597
- // When interruptible, the loading state drives the spinner and aria-busy but
598
- // not disabled, so clicks keep landing and can interrupt the in-flight action.
599
- const buttonDisabled =
600
- isDisabled || groupDisabled || (isLoadingState && !isInterruptible);
601
- // A loading button remains non-interactive, but its spinner communicates an
602
- // active state and must retain contrast. Only explicitly disabled controls
603
- // receive the visually dimmed treatment.
604
- const visuallyDisabled = isDisabled || groupDisabled;
597
+ const isTrueDisabled = isDisabled || groupDisabled;
598
+ // Busy-only: the button can't fire again purely because an action is in
599
+ // flight, not because it's genuinely isDisabled/group-disabled. Per the API
600
+ // Conventions "Disabled vs Busy" rule, busy is visual-only (aria-busy +
601
+ // spinner) and must never take focusability away — the click-handler ref
602
+ // guard (actionInFlightRef) is what actually makes a fire-once action fire
603
+ // once, so the native disabled attribute adds nothing but a focus loss
604
+ // here. When interruptible, the loading state drives the spinner and
605
+ // aria-busy but not disabled at all, so clicks keep landing and can
606
+ // interrupt the in-flight action.
607
+ const isBusyOnlyDisabled =
608
+ !isTrueDisabled && isLoadingState && !isInterruptible;
609
+ const buttonDisabled = isTrueDisabled || isBusyOnlyDisabled;
605
610
  // isIconOnly prop is the source of truth for icon-only rendering.
606
611
  // When false (default), label is always rendered as visible text.
607
612
 
608
613
  const LinkComponent = useLinkComponent(as);
609
614
 
610
- // Render as link when href is provided and button is not disabled.
611
- // Disabled links are an accessibility anti-pattern — fall back to <button>.
612
- const renderAsLink = href != null && !buttonDisabled;
615
+ // Render as link when href is provided and the button is not TRULY
616
+ // disabled. Disabled links are an accessibility anti-pattern, so a
617
+ // genuinely isDisabled/group-disabled button falls back to <button>.
618
+ // Busy-only does NOT fall back: swapping <a> for a disabled <button>
619
+ // mid-action would drop focus the same way the native attribute does
620
+ // (credit @AKnassa, #4879), which is exactly the bug this PR fixes.
621
+ const renderAsLink = href != null && !isTrueDisabled;
613
622
 
614
- // Use aria-disabled when tooltip is present so the button remains focusable
615
- // for keyboard users to reach the tooltip. Otherwise use native disabled.
616
- const useAriaDisabled = tooltip != null && buttonDisabled;
623
+ // Use aria-disabled (button stays focusable) when busy-only, or when a
624
+ // tooltip is present so keyboard users can still reach it. A genuinely
625
+ // isDisabled/group-disabled button (not busy) keeps native disabled.
626
+ const useAriaDisabled =
627
+ buttonDisabled && (isBusyOnlyDisabled || tooltip != null);
617
628
 
618
629
  // Attach tooltip behavior via the hook rather than wrapping the button in a
619
630
  // <Tooltip> element. The hook adds hover/focus triggers to the button itself,
@@ -630,6 +641,16 @@ export function Button({
630
641
  // a re-click while pending starts a fresh action that interrupts the prior.
631
642
  if (buttonDisabled || (actionInFlightRef.current && !isInterruptible)) {
632
643
  e.preventDefault();
644
+ // A native `disabled` element never dispatches a click at all, so it
645
+ // never reaches a wrapping element's own handler either. An
646
+ // aria-disabled button (busy-only, or with a tooltip) is still a real,
647
+ // interactive element the DOM keeps dispatching clicks on —
648
+ // preventDefault alone only cancels the button's OWN default action
649
+ // (form submit, link navigation), not propagation. Without this, an
650
+ // unavailable Button inside a clickable row/card/backdrop still
651
+ // triggers that ancestor's action, which the previously native-disabled
652
+ // control never did.
653
+ e.stopPropagation();
633
654
  return;
634
655
  }
635
656
  props.onClick?.(e);
@@ -669,7 +690,7 @@ export function Button({
669
690
  isIconOnly && styles.iconOnly,
670
691
  interactionOverlayStyles.backgroundImage,
671
692
  buttonDisabled && styles.inactive,
672
- visuallyDisabled && styles.disabled,
693
+ isTrueDisabled && styles.disabled,
673
694
  useAriaDisabled && styles.ariaDisabled,
674
695
  renderAsLink && styles.link,
675
696
  !buttonGroup && styles.pressable,
@@ -797,7 +818,9 @@ export function Button({
797
818
  {...describedByProp}
798
819
  {...edgeCompAttr}
799
820
  aria-busy={isLoadingState || undefined}
800
- onClick={handleClick}>
821
+ aria-disabled={useAriaDisabled || undefined}
822
+ onClick={handleClick}
823
+ {...(handleKeyDown ? {onKeyDown: handleKeyDown} : null)}>
801
824
  {buttonContent}
802
825
  </LinkComponent>
803
826
  );
@@ -22,65 +22,4 @@
22
22
 
23
23
  import type {KnownFailure} from '@astryxdesign/a11y-spec';
24
24
 
25
- export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
26
- // ---- Button and IconButton go natively disabled while busy --------------
27
- // One defect, one record per component.
28
- // They are written out rather than generated, because a record has to name
29
- // exactly one thing that is broken — a loop would make it easy to widen this
30
- // later without anyone noticing.
31
- {
32
- expectation: 'button.focus.reachable-and-escapable',
33
- binding: 'Button',
34
- state: 'button-loading',
35
- evidenceLayer: 'real-browser',
36
- failureEquals:
37
- '10 presses of Tab from the start of the document never reached the button, so a keyboard user cannot get to this action',
38
- standardsReference:
39
- 'WCAG 2.2 2.1.1 Keyboard and 2.1.2 No Keyboard Trap (Level A)',
40
- userImpact:
41
- 'A keyboard user who activates Save and waits is dropped to the top of the document the moment the action starts, and the button leaves the tab sequence entirely — so they cannot get back to it, to see that it is busy or to interrupt it. The next Tab restarts from the beginning of the page.',
42
- reason:
43
- 'Button sets the native disabled attribute while a clickAction is pending. A natively disabled element is neither focusable nor a tab stop. Separate implementation work will turn this record into an unexpected pass.',
44
- },
45
- {
46
- expectation: 'button.focus.reachable-and-escapable',
47
- binding: 'IconButton',
48
- state: 'icon-button-loading',
49
- evidenceLayer: 'real-browser',
50
- failureEquals:
51
- '10 presses of Tab from the start of the document never reached the button, so a keyboard user cannot get to this action',
52
- standardsReference:
53
- 'WCAG 2.2 2.1.1 Keyboard and 2.1.2 No Keyboard Trap (Level A)',
54
- userImpact:
55
- 'The same drop from an icon button, where it is worse: an icon button is usually one of several in a row, so the user loses their place among them.',
56
- reason:
57
- 'IconButton is a thin wrapper over Button and inherits the behaviour exactly.',
58
- },
59
-
60
- {
61
- expectation: 'button.unavailable.inert',
62
- binding: 'Button',
63
- state: 'button-loading',
64
- evidenceLayer: 'real-browser',
65
- failureEquals:
66
- 'this state is declared focusable so its reason stays reachable, but it cannot take focus — so a keyboard user can neither read why it is unavailable nor reach it to confirm it refuses to act',
67
- standardsReference:
68
- 'Current Astryx family:buttons FR3 and WAI-ARIA APG Button unavailable-state requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
69
- userImpact:
70
- 'The third face of the same defect: because the busy button cannot take focus, there is no way to confirm from the keyboard that it declines a second press — the user can neither reach it nor see why it is unavailable.',
71
- reason:
72
- 'Same native disabled attribute. Recorded separately because a record names exactly one outcome, and a fix that restored focus without keeping the button inert would satisfy one of these and not the other.',
73
- },
74
- {
75
- expectation: 'button.unavailable.inert',
76
- binding: 'IconButton',
77
- state: 'icon-button-loading',
78
- evidenceLayer: 'real-browser',
79
- failureEquals:
80
- 'this state is declared focusable so its reason stays reachable, but it cannot take focus — so a keyboard user can neither read why it is unavailable nor reach it to confirm it refuses to act',
81
- standardsReference:
82
- 'Current Astryx family:buttons FR3 and WAI-ARIA APG Button unavailable-state requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
83
- userImpact: 'The same, from an icon button.',
84
- reason: 'Inherited from Button, as above.',
85
- },
86
- ];
25
+ export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [];
@@ -177,16 +177,9 @@ export const BUTTON_BINDING_STATES = [
177
177
  binding: 'Button',
178
178
  summary:
179
179
  'a button waiting on the action it started: the action is unavailable, and it is meant to stay focusable so the user can see that and interrupt it',
180
- // `focusable: true` is the declaration, and the component does not deliver
181
- // it — see the known-failure records. Declaring what the component does
182
- // instead would make the expectations that catch it not-applicable, which
183
- // is how a contract quietly stops noticing a defect.
184
180
  facts: facts({operable: false, unavailable: true}),
185
181
  visibleLabel: null,
186
182
  storyId: 'a11y-button-pattern--button-loading',
187
- declaredNotDelivered: [
188
- {fact: 'focusable', owned: 'button.focus.reachable-and-escapable'},
189
- ],
190
183
  },
191
184
 
192
185
  // ---- IconButton ---------------------------------------------------------
@@ -214,9 +207,6 @@ export const BUTTON_BINDING_STATES = [
214
207
  facts: facts({operable: false, unavailable: true}),
215
208
  visibleLabel: null,
216
209
  storyId: 'a11y-button-pattern--icon-button-loading',
217
- declaredNotDelivered: [
218
- {fact: 'focusable', owned: 'button.focus.reachable-and-escapable'},
219
- ],
220
210
  },
221
211
 
222
212
  // ---- ClickableCard ------------------------------------------------------
@@ -46,7 +46,7 @@ export const docs = {
46
46
  {
47
47
  name: 'isLoading',
48
48
  type: 'boolean',
49
- description: 'Shows a loading spinner and disables interaction.',
49
+ description: 'Shows a loading spinner and blocks re-activation while staying focusable (aria-busy + aria-disabled, not native disabled).',
50
50
  default: 'false',
51
51
  },
52
52
  {
@@ -141,7 +141,7 @@ export const docsDense = {
141
141
  variant: 'visual style variant',
142
142
  size: 'size variant',
143
143
  elevation: 'resting shadow depth: none|low|med|high; raise for a floating action button (FAB)',
144
- isLoading: 'shows loading spinner + disables interaction',
144
+ isLoading: 'shows loading spinner, blocks re-activation but stays focusable (aria-disabled, not native disabled)',
145
145
  isDisabled: 'disables button',
146
146
  tooltip: 'tooltip text shown on hover',
147
147
  onClick: 'standard click handler',
@@ -124,7 +124,9 @@ describe('IconButton', () => {
124
124
  it('shows loading state', () => {
125
125
  render(<IconButton label="Save" icon={<span>💾</span>} isLoading />);
126
126
  const button = screen.getByRole('button');
127
- expect(button).toBeDisabled();
127
+ // Busy is aria-disabled (focusable), not native disabled — see #4871.
128
+ expect(button).not.toBeDisabled();
129
+ expect(button).toHaveAttribute('aria-disabled', 'true');
128
130
  });
129
131
 
130
132
  it('forwards ref correctly', () => {
@@ -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.