@wordpress/ui 0.11.0 → 0.12.1-next.v.202604201441.0

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 (207) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -4
  3. package/build/alert-dialog/popup.cjs +4 -4
  4. package/build/alert-dialog/popup.cjs.map +2 -2
  5. package/build/collapsible-card/header.cjs +10 -0
  6. package/build/collapsible-card/header.cjs.map +3 -3
  7. package/build/dialog/context.cjs +21 -9
  8. package/build/dialog/context.cjs.map +2 -2
  9. package/build/dialog/footer.cjs +4 -4
  10. package/build/dialog/footer.cjs.map +2 -2
  11. package/build/dialog/header.cjs +4 -4
  12. package/build/dialog/header.cjs.map +2 -2
  13. package/build/dialog/popup.cjs +4 -4
  14. package/build/dialog/popup.cjs.map +2 -2
  15. package/build/dialog/title.cjs +9 -6
  16. package/build/dialog/title.cjs.map +2 -2
  17. package/build/form/primitives/select/item.cjs +3 -3
  18. package/build/form/primitives/select/item.cjs.map +2 -2
  19. package/build/form/primitives/select/popup.cjs +3 -3
  20. package/build/form/primitives/select/popup.cjs.map +2 -2
  21. package/build/link/link.cjs +8 -18
  22. package/build/link/link.cjs.map +2 -2
  23. package/build/link/types.cjs.map +1 -1
  24. package/build/notice/action-button.cjs +3 -3
  25. package/build/notice/action-button.cjs.map +2 -2
  26. package/build/notice/action-link.cjs +8 -7
  27. package/build/notice/action-link.cjs.map +2 -2
  28. package/build/notice/actions.cjs +3 -3
  29. package/build/notice/actions.cjs.map +2 -2
  30. package/build/notice/close-icon.cjs +3 -3
  31. package/build/notice/close-icon.cjs.map +2 -2
  32. package/build/notice/description.cjs +3 -3
  33. package/build/notice/description.cjs.map +2 -2
  34. package/build/notice/root.cjs +3 -3
  35. package/build/notice/root.cjs.map +2 -2
  36. package/build/notice/title.cjs +3 -3
  37. package/build/notice/title.cjs.map +2 -2
  38. package/build/popover/arrow.cjs +4 -4
  39. package/build/popover/arrow.cjs.map +2 -2
  40. package/build/popover/context.cjs +21 -9
  41. package/build/popover/context.cjs.map +2 -2
  42. package/build/popover/description.cjs +4 -4
  43. package/build/popover/description.cjs.map +2 -2
  44. package/build/popover/popup.cjs +8 -5
  45. package/build/popover/popup.cjs.map +2 -2
  46. package/build/popover/title.cjs +5 -2
  47. package/build/popover/title.cjs.map +2 -2
  48. package/build/tabs/context.cjs +9 -22
  49. package/build/tabs/context.cjs.map +2 -2
  50. package/build/tabs/list.cjs +4 -4
  51. package/build/tabs/list.cjs.map +2 -2
  52. package/build/tabs/panel.cjs +19 -6
  53. package/build/tabs/panel.cjs.map +3 -3
  54. package/build/tabs/tab.cjs +4 -4
  55. package/build/tabs/tab.cjs.map +2 -2
  56. package/build/tooltip/popup.cjs +4 -4
  57. package/build/tooltip/popup.cjs.map +2 -2
  58. package/build/utils/use-schedule-validation.cjs +59 -0
  59. package/build/utils/use-schedule-validation.cjs.map +7 -0
  60. package/build-module/alert-dialog/popup.mjs +4 -4
  61. package/build-module/alert-dialog/popup.mjs.map +2 -2
  62. package/build-module/collapsible-card/header.mjs +10 -0
  63. package/build-module/collapsible-card/header.mjs.map +3 -3
  64. package/build-module/dialog/context.mjs +21 -9
  65. package/build-module/dialog/context.mjs.map +2 -2
  66. package/build-module/dialog/footer.mjs +4 -4
  67. package/build-module/dialog/footer.mjs.map +2 -2
  68. package/build-module/dialog/header.mjs +4 -4
  69. package/build-module/dialog/header.mjs.map +2 -2
  70. package/build-module/dialog/popup.mjs +4 -4
  71. package/build-module/dialog/popup.mjs.map +2 -2
  72. package/build-module/dialog/title.mjs +10 -7
  73. package/build-module/dialog/title.mjs.map +2 -2
  74. package/build-module/form/primitives/select/item.mjs +3 -3
  75. package/build-module/form/primitives/select/item.mjs.map +2 -2
  76. package/build-module/form/primitives/select/popup.mjs +3 -3
  77. package/build-module/form/primitives/select/popup.mjs.map +2 -2
  78. package/build-module/link/link.mjs +8 -18
  79. package/build-module/link/link.mjs.map +2 -2
  80. package/build-module/notice/action-button.mjs +3 -3
  81. package/build-module/notice/action-button.mjs.map +2 -2
  82. package/build-module/notice/action-link.mjs +8 -7
  83. package/build-module/notice/action-link.mjs.map +2 -2
  84. package/build-module/notice/actions.mjs +3 -3
  85. package/build-module/notice/actions.mjs.map +2 -2
  86. package/build-module/notice/close-icon.mjs +3 -3
  87. package/build-module/notice/close-icon.mjs.map +2 -2
  88. package/build-module/notice/description.mjs +3 -3
  89. package/build-module/notice/description.mjs.map +2 -2
  90. package/build-module/notice/root.mjs +3 -3
  91. package/build-module/notice/root.mjs.map +2 -2
  92. package/build-module/notice/title.mjs +3 -3
  93. package/build-module/notice/title.mjs.map +2 -2
  94. package/build-module/popover/arrow.mjs +4 -4
  95. package/build-module/popover/arrow.mjs.map +2 -2
  96. package/build-module/popover/context.mjs +21 -9
  97. package/build-module/popover/context.mjs.map +2 -2
  98. package/build-module/popover/description.mjs +4 -4
  99. package/build-module/popover/description.mjs.map +2 -2
  100. package/build-module/popover/popup.mjs +8 -5
  101. package/build-module/popover/popup.mjs.map +2 -2
  102. package/build-module/popover/title.mjs +6 -3
  103. package/build-module/popover/title.mjs.map +2 -2
  104. package/build-module/tabs/context.mjs +11 -24
  105. package/build-module/tabs/context.mjs.map +2 -2
  106. package/build-module/tabs/list.mjs +4 -4
  107. package/build-module/tabs/list.mjs.map +2 -2
  108. package/build-module/tabs/panel.mjs +19 -6
  109. package/build-module/tabs/panel.mjs.map +3 -3
  110. package/build-module/tabs/tab.mjs +4 -4
  111. package/build-module/tabs/tab.mjs.map +2 -2
  112. package/build-module/tooltip/popup.mjs +4 -4
  113. package/build-module/tooltip/popup.mjs.map +2 -2
  114. package/build-module/utils/use-schedule-validation.mjs +34 -0
  115. package/build-module/utils/use-schedule-validation.mjs.map +7 -0
  116. package/build-types/alert-dialog/stories/index.story.d.ts +1 -1
  117. package/build-types/alert-dialog/stories/index.story.d.ts.map +1 -1
  118. package/build-types/badge/stories/index.story.d.ts.map +1 -1
  119. package/build-types/collapsible-card/header.d.ts.map +1 -1
  120. package/build-types/dialog/context.d.ts +1 -1
  121. package/build-types/dialog/context.d.ts.map +1 -1
  122. package/build-types/dialog/title.d.ts.map +1 -1
  123. package/build-types/empty-state/stories/index.story.d.ts +1 -1
  124. package/build-types/empty-state/stories/index.story.d.ts.map +1 -1
  125. package/build-types/form/input-control/stories/index.story.d.ts +1 -1
  126. package/build-types/form/input-control/stories/index.story.d.ts.map +1 -1
  127. package/build-types/form/primitives/field/stories/index.story.d.ts +1 -1
  128. package/build-types/form/primitives/field/stories/index.story.d.ts.map +1 -1
  129. package/build-types/form/primitives/fieldset/stories/index.story.d.ts +1 -1
  130. package/build-types/form/primitives/fieldset/stories/index.story.d.ts.map +1 -1
  131. package/build-types/form/primitives/input/stories/index.story.d.ts +1 -1
  132. package/build-types/form/primitives/input/stories/index.story.d.ts.map +1 -1
  133. package/build-types/form/primitives/input-layout/stories/index.story.d.ts +1 -1
  134. package/build-types/form/primitives/input-layout/stories/index.story.d.ts.map +1 -1
  135. package/build-types/form/primitives/select/stories/index.story.d.ts +1 -1
  136. package/build-types/form/primitives/select/stories/index.story.d.ts.map +1 -1
  137. package/build-types/link/link.d.ts.map +1 -1
  138. package/build-types/link/types.d.ts +1 -2
  139. package/build-types/link/types.d.ts.map +1 -1
  140. package/build-types/notice/action-link.d.ts.map +1 -1
  141. package/build-types/popover/context.d.ts +1 -1
  142. package/build-types/popover/context.d.ts.map +1 -1
  143. package/build-types/popover/popup.d.ts.map +1 -1
  144. package/build-types/popover/stories/index.story.d.ts +1 -1
  145. package/build-types/popover/stories/index.story.d.ts.map +1 -1
  146. package/build-types/popover/title.d.ts.map +1 -1
  147. package/build-types/stack/stories/index.story.d.ts.map +1 -1
  148. package/build-types/tabs/context.d.ts.map +1 -1
  149. package/build-types/tabs/panel.d.ts.map +1 -1
  150. package/build-types/tabs/stories/index.story.d.ts +1 -1
  151. package/build-types/tabs/stories/index.story.d.ts.map +1 -1
  152. package/build-types/text/stories/index.story.d.ts.map +1 -1
  153. package/build-types/tooltip/stories/index.story.d.ts +1 -1
  154. package/build-types/tooltip/stories/index.story.d.ts.map +1 -1
  155. package/build-types/tooltip/stories/usage-guidelines.story.d.ts.map +1 -1
  156. package/build-types/utils/use-schedule-validation.d.ts +13 -0
  157. package/build-types/utils/use-schedule-validation.d.ts.map +1 -0
  158. package/package.json +11 -11
  159. package/src/alert-dialog/stories/index.story.tsx +2 -2
  160. package/src/badge/stories/choosing-intent.story.tsx +1 -1
  161. package/src/badge/stories/index.story.tsx +1 -0
  162. package/src/collapsible-card/header.tsx +2 -0
  163. package/src/dialog/context.tsx +28 -15
  164. package/src/dialog/style.module.css +12 -0
  165. package/src/dialog/test/index.test.tsx +222 -142
  166. package/src/dialog/title.tsx +6 -4
  167. package/src/empty-state/stories/index.story.tsx +2 -1
  168. package/src/form/input-control/stories/index.story.tsx +4 -1
  169. package/src/form/primitives/field/stories/index.story.tsx +1 -1
  170. package/src/form/primitives/fieldset/stories/index.story.tsx +1 -1
  171. package/src/form/primitives/input/stories/index.story.tsx +2 -1
  172. package/src/form/primitives/input-layout/stories/index.story.tsx +2 -1
  173. package/src/form/primitives/select/stories/index.story.tsx +1 -1
  174. package/src/link/link.tsx +12 -26
  175. package/src/link/style.module.css +4 -16
  176. package/src/link/test/index.test.tsx +31 -27
  177. package/src/link/types.ts +1 -2
  178. package/src/notice/action-link.tsx +7 -4
  179. package/src/notice/style.module.css +5 -5
  180. package/src/popover/context.tsx +28 -12
  181. package/src/popover/popup.tsx +4 -1
  182. package/src/popover/stories/index.story.tsx +2 -1
  183. package/src/popover/style.module.css +23 -1
  184. package/src/popover/test/index.test.tsx +146 -70
  185. package/src/popover/title.tsx +6 -3
  186. package/src/stack/stories/index.story.tsx +1 -0
  187. package/src/tabs/context.tsx +14 -34
  188. package/src/tabs/panel.tsx +7 -2
  189. package/src/tabs/stories/index.story.tsx +2 -1
  190. package/src/tabs/style.module.css +0 -17
  191. package/src/tabs/test/index.test.tsx +7 -3
  192. package/src/text/stories/index.story.tsx +1 -0
  193. package/src/tooltip/stories/index.story.tsx +2 -1
  194. package/src/tooltip/stories/usage-guidelines.story.tsx +5 -1
  195. package/src/tooltip/style.module.css +12 -0
  196. package/src/utils/css/item-popup.module.css +12 -0
  197. package/src/utils/use-schedule-validation.ts +45 -0
  198. package/build/types/css-modules.d.cjs +0 -2
  199. package/build/types/css-modules.d.cjs.map +0 -7
  200. package/build/types/react.d.cjs +0 -5
  201. package/build/types/react.d.cjs.map +0 -7
  202. package/build-module/types/css-modules.d.mjs +0 -1
  203. package/build-module/types/css-modules.d.mjs.map +0 -7
  204. package/build-module/types/react.d.mjs +0 -3
  205. package/build-module/types/react.d.mjs.map +0 -7
  206. package/src/types/css-modules.d.ts +0 -4
  207. package/src/types/react.d.ts +0 -7
