@astryxdesign/core 0.7.1 → 0.7.2-canary.374b53a

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 (70) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +38 -14
  3. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  4. package/dist/HoverCard/useHoverCard.js +10 -2
  5. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  6. package/dist/MultiSelector/hooks.js +3 -1
  7. package/dist/Pagination/Pagination.d.ts +8 -5
  8. package/dist/Pagination/Pagination.d.ts.map +1 -1
  9. package/dist/Pagination/Pagination.js +21 -8
  10. package/dist/PowerSearch/PowerSearch.js +1 -1
  11. package/dist/Selector/hooks.d.ts.map +1 -1
  12. package/dist/Selector/hooks.js +6 -2
  13. package/dist/Switch/Switch.d.ts +1 -1
  14. package/dist/Switch/Switch.d.ts.map +1 -1
  15. package/dist/Switch/Switch.js +13 -1
  16. package/dist/Text/useTruncation.d.ts +7 -5
  17. package/dist/Text/useTruncation.d.ts.map +1 -1
  18. package/dist/Text/useTruncation.js +16 -5
  19. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  20. package/dist/Tooltip/useTooltip.js +11 -0
  21. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  22. package/dist/Typeahead/BaseTypeahead.js +26 -0
  23. package/dist/astryx.css +1 -0
  24. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  25. package/dist/i18n/generated-locales/en.generated.js +19 -0
  26. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  27. package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
  28. package/dist/utils/sharedMutationObserver.d.ts +21 -0
  29. package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
  30. package/dist/utils/sharedMutationObserver.js +69 -0
  31. package/locales/en.json +76 -0
  32. package/locales/pseudo.json +57 -0
  33. package/package.json +4 -4
  34. package/src/Button/Button.doc.mjs +2 -2
  35. package/src/Button/Button.test.tsx +155 -9
  36. package/src/Button/Button.tsx +39 -16
  37. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  38. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  39. package/src/HoverCard/HoverCard.test.tsx +58 -0
  40. package/src/HoverCard/useHoverCard.tsx +10 -2
  41. package/src/IconButton/IconButton.doc.mjs +2 -2
  42. package/src/IconButton/IconButton.test.tsx +3 -1
  43. package/src/List/ListItem.doc.mjs +8 -5
  44. package/src/MultiSelector/hooks.ts +3 -1
  45. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  46. package/src/Pagination/Pagination.doc.mjs +7 -7
  47. package/src/Pagination/Pagination.spec.md +9 -9
  48. package/src/Pagination/Pagination.test.tsx +242 -0
  49. package/src/Pagination/Pagination.tsx +27 -11
  50. package/src/PowerSearch/PowerSearch.test.tsx +85 -0
  51. package/src/PowerSearch/PowerSearch.tsx +2 -2
  52. package/src/Selector/hooks.ts +6 -2
  53. package/src/Selector/useCombobox.doc.mjs +237 -0
  54. package/src/Switch/Switch.test.tsx +49 -0
  55. package/src/Switch/Switch.tsx +11 -0
  56. package/src/Text/useTruncation.test.tsx +69 -0
  57. package/src/Text/useTruncation.ts +16 -5
  58. package/src/Tokenizer/Tokenizer.test.tsx +54 -1
  59. package/src/Tooltip/useTooltip.test.tsx +13 -0
  60. package/src/Tooltip/useTooltip.tsx +11 -0
  61. package/src/Typeahead/BaseTypeahead.tsx +26 -0
  62. package/src/Typeahead/Typeahead.test.tsx +260 -2
  63. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  64. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  65. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  66. package/src/i18n/generated-locales/en.generated.ts +21 -0
  67. package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
  68. package/src/i18n/useTranslator.test.tsx +37 -0
  69. package/src/utils/sharedMutationObserver.test.ts +53 -0
  70. package/src/utils/sharedMutationObserver.ts +78 -0