@@ -1,8 +1,21 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { Component, createRef, useState } from '@wordpress/element';
3
+ import { createRef, useState } from '@wordpress/element';
4
4
  import * as Popover from '../index';
5
5
 
6
+ function collectUncaughtErrors() {
7
+ const errors: Error[] = [];
8
+ const handler = ( event: ErrorEvent ) => {
9
+ event.preventDefault();
10
+ errors.push( event.error );
11
+ };
12
+ window.addEventListener( 'error', handler );
13
+ return {
14
+ errors,
15
+ cleanup: () => window.removeEventListener( 'error', handler ),
16
+ };
17
+ }
18
+
6
19
  describe( 'Popover', () => {
7
20
  describe( 'forwards ref', () => {
8
21
  it( 'should forward ref on Trigger', () => {
@@ -613,84 +626,87 @@ describe( 'Popover', () => {
613
626
  } );
614
627
 
615
628
  describe( 'title validation', () => {
629
+ // Suppress console.error from React act() warnings and jsdom
630
+ // unhandled-error logging. Validation errors are caught via
631
+ // collectUncaughtErrors (window 'error' event) instead.
632
+ let originalConsoleError: typeof console.error;
633
+
634
+ beforeEach( () => {
635
+ // eslint-disable-next-line no-console
636
+ originalConsoleError = console.error;
637
+ // eslint-disable-next-line no-console
638
+ console.error = jest.fn();
639
+ } );
640
+
641
+ afterEach( () => {
642
+ // eslint-disable-next-line no-console
643
+ console.error = originalConsoleError;
644
+ } );
645
+
616
646
  it( 'should throw when Popover.Title is missing', async () => {
617
647
  const user = userEvent.setup();
618
- const onError = jest.fn();
619
-
620
- // Suppress console.error from React error boundary
621
- const spy = jest
622
- .spyOn( console, 'error' )
623
- .mockImplementation( () => {} );
648
+ const { errors, cleanup } = collectUncaughtErrors();
624
649
 
625
650
  render(
626
- <ErrorBoundary onError={ onError }>
627
- <Popover.Root>
628
- <Popover.Trigger>Open</Popover.Trigger>
629
- <Popover.Popup>No title here</Popover.Popup>
630
- </Popover.Root>
631
- </ErrorBoundary>
651
+ <Popover.Root>
652
+ <Popover.Trigger>Open</Popover.Trigger>
653
+ <Popover.Popup>No title here</Popover.Popup>
654
+ </Popover.Root>
632
655
  );
633
656
 
634
657
  await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
635
658
 
636
659
  await waitFor( () => {
637
- expect( onError ).toHaveBeenCalledWith(
638
- expect.objectContaining( {
639
- message: expect.stringContaining(
640
- 'Missing <Popover.Title>'
641
- ),
642
- } )
643
- );
660
+ expect( errors.length ).toBeGreaterThan( 0 );
644
661
  } );
645
662
 
646
- spy.mockRestore();
663
+ expect( errors[ 0 ].message ).toBe(
664
+ 'Popover: Missing <Popover.Title>. ' +
665
+ 'For accessibility, every popover requires a title. ' +
666
+ 'If needed, the title can be visually hidden but must not be omitted.'
667
+ );
668
+
669
+ cleanup();
647
670
  } );
648
671
 
649
672
  it( 'should throw when Popover.Title is empty', async () => {
650
673
  const user = userEvent.setup();
651
- const onError = jest.fn();
652
-
653
- const spy = jest
654
- .spyOn( console, 'error' )
655
- .mockImplementation( () => {} );
674
+ const { errors, cleanup } = collectUncaughtErrors();
656
675
 
657
676
  render(
658
- <ErrorBoundary onError={ onError }>
659
- <Popover.Root>
660
- <Popover.Trigger>Open</Popover.Trigger>
661
- <Popover.Popup>
662
- <Popover.Title />
663
- </Popover.Popup>
664
- </Popover.Root>
665
- </ErrorBoundary>
677
+ <Popover.Root>
678
+ <Popover.Trigger>Open</Popover.Trigger>
679
+ <Popover.Popup>
680
+ <Popover.Title />
681
+ </Popover.Popup>
682
+ </Popover.Root>
666
683
  );
667
684
 
668
685
  await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
669
686
 
670
687
  await waitFor( () => {
671
- expect( onError ).toHaveBeenCalledWith(
672
- expect.objectContaining( {
673
- message: expect.stringContaining( 'cannot be empty' ),
674
- } )
675
- );
688
+ expect( errors.length ).toBeGreaterThan( 0 );
676
689
  } );
677
690
 
678
- spy.mockRestore();
691
+ expect( errors[ 0 ].message ).toBe(
692
+ 'Popover: <Popover.Title> cannot be empty. ' +
693
+ 'Provide meaningful text content for the popover title.'
694
+ );
695
+
696
+ cleanup();
679
697
  } );
680
698
 
681
699
  it( 'should not throw when Popover.Title is present', async () => {
682
700
  const user = userEvent.setup();
683
- const onError = jest.fn();
701
+ const { errors, cleanup } = collectUncaughtErrors();
684
702
 
685
703
  render(
686
- <ErrorBoundary onError={ onError }>
687
- <Popover.Root>
688
- <Popover.Trigger>Open</Popover.Trigger>
689
- <Popover.Popup>
690
- <Popover.Title>Valid Title</Popover.Title>
691
- </Popover.Popup>
692
- </Popover.Root>
693
- </ErrorBoundary>
704
+ <Popover.Root>
705
+ <Popover.Trigger>Open</Popover.Trigger>
706
+ <Popover.Popup>
707
+ <Popover.Title>Valid Title</Popover.Title>
708
+ </Popover.Popup>
709
+ </Popover.Root>
694
710
  );
695
711
 
696
712
  await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
@@ -699,29 +715,89 @@ describe( 'Popover', () => {
699
715
  expect( screen.getByText( 'Valid Title' ) ).toBeVisible();
700
716
  } );
701
717
 
702
- expect( onError ).not.toHaveBeenCalled();
718
+ await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
719
+ expect( errors ).toHaveLength( 0 );
720
+
721
+ cleanup();
703
722
  } );
704
- } );
705
- } );
706
723
 
707
- class ErrorBoundary extends Component<
708
- { children: React.ReactNode; onError: ( error: Error ) => void },
709
- { hasError: boolean }
710
- > {
711
- state = { hasError: false };
724
+ it( 'should throw when title is removed after mount', async () => {
725
+ const { errors, cleanup } = collectUncaughtErrors();
712
726
 
713
- static getDerivedStateFromError() {
714
- return { hasError: true };
715
- }
727
+ const ui = ( showTitle: boolean ) => (
728
+ <Popover.Root defaultOpen>
729
+ <Popover.Trigger>Open</Popover.Trigger>
730
+ <Popover.Popup>
731
+ { showTitle && <Popover.Title>My Title</Popover.Title> }
732
+ <p>Content</p>
733
+ </Popover.Popup>
734
+ </Popover.Root>
735
+ );
716
736
 
717
- componentDidCatch( error: Error ) {
718
- this.props.onError( error );
719
- }
737
+ const { rerender } = render( ui( true ) );
720
738
 
721
- render() {
722
- if ( this.state.hasError ) {
723
- return null;
724
- }
725
- return this.props.children;
726
- }
727
- }
739
+ // Wait for the popover to render.
740
+ await waitFor( () => {
741
+ expect( screen.getByText( 'Content' ) ).toBeInTheDocument();
742
+ } );
743
+
744
+ // Let initial validation settle — no errors expected.
745
+ await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
746
+ expect( errors ).toHaveLength( 0 );
747
+
748
+ // Remove the title via rerender.
749
+ rerender( ui( false ) );
750
+
751
+ await waitFor( () => {
752
+ expect( errors.length ).toBeGreaterThan( 0 );
753
+ } );
754
+
755
+ expect( errors[ 0 ].message ).toBe(
756
+ 'Popover: Missing <Popover.Title>. ' +
757
+ 'For accessibility, every popover requires a title. ' +
758
+ 'If needed, the title can be visually hidden but must not be omitted.'
759
+ );
760
+
761
+ cleanup();
762
+ } );
763
+
764
+ it( 'should recover when title is added back', async () => {
765
+ const { errors, cleanup } = collectUncaughtErrors();
766
+
767
+ const ui = ( showTitle: boolean ) => (
768
+ <Popover.Root defaultOpen>
769
+ <Popover.Trigger>Open</Popover.Trigger>
770
+ <Popover.Popup>
771
+ { showTitle && <Popover.Title>My Title</Popover.Title> }
772
+ <p>Content</p>
773
+ </Popover.Popup>
774
+ </Popover.Root>
775
+ );
776
+
777
+ const { rerender } = render( ui( false ) );
778
+
779
+ // Wait for the popover to render.
780
+ await waitFor( () => {
781
+ expect( screen.getByText( 'Content' ) ).toBeInTheDocument();
782
+ } );
783
+
784
+ // Initially no title — should error.
785
+ await waitFor( () => {
786
+ expect( errors.length ).toBeGreaterThan( 0 );
787
+ } );
788
+
789
+ const errorCountAfterInitial = errors.length;
790
+
791
+ // Add the title back via rerender.
792
+ rerender( ui( true ) );
793
+
794
+ // Wait for deferred validation to settle.
795
+ await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
796
+
797
+ // No new errors should have been thrown.
798
+ expect( errors ).toHaveLength( errorCountAfterInitial );
799
+
800
+ cleanup();
801
+ } );
802
+ } );
803
+ } );
@@ -1,6 +1,6 @@
1
1
  import { Popover as _Popover } from '@base-ui/react/popover';