@@ -139,6 +139,25 @@ const runtimeCatalog = {
139
139
  '@astryx.listInput.announceReturnedToPosition': '⟦{itemName} řéţúřñéð ţó þóšíţíóñ {position, number}.⟧',
140
140
  '@astryx.listInput.announceDropped': '⟦{itemName} ðřóþþéð àţ þóšíţíóñ {position, number} óƒ {total, number}.⟧',
141
141
  '@astryx.listInput.announceAlreadyAtBoundary': '⟦Ţĥíš {itemName} íš àłřéàðý {boundary, select, first {first} last {last} other {}}.⟧',
142
+ '@astryx.diffViewer.label': '⟦Ð탃⟧',
143
+ '@astryx.diffViewer.renamedPath': '⟦{oldPath} → {newPath}⟧',
144
+ '@astryx.diffViewer.statusAdded': '⟦Àððéð⟧',
145
+ '@astryx.diffViewer.statusDeleted': '⟦Ðéłéţéð⟧',
146
+ '@astryx.diffViewer.statusRenamed': '⟦Řéñàɱéð⟧',
147
+ '@astryx.diffViewer.addedCount': '⟦+{count, number}⟧',
148
+ '@astryx.diffViewer.removedCount': '⟦−{count, number}⟧',
149
+ '@astryx.diffViewer.stats': '⟦{added, plural, one {# line added} other {# lines added}}, {removed, plural, one {# line removed} other {# lines removed}}⟧',
150
+ '@astryx.diffViewer.added': '⟦Àððéð⟧',
151
+ '@astryx.diffViewer.removed': '⟦Řéɱóṽéð⟧',
152
+ '@astryx.diffViewer.lineNumberButton': '⟦{side, select, old {Original line {line, number}} other {Changed line {line, number}}}⟧',
153
+ '@astryx.diffViewer.columnOldLine': '⟦Óříĝíñàł łíñé ñúɱƀéř⟧',
154
+ '@astryx.diffViewer.columnNewLine': '⟦Çĥàñĝéð łíñé ñúɱƀéř⟧',
155
+ '@astryx.diffViewer.columnChange': '⟦Çĥàñĝé⟧',
156
+ '@astryx.diffViewer.columnCode': '⟦Çóðé⟧',
157
+ '@astryx.diffViewer.columnOldCode': '⟦Óříĝíñàł çóðé⟧',
158
+ '@astryx.diffViewer.columnNewCode': '⟦Çĥàñĝéð çóðé⟧',
159
+ '@astryx.diffViewer.binary': '⟦Ɓíñàřý ƒíłé ñóţ šĥóŵñ⟧',
160
+ '@astryx.diffViewer.noChanges': '⟦Ñó çóñţéñţ çĥàñĝéš⟧',
142
161
  '@astryx.markdownHeadingLinks.copied': '⟦Łíñķ çóþíéð⟧',
143
162
  '@astryx.markdownHeadingLinks.copy': '⟦Çóþý łíñķ ţó {heading}⟧',
144
163
  '@astryx.richTextEditor.tabEscapeHint': '⟦Þřéšš Éšçàþé ţĥéñ Ţàƀ ţó ɱóṽé ƒóçúš óúţ óƒ ţĥé éðíţóř.⟧',
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file sharedMutationObserver.ts
3
+ * @input MutationObserver API
4
+ * @output Exports observeContent for shared content-change observation
5
+ * @position Internal utility; consumed by useTruncation. The content-change
6
+ * counterpart of sharedResizeObserver: one observer for every element, so a
7
+ * repeated cell or row path does not create one observer per instance.
8
+ */
9
+ type ContentCallback = () => void;
10
+ /**
11
+ * Call `callback` when the element's text or children change (including
12
+ * descendants). Returns an unsubscribe that removes only this registration.
13
+ *
14
+ * MutationObserver has no per-target unobserve, so a released element stays
15
+ * registered with the shared observer until no element remains; its records
16
+ * match no callback and are ignored. The observer disconnects and is released
17
+ * when the last registration goes.
18
+ */
19
+ export declare function observeContent(element: Element, callback: ContentCallback): () => void;
20
+ export {};
21
+ //# sourceMappingURL=sharedMutationObserver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sharedMutationObserver.d.ts","sourceRoot":"","sources":["../../src/utils/sharedMutationObserver.ts"],"names":[],"mappings":"AAIA;;;;;;;GAOG;AAEH,KAAK,eAAe,GAAG,MAAM,IAAI,CAAC;AAuBlC;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,eAAe,GACxB,MAAM,IAAI,CA6BZ"}
@@ -0,0 +1,69 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file sharedMutationObserver.ts
7
+ * @input MutationObserver API
8
+ * @output Exports observeContent for shared content-change observation
9
+ * @position Internal utility; consumed by useTruncation. The content-change
10
+ * counterpart of sharedResizeObserver: one observer for every element, so a
11
+ * repeated cell or row path does not create one observer per instance.
12
+ */
13
+ const OPTIONS = {
14
+ characterData: true,
15
+ childList: true,
16
+ subtree: true
17
+ };
18
+ let observer = null;
19
+ const callbacks = new Map();
20
+ function dispatch(records) {
21
+ // One call per callback per batch, however many records it received. A
22
+ // record's target can be any descendant, so walk up to the observed roots.
23
+ const pending = new Set();
24
+ for (const record of records) {
25
+ for (let node = record.target; node; node = node.parentNode) {
26
+ callbacks.get(node)?.forEach(callback => pending.add(callback));
27
+ }
28
+ }
29
+ pending.forEach(callback => callback());
30
+ }
31
+
32
+ /**
33
+ * Call `callback` when the element's text or children change (including
34
+ * descendants). Returns an unsubscribe that removes only this registration.
35
+ *
36
+ * MutationObserver has no per-target unobserve, so a released element stays
37
+ * registered with the shared observer until no element remains; its records
38
+ * match no callback and are ignored. The observer disconnects and is released
39
+ * when the last registration goes.
40
+ */
41
+ export function observeContent(element, callback) {
42
+ if (typeof MutationObserver === 'undefined') {
43
+ return () => {};
44
+ }
45
+ observer ??= new MutationObserver(dispatch);
46
+ const existing = callbacks.get(element);
47
+ if (existing) {
48
+ existing.add(callback);
49
+ } else {
50
+ callbacks.set(element, new Set([callback]));
51
+ observer.observe(element, OPTIONS);
52
+ }
53
+ let isSubscribed = true;
54
+ return () => {
55
+ if (!isSubscribed) {
56
+ return;
57
+ }
58
+ isSubscribed = false;
59
+ const targetCallbacks = callbacks.get(element);
60
+ targetCallbacks?.delete(callback);
61
+ if (targetCallbacks?.size === 0) {
62
+ callbacks.delete(element);
63
+ }
64
+ if (callbacks.size === 0 && observer) {
65
+ observer.disconnect();
66
+ observer = null;
67
+ }
68
+ };
69
+ }
package/locales/en.json CHANGED
@@ -527,6 +527,82 @@
527
527
  "defaultMessage": "This {itemName} is already {boundary, select, first {first} last {last} other {}}.",
528
528
  "description": "Screen-reader-only live announcement when an arrow-key reorder attempt has no effect because the record is already at that end of the list. `{itemName}` is the consumer's singular noun for one record; `{boundary}` is always exactly \"first\" or \"last\"."
529
529
  },
530
+ "@astryx.diffViewer.label": {
531
+ "defaultMessage": "Diff",
532
+ "description": "Accessible name of a lab DiffViewer's scrollable code area when the file has no path to name it."
533
+ },
534
+ "@astryx.diffViewer.renamedPath": {
535
+ "defaultMessage": "{oldPath} → {newPath}",
536
+ "description": "File path shown in a lab DiffViewer header when the file was renamed. `{oldPath}` and `{newPath}` are file paths and must stay untranslated; keep the arrow pointing from the old path to the new one."
537
+ },
538
+ "@astryx.diffViewer.statusAdded": {
539
+ "defaultMessage": "Added",
540
+ "description": "Badge in a lab DiffViewer file header marking a file that the change creates."
541
+ },
542
+ "@astryx.diffViewer.statusDeleted": {
543
+ "defaultMessage": "Deleted",
544
+ "description": "Badge in a lab DiffViewer file header marking a file that the change removes."
545
+ },
546
+ "@astryx.diffViewer.statusRenamed": {
547
+ "defaultMessage": "Renamed",
548
+ "description": "Badge in a lab DiffViewer file header marking a file that the change moves to a new path."
549
+ },
550
+ "@astryx.diffViewer.addedCount": {
551
+ "defaultMessage": "+{count, number}",
552
+ "description": "Compact count of added lines shown in a lab DiffViewer file header, e.g. \"+12\". Hidden from screen readers, which hear `@astryx.diffViewer.stats` instead."
553
+ },
554
+ "@astryx.diffViewer.removedCount": {
555
+ "defaultMessage": "−{count, number}",
556
+ "description": "Compact count of removed lines shown in a lab DiffViewer file header, e.g. \"−3\" (a minus sign). Hidden from screen readers, which hear `@astryx.diffViewer.stats` instead."
557
+ },
558
+ "@astryx.diffViewer.stats": {
559
+ "defaultMessage": "{added, plural, one {# line added} other {# lines added}}, {removed, plural, one {# line removed} other {# lines removed}}",
560
+ "description": "Screen-reader text for the line counts in a lab DiffViewer file header, e.g. \"12 lines added, 3 lines removed\"."
561
+ },
562
+ "@astryx.diffViewer.added": {
563
+ "defaultMessage": "Added",
564
+ "description": "Screen-reader text marking one line of a lab DiffViewer as added by the change; sighted readers see a + sign and a tinted row."
565
+ },
566
+ "@astryx.diffViewer.removed": {
567
+ "defaultMessage": "Removed",
568
+ "description": "Screen-reader text marking one line of a lab DiffViewer as removed by the change; sighted readers see a - sign and a tinted row."
569
+ },
570
+ "@astryx.diffViewer.lineNumberButton": {
571
+ "defaultMessage": "{side, select, old {Original line {line, number}} other {Changed line {line, number}}}",
572
+ "description": "Accessible label of a clickable line number in a lab DiffViewer (callers use it, for example, to start a comment on that line). `side` is `old` for the file before the change and `new` for the file after it; `line` is the line number in that version."
573
+ },
574
+ "@astryx.diffViewer.columnOldLine": {
575
+ "defaultMessage": "Original line number",
576
+ "description": "Screen-reader-only header of a lab DiffViewer column holding line numbers in the file before the change. Pairs with `@astryx.diffViewer.lineNumberButton` (\"Original line …\")."
577
+ },
578
+ "@astryx.diffViewer.columnNewLine": {
579
+ "defaultMessage": "Changed line number",
580
+ "description": "Screen-reader-only header of a lab DiffViewer column holding line numbers in the file after the change. Pairs with `@astryx.diffViewer.lineNumberButton` (\"Changed line …\")."
581
+ },
582
+ "@astryx.diffViewer.columnChange": {
583
+ "defaultMessage": "Change",
584
+ "description": "Screen-reader-only header of a lab DiffViewer column that marks each line as added or removed (noun: the kind of change)."
585
+ },
586
+ "@astryx.diffViewer.columnCode": {
587
+ "defaultMessage": "Code",
588
+ "description": "Screen-reader-only header of the source-code column in a lab DiffViewer's single-column (unified) view."
589
+ },
590
+ "@astryx.diffViewer.columnOldCode": {
591
+ "defaultMessage": "Original code",
592
+ "description": "Screen-reader-only header of the source-code column for the file before the change, in a lab DiffViewer's side-by-side (split) view."
593
+ },
594
+ "@astryx.diffViewer.columnNewCode": {
595
+ "defaultMessage": "Changed code",
596
+ "description": "Screen-reader-only header of the source-code column for the file after the change, in a lab DiffViewer's side-by-side (split) view."
597
+ },
598
+ "@astryx.diffViewer.binary": {
599
+ "defaultMessage": "Binary file not shown",
600
+ "description": "Message in place of line changes in a lab DiffViewer when the file is binary and has no text diff."
601
+ },
602
+ "@astryx.diffViewer.noChanges": {
603
+ "defaultMessage": "No content changes",
604
+ "description": "Message in a lab DiffViewer when a file changed only its path or mode, so there are no line changes to show."
605
+ },
530
606
  "@astryx.markdownHeadingLinks.copied": {
531
607
  "defaultMessage": "Link copied",
532
608
  "description": "Accessible name on the Markdown heading copy button and text announced to a polite live region for ~1.5s after a successful copy. Sentence meaning \"Link copied\"; pairs with `markdownHeadingLinks.copy`."
@@ -395,6 +395,63 @@
395
395
  "@astryx.listInput.announceAlreadyAtBoundary": {
396
396
  "defaultMessage": "⟦Ţĥíš {itemName} íš àłřéàðý {boundary, select, first {first} last {last} other {}}.⟧"
397
397
  },
398
+ "@astryx.diffViewer.label": {
399
+ "defaultMessage": "⟦Ð탃⟧"
400
+ },
401
+ "@astryx.diffViewer.renamedPath": {
402
+ "defaultMessage": "⟦{oldPath} → {newPath}⟧"
403
+ },
404
+ "@astryx.diffViewer.statusAdded": {
405
+ "defaultMessage": "⟦Àððéð⟧"
406
+ },
407
+ "@astryx.diffViewer.statusDeleted": {
408
+ "defaultMessage": "⟦Ðéłéţéð⟧"
409
+ },
410
+ "@astryx.diffViewer.statusRenamed": {
411
+ "defaultMessage": "⟦Řéñàɱéð⟧"
412
+ },
413
+ "@astryx.diffViewer.addedCount": {
414
+ "defaultMessage": "⟦+{count, number}⟧"
415
+ },
416
+ "@astryx.diffViewer.removedCount": {
417
+ "defaultMessage": "⟦−{count, number}⟧"
418
+ },
419
+ "@astryx.diffViewer.stats": {
420
+ "defaultMessage": "⟦{added, plural, one {# line added} other {# lines added}}, {removed, plural, one {# line removed} other {# lines removed}}⟧"
421
+ },
422
+ "@astryx.diffViewer.added": {
423
+ "defaultMessage": "⟦Àððéð⟧"
424
+ },
425
+ "@astryx.diffViewer.removed": {
426
+ "defaultMessage": "⟦Řéɱóṽéð⟧"
427
+ },
428
+ "@astryx.diffViewer.lineNumberButton": {
429
+ "defaultMessage": "⟦{side, select, old {Original line {line, number}} other {Changed line {line, number}}}⟧"
430
+ },
431
+ "@astryx.diffViewer.columnOldLine": {
432
+ "defaultMessage": "⟦Óříĝíñàł łíñé ñúɱƀéř⟧"
433
+ },
434
+ "@astryx.diffViewer.columnNewLine": {
435
+ "defaultMessage": "⟦Çĥàñĝéð łíñé ñúɱƀéř⟧"
436
+ },
437
+ "@astryx.diffViewer.columnChange": {
438
+ "defaultMessage": "⟦Çĥàñĝé⟧"
439
+ },
440
+ "@astryx.diffViewer.columnCode": {
441
+ "defaultMessage": "⟦Çóðé⟧"
442
+ },
443
+ "@astryx.diffViewer.columnOldCode": {
444
+ "defaultMessage": "⟦Óříĝíñàł çóðé⟧"
445
+ },
446
+ "@astryx.diffViewer.columnNewCode": {
447
+ "defaultMessage": "⟦Çĥàñĝéð çóðé⟧"
448
+ },
449
+ "@astryx.diffViewer.binary": {
450
+ "defaultMessage": "⟦Ɓíñàřý ƒíłé ñóţ šĥóŵñ⟧"
451
+ },
452
+ "@astryx.diffViewer.noChanges": {
453
+ "defaultMessage": "⟦Ñó çóñţéñţ çĥàñĝéš⟧"
454
+ },
398
455
  "@astryx.markdownHeadingLinks.copied": {
399
456
  "defaultMessage": "⟦Łíñķ çóþíéð⟧"
400
457
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.1",
3
+ "version": "0.7.2-canary.374b53a",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -787,6 +787,8 @@
787
787
  "react-dom": ">=19.0.0"
788
788
  },
789
789
  "devDependencies": {
790
+ "@astryxdesign/a11y-spec": "0.7.2-canary.374b53a",
791
+ "@astryxdesign/cli": "0.7.2-canary.374b53a",
790
792
  "@babel/cli": "^7.29.7",
791
793
  "@babel/core": "^7.29.7",
792
794
  "@babel/preset-react": "^7.29.7",
@@ -797,9 +799,7 @@
797
799
  "@testing-library/jest-dom": "^6.6.0",
798
800
  "@testing-library/react": "^16.3.2",
799
801
  "@types/babel__core": "^7.20.5",
800
- "tailwindcss": "^4.3.2",
801
- "@astryxdesign/a11y-spec": "0.0.0",
802
- "@astryxdesign/cli": "0.7.1"
802
+ "tailwindcss": "^4.3.2"
803
803
  },
804
804
  "dependencies": {
805
805
  "intl-messageformat": "^11.2.9"
@@ -138,7 +138,7 @@ export const docs = {
138
138
  {
139
139
  name: 'isLoading',
140
140
  type: 'boolean',
141
- description: 'Shows a loading spinner and disables interaction. Announces "Loading" via a live region.',
141
+ description: 'Shows a loading spinner and blocks re-activation while staying focusable (aria-busy + aria-disabled, not native disabled, so keyboard focus is never dropped mid-action). Announces "Loading" via a live region.',
142
142
  default: 'false',
143
143
  },
144
144
  {
@@ -419,7 +419,7 @@ export const docsDense = {
419
419
  displayName: 'HTML name for form submission',
420
420
  value: 'HTML value for form submission',
421
421
  form: 'associates button with form element by ID',
422
- isLoading: 'shows spinner+disables interaction; announces via live region',
422
+ isLoading: 'shows spinner, blocks re-activation but stays focusable (aria-disabled, not native disabled); announces via live region',
423
423
  icon: 'icon element rendered before label text; unsized Astryx Icon defaults to sm for sm/md buttons and md for lg',
424
424
  isIconOnly: 'when true, renders square icon-only button; label becomes aria-label',
425
425
  width: "Width of button. Numbers=pixels, strings=as-is (e.g. '100%' for full-width). Default: content width, capped at the container; label truncates w/ ellipsis in a narrow row; icon-only stays square.",
@@ -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
  );