2
2
  import { useMergeRefs } from '@wordpress/compose';
3
- import { forwardRef, useLayoutEffect, useRef } from '@wordpress/element';
3
+ import { forwardRef, useEffect, useRef } from '@wordpress/element';
4
4
  import { Text } from '../text';
5
5
  import { usePopoverValidationContext } from './context';
6
6
  import type { TitleProps } from './types';
@@ -27,8 +27,11 @@ const Title = forwardRef< HTMLHeadingElement, TitleProps >(
27
27
  const internalRef = useRef< HTMLHeadingElement >( null );
28
28
  const mergedRef = useMergeRefs( [ internalRef, forwardedRef ] );
29
29
 
30
- useLayoutEffect( () => {
31
- validationContext?.registerTitle( internalRef.current );
30
+ useEffect( () => {
31
+ if ( validationContext ) {
32
+ return validationContext.registerTitle( internalRef.current );
33
+ }
34
+ return undefined;
32
35
  }, [ validationContext ] );
33
36
 
34
37
  return (
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Stack } from '../index';
3
3
 
4
4
  const meta: Meta< typeof Stack > = {
5
+ tags: [ 'manifest' ],
5
6
  title: 'Design System/Components/Stack',
6
7
  component: Stack,
7
8
  };
@@ -2,10 +2,11 @@ import {
2
2
  createContext,
3
3
  useContext,
4
4
  useCallback,
5
+ useEffect,
5
6
  useMemo,
6
7
  useRef,
7
- useEffect,
8
8
  } from '@wordpress/element';
9
+ import { useScheduleValidation } from '../utils/use-schedule-validation';
9
10
 
10
11
  type TabsValidationContextType = {
11
12
  registerTab: () => () => void;
@@ -77,33 +78,20 @@ function TabsValidationProviderDev( {
77
78
  } ) {
78
79
  const tabCountRef = useRef( 0 );
79
80
  const panelCountRef = useRef( 0 );
80
- const validationScheduledRef = useRef< ReturnType<
81
- typeof setTimeout
82
- > | null >( null );
83
81
 
84
- const scheduleValidation = useCallback( () => {
85
- if ( validationScheduledRef.current ) {
86
- clearTimeout( validationScheduledRef.current );
82
+ const scheduleValidation = useScheduleValidation( () => {
83
+ const tabCount = tabCountRef.current;
84
+ const panelCount = panelCountRef.current;
85
+
86
+ if ( tabCount !== panelCount ) {
87
+ throw new Error(
88
+ `Tabs: Tab/Panel count mismatch (${ tabCount } Tabs, ${ panelCount } Panels). ` +
89
+ `Each Tab must be associated with exactly one Panel. ` +
90
+ `Mismatched or missing associations can break screen reader navigation ` +
91
+ `and violate WAI-ARIA Tabs pattern requirements.`
92
+ );
87
93
  }
88
-
89
- // Schedule validation for the next tick to allow all
90
- // registrations/unregistrations to complete.
91
- validationScheduledRef.current = setTimeout( () => {
92
- const tabCount = tabCountRef.current;
93
- const panelCount = panelCountRef.current;
94
-
95
- if ( tabCount !== panelCount ) {
96
- throw new Error(
97
- `Tabs: Tab/Panel count mismatch (${ tabCount } Tabs, ${ panelCount } Panels). ` +
98
- `Each Tab must be associated with exactly one Panel. ` +
99
- `Mismatched or missing associations can break screen reader navigation ` +
100
- `and violate WAI-ARIA Tabs pattern requirements.`
101
- );
102
- }
103
-
104
- validationScheduledRef.current = null;
105
- }, 0 );
106
- }, [] );
94
+ } );
107
95
 
108
96
  const registerTab = useCallback( () => {
109
97
  tabCountRef.current += 1;
@@ -125,14 +113,6 @@ function TabsValidationProviderDev( {
125
113
  };
126
114
  }, [ scheduleValidation ] );
127
115
 
128
- useEffect( () => {
129
- return () => {
130
- if ( validationScheduledRef.current ) {
131
- clearTimeout( validationScheduledRef.current );
132
- }
133
- };
134
- }, [] );
135
-
136
116
  const contextValue = useMemo(
137
117
  () => ( {
138
118
  registerTab,
@@ -2,7 +2,8 @@ import { forwardRef } from '@wordpress/element';
2
2
  import clsx from 'clsx';
3
3
  import { Tabs as _Tabs } from '@base-ui/react/tabs';
4
4
  import { useRegisterPanel } from './context';
5
- import styles from './style.module.css';
5
+ import defenseStyles from '../utils/css/global-css-defense.module.css';
6
+ import focusStyles from '../utils/css/focus.module.css';
6
7
  import type { TabPanelProps } from './types';
7
8
 
8
9
  /**
@@ -18,7 +19,11 @@ export const Panel = forwardRef< HTMLDivElement, TabPanelProps >(
18
19
  return (
19
20
  <_Tabs.Panel
20
21
  ref={ forwardedRef }
21
- className={ clsx( styles.tabpanel, className ) }
22
+ className={ clsx(
23
+ defenseStyles.div,
24
+ focusStyles[ 'outset-ring--focus-visible' ],
25
+ className
26
+ ) }
22
27
  { ...otherProps }
23
28
  />
24
29
  );
@@ -1,7 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState, cloneElement } from '@wordpress/element';
3
3
  import { link, more, wordpress } from '@wordpress/icons';
4
- import { Tabs, Tooltip } from '../..';
4
+ import * as Tabs from '../';
5
+ import * as Tooltip from '../../tooltip';
5
6
 
6
7
  const meta: Meta< typeof Tabs.Root > = {
7
8
  title: 'Design System/Components/Tabs',
@@ -249,21 +249,4 @@
249
249
  rotate: 180deg;
250
250
  }
251
251
  }
252
-
253
- .tabpanel {
254
- &:focus {
255
- box-shadow: none;
256
- outline: none;
257
- }
258
-
259
- &:focus-visible {
260
- box-shadow:
261
- 0 0 0 var(--wpds-border-width-focus)
262
- var(--wpds-color-stroke-focus-brand);
263
-
264
- /* Windows high contrast mode. */
265
- outline: 2px solid transparent;
266
- outline-offset: 0;
267
- }
268
- }
269
252
  }
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable jest/no-conditional-expect */
2
- import { render, screen, waitFor } from '@testing-library/react';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { DirectionProvider } from '@base-ui/react/direction-provider';
5
5
  import { useEffect, useState, createRef } from '@wordpress/element';
@@ -2344,7 +2344,9 @@ describe( 'Tabs', () => {
2344
2344
  await waitForComponentToBeInitializedWithSelectedTab( 'One' );
2345
2345
 
2346
2346
  // Wait a bit to ensure validation has run
2347
- await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
2347
+ await act(
2348
+ () => new Promise( ( resolve ) => setTimeout( resolve, 50 ) )
2349
+ );
2348
2350
 
2349
2351
  expect( errors ).toHaveLength( 0 );
2350
2352
 
@@ -2391,7 +2393,9 @@ describe( 'Tabs', () => {
2391
2393
  await waitForComponentToBeInitializedWithSelectedTab( 'One' );
2392
2394
 
2393
2395
  // Wait for validation
2394
- await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
2396
+ await act(
2397
+ () => new Promise( ( resolve ) => setTimeout( resolve, 50 ) )
2398
+ );
2395
2399
 
2396
2400
  // No errors since counts match
2397
2401
  expect( errors ).toHaveLength( 0 );
@@ -3,6 +3,7 @@ import { Text } from '../index';
3
3
  import { Stack } from '../../stack';
4
4
 
5
5
  const meta: Meta< typeof Text > = {
6
+ tags: [ 'manifest' ],
6
7
  title: 'Design System/Components/Text',
7
8
  component: Text,
8
9
  };
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { formatBold, formatItalic } from '@wordpress/icons';
3
- import { Icon, Tooltip } from '../..';
3
+ import { Icon } from '../../icon';
4
+ import * as Tooltip from '../';
4
5
 
5
6
  const meta: Meta< typeof Tooltip.Root > = {
6
7
  title: 'Design System/Components/Tooltip',
@@ -5,7 +5,11 @@ import {
5
5
  formatUnderline,
6
6
  info,
7
7
  } from '@wordpress/icons';
8
- import { Icon, IconButton, Popover, Tooltip, VisuallyHidden } from '../..';
8
+ import * as Tooltip from '../';
9
+ import { Icon } from '../../icon';
10
+ import { IconButton } from '../../icon-button';
11
+ import * as Popover from '../../popover';
12
+ import { VisuallyHidden } from '../../visually-hidden';
9
13
 
10
14
  const meta: Meta = {
11
15
  title: 'Design System/Components/Tooltip/Usage Guidelines',
@@ -1,5 +1,17 @@
1
1
  @layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2
2
 
3
+ /*
4
+ * Temporary workaround for a Base UI tabbability regression with
5
+ * checkVisibility() and display: contents.
6
+ * See: https://github.com/mui/base-ui/issues/4622
7
+ *
8
+ * This must stay outside the CSS layers to override ThemeProvider's
9
+ * unlayered display: contents.
10
+ */
11
+ [data-wpds-theme-provider-id]:has(> .popup) {
12
+ display: block;
13
+ }
14
+
3
15
  @layer wp-ui-components {
4
16
  .positioner {
5
17
  z-index: var(--wp-ui-tooltip-z-index, initial);
@@ -1,5 +1,17 @@
1
1
  @layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2
2
 
3
+ /*
4
+ * Temporary workaround for a Base UI tabbability regression with
5
+ * checkVisibility() and display: contents.
6
+ * See: https://github.com/mui/base-ui/issues/4622
7
+ *
8
+ * This must stay outside the CSS layers to override ThemeProvider's
9
+ * unlayered display: contents.
10
+ */
11
+ [data-wpds-theme-provider-id]:has(> .popup) {
12
+ display: block;
13
+ }
14
+
3
15
  @layer wp-ui-utilities {
4
16
  .popup {
5
17
  composes: dropdown-motion from "./dropdown-motion.module.css";
@@ -0,0 +1,45 @@
1
+ import { useCallback, useEffect, useRef } from '@wordpress/element';
2
+
3
+ /**
4
+ * Dev-only hook that returns a stable `scheduleValidation` function.
5
+ *
6
+ * Each call debounces to `setTimeout(…, 0)` so that rapid
7
+ * register / unregister cycles (e.g. React strict-mode double-mount)
8
+ * settle before the check runs. The timer is cleaned up on unmount,
9
+ * and calls after unmount are silently ignored.
10
+ *
11
+ * @param validate Callback that performs the actual validation.
12
+ * Stored in a ref — safe to pass an unstable closure.
13
+ */
14
+ export function useScheduleValidation( validate: () => void ) {
15
+ const validateRef = useRef( validate );
16
+ validateRef.current = validate;
17
+
18
+ const timerRef = useRef< ReturnType< typeof setTimeout > | null >( null );
19
+ const unmountedRef = useRef( false );
20
+
21
+ const scheduleValidation = useCallback( () => {
22
+ if ( unmountedRef.current ) {
23
+ return;
24
+ }
25
+ if ( timerRef.current ) {
26
+ clearTimeout( timerRef.current );
27
+ }
28
+ timerRef.current = setTimeout( () => {
29
+ validateRef.current();
30
+ timerRef.current = null;
31
+ }, 0 );
32
+ }, [] );
33
+
34
+ useEffect( () => {
35
+ unmountedRef.current = false;
36
+ return () => {
37
+ unmountedRef.current = true;
38
+ if ( timerRef.current ) {
39
+ clearTimeout( timerRef.current );
40
+ }
41
+ };
42
+ }, [] );
43
+
44
+ return scheduleValidation;
45
+ }
@@ -1,2 +0,0 @@
1
- "use strict";
2
- //# sourceMappingURL=css-modules.d.cjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": [],
4
- "sourcesContent": [],
5
- "mappings": "",
6
- "names": []
7
- }
@@ -1,5 +0,0 @@
1
- "use strict";
2
-
3
- // packages/ui/src/types/react.d.ts
4
- var import_react = require("react");
5
- //# sourceMappingURL=react.d.cjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../src/types/react.d.ts"],
4
- "sourcesContent": ["import 'react';\n\ndeclare module 'react' {\n\tinterface CSSProperties {\n\t\t[ key: `--${ string }` ]: string | number | undefined;\n\t}\n}\n"],
5
- "mappings": ";;;AAAA,mBAAO;",
6
- "names": []
7
- }
@@ -1 +0,0 @@
1
- //# sourceMappingURL=css-modules.d.mjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": [],
4
- "sourcesContent": [],
5
- "mappings": "",
6
- "names": []
7
- }
@@ -1,3 +0,0 @@
1
- // packages/ui/src/types/react.d.ts
2
- import "react";
3
- //# sourceMappingURL=react.d.mjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../src/types/react.d.ts"],
4
- "sourcesContent": ["import 'react';\n\ndeclare module 'react' {\n\tinterface CSSProperties {\n\t\t[ key: `--${ string }` ]: string | number | undefined;\n\t}\n}\n"],
5
- "mappings": ";AAAA,OAAO;",
6
- "names": []
7
- }
@@ -1,4 +0,0 @@
1
- declare module '*.module.css' {
2
- const classes: { [ key: string ]: string };
3
- export default classes;
4
- }
@@ -1,7 +0,0 @@
1
- import 'react';
2
-
3
- declare module 'react' {
4
- interface CSSProperties {
5
- [ key: `--${ string }` ]: string | number | undefined;
6
- }
7
- }