@wordpress/block-library 9.40.2-next.v.202602241322.0 → 9.41.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 (153) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/build/accordion/view.cjs +0 -34
  3. package/build/accordion/view.cjs.map +2 -2
  4. package/build/button/block.json +11 -3
  5. package/build/button/deprecated.cjs +246 -13
  6. package/build/button/deprecated.cjs.map +2 -2
  7. package/build/button/edit.cjs +45 -58
  8. package/build/button/edit.cjs.map +3 -3
  9. package/build/button/save.cjs +3 -7
  10. package/build/button/save.cjs.map +2 -2
  11. package/build/button/utils.cjs +59 -0
  12. package/build/button/utils.cjs.map +7 -0
  13. package/build/image/image.cjs +1 -1
  14. package/build/image/image.cjs.map +2 -2
  15. package/build/navigation/edit/index.cjs +4 -2
  16. package/build/navigation/edit/index.cjs.map +3 -3
  17. package/build/navigation/edit/leaf-more-menu.cjs +68 -6
  18. package/build/navigation/edit/leaf-more-menu.cjs.map +3 -3
  19. package/build/navigation/edit/menu-inspector-controls.cjs +20 -91
  20. package/build/navigation/edit/menu-inspector-controls.cjs.map +3 -3
  21. package/build/navigation/edit/navigation-link-ui.cjs +97 -0
  22. package/build/navigation/edit/navigation-link-ui.cjs.map +7 -0
  23. package/build/navigation/edit/navigation-list-view-header.cjs +86 -0
  24. package/build/navigation/edit/navigation-list-view-header.cjs.map +7 -0
  25. package/build/navigation/edit/navigation-menu-selector.cjs +4 -2
  26. package/build/navigation/edit/navigation-menu-selector.cjs.map +3 -3
  27. package/build/navigation/edit/placeholder/index.cjs +2 -2
  28. package/build/navigation/edit/placeholder/index.cjs.map +3 -3
  29. package/build/navigation-link/shared/controls.cjs +29 -52
  30. package/build/navigation-link/shared/controls.cjs.map +3 -3
  31. package/build/navigation-link/shared/use-link-preview.cjs +8 -9
  32. package/build/navigation-link/shared/use-link-preview.cjs.map +2 -2
  33. package/build/page-list-item/edit.cjs +6 -3
  34. package/build/page-list-item/edit.cjs.map +2 -2
  35. package/build/playlist/edit.cjs +43 -136
  36. package/build/playlist/edit.cjs.map +3 -3
  37. package/build/playlist/view.cjs +56 -38
  38. package/build/playlist/view.cjs.map +2 -2
  39. package/build/playlist-track/edit.cjs +0 -1
  40. package/build/playlist-track/edit.cjs.map +2 -2
  41. package/build/post-title/block.json +3 -0
  42. package/build/post-title/edit.cjs +2 -2
  43. package/build/post-title/edit.cjs.map +2 -2
  44. package/build/utils/waveform-player.cjs +68 -0
  45. package/build/utils/waveform-player.cjs.map +7 -0
  46. package/build/utils/waveform-utils.cjs +171 -0
  47. package/build/utils/waveform-utils.cjs.map +7 -0
  48. package/build-module/accordion/view.mjs +1 -35
  49. package/build-module/accordion/view.mjs.map +2 -2
  50. package/build-module/button/block.json +11 -3
  51. package/build-module/button/deprecated.mjs +246 -13
  52. package/build-module/button/deprecated.mjs.map +2 -2
  53. package/build-module/button/edit.mjs +47 -63
  54. package/build-module/button/edit.mjs.map +2 -2
  55. package/build-module/button/save.mjs +3 -7
  56. package/build-module/button/save.mjs.map +2 -2
  57. package/build-module/button/utils.mjs +33 -0
  58. package/build-module/button/utils.mjs.map +7 -0
  59. package/build-module/image/image.mjs +1 -1
  60. package/build-module/image/image.mjs.map +2 -2
  61. package/build-module/navigation/edit/index.mjs +4 -2
  62. package/build-module/navigation/edit/index.mjs.map +2 -2
  63. package/build-module/navigation/edit/leaf-more-menu.mjs +73 -7
  64. package/build-module/navigation/edit/leaf-more-menu.mjs.map +2 -2
  65. package/build-module/navigation/edit/menu-inspector-controls.mjs +21 -101
  66. package/build-module/navigation/edit/menu-inspector-controls.mjs.map +2 -2
  67. package/build-module/navigation/edit/navigation-link-ui.mjs +76 -0
  68. package/build-module/navigation/edit/navigation-link-ui.mjs.map +7 -0
  69. package/build-module/navigation/edit/navigation-list-view-header.mjs +58 -0
  70. package/build-module/navigation/edit/navigation-list-view-header.mjs.map +7 -0
  71. package/build-module/navigation/edit/navigation-menu-selector.mjs +5 -3
  72. package/build-module/navigation/edit/navigation-menu-selector.mjs.map +2 -2
  73. package/build-module/navigation/edit/placeholder/index.mjs +2 -2
  74. package/build-module/navigation/edit/placeholder/index.mjs.map +2 -2
  75. package/build-module/navigation-link/shared/controls.mjs +29 -53
  76. package/build-module/navigation-link/shared/controls.mjs.map +2 -2
  77. package/build-module/navigation-link/shared/use-link-preview.mjs +8 -9
  78. package/build-module/navigation-link/shared/use-link-preview.mjs.map +2 -2
  79. package/build-module/page-list-item/edit.mjs +6 -3
  80. package/build-module/page-list-item/edit.mjs.map +2 -2
  81. package/build-module/playlist/edit.mjs +41 -139
  82. package/build-module/playlist/edit.mjs.map +2 -2
  83. package/build-module/playlist/view.mjs +56 -38
  84. package/build-module/playlist/view.mjs.map +2 -2
  85. package/build-module/playlist-track/edit.mjs +0 -1
  86. package/build-module/playlist-track/edit.mjs.map +2 -2
  87. package/build-module/post-title/block.json +3 -0
  88. package/build-module/post-title/edit.mjs +2 -2
  89. package/build-module/post-title/edit.mjs.map +2 -2
  90. package/build-module/utils/waveform-player.mjs +43 -0
  91. package/build-module/utils/waveform-player.mjs.map +7 -0
  92. package/build-module/utils/waveform-utils.mjs +131 -0
  93. package/build-module/utils/waveform-utils.mjs.map +7 -0
  94. package/build-style/button/style-rtl.css +6 -0
  95. package/build-style/button/style.css +6 -0
  96. package/build-style/editor-rtl.css +13 -3
  97. package/build-style/editor.css +13 -3
  98. package/build-style/navigation-link/editor-rtl.css +10 -0
  99. package/build-style/navigation-link/editor.css +10 -0
  100. package/build-style/playlist/editor-rtl.css +3 -3
  101. package/build-style/playlist/editor.css +3 -3
  102. package/build-style/playlist/style-rtl.css +351 -17
  103. package/build-style/playlist/style.css +351 -17
  104. package/build-style/style-rtl.css +357 -17
  105. package/build-style/style.css +357 -17
  106. package/package.json +39 -38
  107. package/src/accordion/view.js +1 -44
  108. package/src/accordion-item/index.php +0 -1
  109. package/src/button/block.json +11 -3
  110. package/src/button/deprecated.js +254 -16
  111. package/src/button/edit.js +50 -61
  112. package/src/button/index.php +68 -0
  113. package/src/button/save.js +2 -8
  114. package/src/button/style.scss +49 -7
  115. package/src/button/test/utils.js +84 -0
  116. package/src/button/utils.js +42 -0
  117. package/src/cover/index.php +4 -4
  118. package/src/image/image.js +14 -15
  119. package/src/image/index.php +3 -1
  120. package/src/navigation/edit/index.js +4 -2
  121. package/src/navigation/edit/leaf-more-menu.js +86 -11
  122. package/src/navigation/edit/menu-inspector-controls.js +23 -142
  123. package/src/navigation/edit/navigation-link-ui.js +115 -0
  124. package/src/navigation/edit/navigation-list-view-header.js +62 -0
  125. package/src/navigation/edit/navigation-menu-selector.js +5 -3
  126. package/src/navigation/edit/placeholder/index.js +3 -2
  127. package/src/navigation/edit/test/navigation-menu-selector.js +23 -20
  128. package/src/navigation-link/editor.scss +18 -0
  129. package/src/navigation-link/shared/controls.js +35 -62
  130. package/src/navigation-link/shared/test/controls.js +5 -5
  131. package/src/navigation-link/shared/test/use-link-preview.test.js +19 -1
  132. package/src/navigation-link/shared/use-link-preview.js +14 -15
  133. package/src/page-list/index.php +1 -1
  134. package/src/page-list-item/edit.js +8 -7
  135. package/src/playlist/edit.js +60 -154
  136. package/src/playlist/editor.scss +3 -3
  137. package/src/playlist/index.php +15 -40
  138. package/src/playlist/style.scss +34 -27
  139. package/src/playlist/test/edit.js +137 -0
  140. package/src/playlist/view.js +97 -40
  141. package/src/playlist-track/edit.js +0 -1
  142. package/src/post-title/block.json +3 -0
  143. package/src/post-title/edit.js +4 -2
  144. package/src/query-title/index.php +1 -1
  145. package/src/search/index.php +1 -1
  146. package/src/utils/test/waveform-utils.js +328 -0
  147. package/src/utils/waveform-player.js +77 -0
  148. package/src/utils/waveform-utils.js +232 -0
  149. package/build/navigation/use-navigation-entities.cjs +0 -67
  150. package/build/navigation/use-navigation-entities.cjs.map +0 -7
  151. package/build-module/navigation/use-navigation-entities.mjs +0 -46
  152. package/build-module/navigation/use-navigation-entities.mjs.map +0 -7
  153. package/src/navigation/use-navigation-entities.js +0 -72
@@ -3,22 +3,21 @@
3
3
  */
4
4
  import { store, getContext, getElement } from '@wordpress/interactivity';
5
5
 
6
- store(
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import { initWaveformPlayer, logPlayError } from '../utils/waveform-utils';
10
+
11
+ /**
12
+ * Store player state for each element.
13
+ */
14
+ const playerState = new WeakMap();
15
+
16
+ const { state } = store(
7
17
  'core/playlist',
8
18
  {
9
19
  state: {
10
20
  playlists: {},
11
- get currentTrack() {
12
- const { currentId, playlistId } = getContext();
13
- if ( ! currentId || ! playlistId ) {
14
- return {};
15
- }
16
- const playlist = this.playlists[ playlistId ];
17
- if ( ! playlist ) {
18
- return {};
19
- }
20
- return playlist.tracks[ currentId ] || {};
21
- },
22
21
  get isCurrentTrack() {
23
22
  const { currentId, uniqueId } = getContext();
24
23
  return currentId === uniqueId;
@@ -28,42 +27,100 @@ store(
28
27
  changeTrack() {
29
28
  const context = getContext();
30
29
  context.currentId = context.uniqueId;
31
- context.isPlaying = true;
32
- },
33
- isPlaying() {
34
- const context = getContext();
35
- context.isPlaying = true;
36
- },
37
- isPaused() {
38
- const context = getContext();
39
- context.isPlaying = false;
40
- },
41
- nextSong() {
42
- const context = getContext();
43
- const currentIndex = context.tracks.findIndex(
44
- ( uniqueId ) => uniqueId === context.currentId
45
- );
46
- const nextTrack = context.tracks[ currentIndex + 1 ];
47
- if ( nextTrack ) {
48
- context.currentId = nextTrack;
49
- const { ref } = getElement();
50
- // Waits a moment before changing the track, since
51
- // immediately changing the track can be jarring.
52
- setTimeout( () => {
53
- ref.play();
54
- }, 1000 );
55
- }
56
30
  },
57
31
  },
58
32
  callbacks: {
59
- autoPlay() {
33
+ initWaveformPlayer() {
60
34
  const context = getContext();
61
35
  const { ref } = getElement();
62
- if ( context.currentId && context.isPlaying ) {
63
- ref.play();
36
+
37
+ if ( ! context.currentId || ! ref ) {
38
+ return;
64
39
  }
40
+
41
+ const track =
42
+ state.playlists[ context.playlistId ]?.tracks[
43
+ context.currentId
44
+ ];
45
+ if ( ! track?.url ) {
46
+ return;
47
+ }
48
+
49
+ const existing = playerState.get( ref );
50
+
51
+ // Skip if we already initialized with this exact URL.
52
+ if ( existing?.url === track.url ) {
53
+ return;
54
+ }
55
+
56
+ // Autoplay if we're switching from a different track (user action),
57
+ // but not on initial page load (when existing has no URL).
58
+ const shouldAutoPlay = !! existing?.url;
59
+
60
+ initPlayer( ref, track, shouldAutoPlay, context );
65
61
  },
66
62
  },
67
63
  },
68
64
  { lock: true }
69
65
  );
66
+
67
+ /**
68
+ * Initialize the waveform player for a given element.
69
+ *
70
+ * @param {Element} ref - The container element.
71
+ * @param {Object} track - The track data.
72
+ * @param {boolean} shouldAutoPlay - Whether to auto-play after initialization.
73
+ * @param {Object} context - The Interactivity API context.
74
+ */
75
+ function initPlayer( ref, track, shouldAutoPlay, context ) {
76
+ const existing = playerState.get( ref );
77
+
78
+ // If a player already exists, load the new track without recreating.
79
+ if ( existing?.instance ) {
80
+ existing.instance
81
+ .loadTrack( track.url, track.title, track.artist, {
82
+ artwork: track.image,
83
+ } )
84
+ .then( () => {
85
+ existing.url = track.url;
86
+ if ( shouldAutoPlay ) {
87
+ existing.instance.play()?.catch( logPlayError );
88
+ }
89
+ } )
90
+ .catch( logPlayError );
91
+ return;
92
+ }
93
+
94
+ // Read translated labels from server-rendered data attributes.
95
+ const labels = {
96
+ play: ref.dataset.labelPlay,
97
+ pause: ref.dataset.labelPause,
98
+ };
99
+
100
+ // Initialize using the shared core.
101
+ const player = initWaveformPlayer( ref, {
102
+ src: track.url,
103
+ title: track.title,
104
+ artist: track.artist,
105
+ image: track.image,
106
+ autoPlay: shouldAutoPlay,
107
+ labels,
108
+ onEnded: () => {
109
+ // Advance to next track (autoPlay handles playback).
110
+ const currentIndex = context.tracks.findIndex(
111
+ ( uniqueId ) => uniqueId === context.currentId
112
+ );
113
+ const nextTrack = context.tracks[ currentIndex + 1 ];
114
+ if ( nextTrack ) {
115
+ context.currentId = nextTrack;
116
+ }
117
+ },
118
+ } );
119
+
120
+ // Store state for cleanup, including instance for loadTrack reuse.
121
+ playerState.set( ref, {
122
+ url: track.url,
123
+ instance: player.instance,
124
+ destroy: player.destroy,
125
+ } );
126
+ }
@@ -180,7 +180,6 @@ const PlaylistTrackEdit = ( { attributes, setAttributes, context } ) => {
180
180
  __next40pxDefaultSize
181
181
  label={ __( 'Title' ) }
182
182
  value={ title ? stripHTML( title ) : '' }
183
- placeholder={ title ? stripHTML( title ) : '' }
184
183
  onChange={ ( titleValue ) => {
185
184
  setAttributes( { title: titleValue } );
186
185
  } }
@@ -30,6 +30,9 @@
30
30
  "type": "string",
31
31
  "default": "_self",
32
32
  "role": "content"
33
+ },
34
+ "placeholder": {
35
+ "type": "string"
33
36
  }
34
37
  },
35
38
  "example": {
@@ -28,7 +28,7 @@ import { createInterpolateElement } from '@wordpress/element';
28
28
  import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
29
29
 
30
30
  export default function PostTitleEdit( {
31
- attributes: { level, levelOptions, isLink, rel, linkTarget },
31
+ attributes: { level, levelOptions, isLink, rel, linkTarget, placeholder },
32
32
  setAttributes,
33
33
  context: { postType, postId, queryId },
34
34
  insertBlocksAfter,
@@ -70,7 +70,9 @@ export default function PostTitleEdit( {
70
70
  const blockEditingMode = useBlockEditingMode();
71
71
  const dropdownMenuProps = useToolsPanelDropdownMenuProps();
72
72
 
73
- let titleElement = <TagName { ...blockProps }>{ __( 'Title' ) }</TagName>;
73
+ let titleElement = (
74
+ <TagName { ...blockProps }>{ placeholder || __( 'Title' ) }</TagName>
75
+ );
74
76
 
75
77
  if ( postType && postId ) {
76
78
  titleElement = userCanEdit ? (
@@ -13,7 +13,7 @@
13
13
  * @since 5.8.0
14
14
  *
15
15
  * @param array $attributes Block attributes.
16
- * @param array $_content Block content.
16
+ * @param array $content Block content.
17
17
  * @param object $block Block instance.
18
18
  *
19
19
  * @return string Returns the query title based on the queried object.
@@ -72,7 +72,7 @@ function render_block_core_search( $attributes ) {
72
72
  if ( $input->next_tag() ) {
73
73
  $input->add_class( implode( ' ', $input_classes ) );
74
74
  $input->set_attribute( 'id', $input_id );
75
- $input->set_attribute( 'value', get_search_query() );
75
+ $input->set_attribute( 'value', get_search_query( false ) );
76
76
  $input->set_attribute( 'placeholder', $attributes['placeholder'] );
77
77
 
78
78
  // If it's interactive, enqueue the script module and add the directives.
@@ -0,0 +1,328 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * External dependencies
7
+ */
8
+ import '@testing-library/jest-dom';
9
+
10
+ /**
11
+ * Internal dependencies
12
+ */
13
+ import {
14
+ createWaveformContainer,
15
+ styleSvgIcons,
16
+ setupPlayButtonAccessibility,
17
+ logPlayError,
18
+ } from '../waveform-utils';
19
+
20
+ // Base player data used across tests
21
+ const basePlayerData = {
22
+ url: 'https://example.com/song.mp3',
23
+ waveformColor: 'rgba(0, 0, 0, 0.3)',
24
+ progressColor: 'rgba(0, 0, 0, 0.6)',
25
+ buttonColor: '#000000',
26
+ };
27
+
28
+ describe( 'Waveform utilities', () => {
29
+ describe( 'createWaveformContainer', () => {
30
+ it( 'should create a container with required data attributes', () => {
31
+ const container = createWaveformContainer( basePlayerData );
32
+
33
+ expect( container.tagName ).toBe( 'DIV' );
34
+ expect( container ).toHaveAttribute( 'data-waveform-player', '' );
35
+ expect( container ).toHaveAttribute(
36
+ 'data-url',
37
+ 'https://example.com/song.mp3'
38
+ );
39
+ expect( container ).toHaveAttribute( 'data-height', '100' );
40
+ expect( container ).toHaveAttribute(
41
+ 'data-waveform-style',
42
+ 'bars'
43
+ );
44
+ expect( container ).toHaveAttribute(
45
+ 'data-waveform-color',
46
+ 'rgba(0, 0, 0, 0.3)'
47
+ );
48
+ expect( container ).toHaveAttribute(
49
+ 'data-progress-color',
50
+ 'rgba(0, 0, 0, 0.6)'
51
+ );
52
+ expect( container ).toHaveAttribute(
53
+ 'data-button-color',
54
+ '#000000'
55
+ );
56
+ } );
57
+
58
+ it( 'should set optional attributes when provided', () => {
59
+ const container = createWaveformContainer( {
60
+ ...basePlayerData,
61
+ title: 'My Song',
62
+ artist: 'The Artist',
63
+ artwork: 'https://example.com/cover.jpg',
64
+ } );
65
+
66
+ expect( container ).toHaveAttribute( 'data-title', 'My Song' );
67
+ expect( container ).toHaveAttribute(
68
+ 'data-subtitle',
69
+ 'The Artist'
70
+ );
71
+ expect( container ).toHaveAttribute(
72
+ 'data-artwork',
73
+ 'https://example.com/cover.jpg'
74
+ );
75
+ } );
76
+
77
+ it( 'should not set optional attributes when not provided', () => {
78
+ const container = createWaveformContainer( basePlayerData );
79
+
80
+ expect( container ).not.toHaveAttribute( 'data-title' );
81
+ expect( container ).not.toHaveAttribute( 'data-subtitle' );
82
+ expect( container ).not.toHaveAttribute( 'data-artwork' );
83
+ } );
84
+
85
+ it( 'should use custom height when provided', () => {
86
+ const container = createWaveformContainer( {
87
+ ...basePlayerData,
88
+ height: 150,
89
+ } );
90
+
91
+ expect( container ).toHaveAttribute( 'data-height', '150' );
92
+ } );
93
+ } );
94
+
95
+ describe( 'styleSvgIcons', () => {
96
+ it( 'should set white fill for dark button colors', () => {
97
+ const container = document.createElement( 'div' );
98
+ const svg = document.createElementNS(
99
+ 'http://www.w3.org/2000/svg',
100
+ 'svg'
101
+ );
102
+ const path = document.createElementNS(
103
+ 'http://www.w3.org/2000/svg',
104
+ 'path'
105
+ );
106
+ svg.appendChild( path );
107
+ container.appendChild( svg );
108
+
109
+ styleSvgIcons( container, '#000000' );
110
+
111
+ expect( path ).toHaveStyle( { fill: '#ffffff' } );
112
+ } );
113
+
114
+ it( 'should set black fill for light button colors', () => {
115
+ const container = document.createElement( 'div' );
116
+ const svg = document.createElementNS(
117
+ 'http://www.w3.org/2000/svg',
118
+ 'svg'
119
+ );
120
+ const path = document.createElementNS(
121
+ 'http://www.w3.org/2000/svg',
122
+ 'path'
123
+ );
124
+ svg.appendChild( path );
125
+ container.appendChild( svg );
126
+
127
+ styleSvgIcons( container, '#ffffff' );
128
+
129
+ expect( path ).toHaveStyle( { fill: '#000000' } );
130
+ } );
131
+
132
+ it( 'should style multiple SVG paths', () => {
133
+ const container = document.createElement( 'div' );
134
+ const svg = document.createElementNS(
135
+ 'http://www.w3.org/2000/svg',
136
+ 'svg'
137
+ );
138
+ const path1 = document.createElementNS(
139
+ 'http://www.w3.org/2000/svg',
140
+ 'path'
141
+ );
142
+ const path2 = document.createElementNS(
143
+ 'http://www.w3.org/2000/svg',
144
+ 'path'
145
+ );
146
+ svg.appendChild( path1 );
147
+ svg.appendChild( path2 );
148
+ container.appendChild( svg );
149
+
150
+ styleSvgIcons( container, '#000000' );
151
+
152
+ expect( path1 ).toHaveStyle( { fill: '#ffffff' } );
153
+ expect( path2 ).toHaveStyle( { fill: '#ffffff' } );
154
+ } );
155
+
156
+ it( 'should handle container with no SVG paths', () => {
157
+ const container = document.createElement( 'div' );
158
+
159
+ // Should not throw.
160
+ expect( () => {
161
+ styleSvgIcons( container, '#000000' );
162
+ } ).not.toThrow();
163
+ } );
164
+
165
+ it( 'should use white for dark colors', () => {
166
+ const container = document.createElement( 'div' );
167
+ const svg = document.createElementNS(
168
+ 'http://www.w3.org/2000/svg',
169
+ 'svg'
170
+ );
171
+ const path = document.createElementNS(
172
+ 'http://www.w3.org/2000/svg',
173
+ 'path'
174
+ );
175
+ svg.appendChild( path );
176
+ container.appendChild( svg );
177
+
178
+ // A dark blue color.
179
+ styleSvgIcons( container, '#000080' );
180
+
181
+ expect( path ).toHaveStyle( { fill: '#ffffff' } );
182
+ } );
183
+
184
+ it( 'should use black for mid-light colors', () => {
185
+ const container = document.createElement( 'div' );
186
+ const svg = document.createElementNS(
187
+ 'http://www.w3.org/2000/svg',
188
+ 'svg'
189
+ );
190
+ const path = document.createElementNS(
191
+ 'http://www.w3.org/2000/svg',
192
+ 'path'
193
+ );
194
+ svg.appendChild( path );
195
+ container.appendChild( svg );
196
+
197
+ // A light yellow color.
198
+ styleSvgIcons( container, '#ffff00' );
199
+
200
+ expect( path ).toHaveStyle( { fill: '#000000' } );
201
+ } );
202
+ } );
203
+
204
+ describe( 'setupPlayButtonAccessibility', () => {
205
+ it( 'should set aria-label to Play initially', () => {
206
+ const container = document.createElement( 'div' );
207
+ const playBtn = document.createElement( 'button' );
208
+ playBtn.className = 'waveform-btn';
209
+ container.appendChild( playBtn );
210
+
211
+ setupPlayButtonAccessibility( container );
212
+
213
+ expect( playBtn ).toHaveAttribute( 'aria-label', 'Play' );
214
+ } );
215
+
216
+ it( 'should change aria-label to Pause on play event', () => {
217
+ const container = document.createElement( 'div' );
218
+ const playBtn = document.createElement( 'button' );
219
+ playBtn.className = 'waveform-btn';
220
+ container.appendChild( playBtn );
221
+
222
+ setupPlayButtonAccessibility( container );
223
+ container.dispatchEvent( new CustomEvent( 'waveformplayer:play' ) );
224
+
225
+ expect( playBtn ).toHaveAttribute( 'aria-label', 'Pause' );
226
+ } );
227
+
228
+ it( 'should change aria-label back to Play on pause event', () => {
229
+ const container = document.createElement( 'div' );
230
+ const playBtn = document.createElement( 'button' );
231
+ playBtn.className = 'waveform-btn';
232
+ container.appendChild( playBtn );
233
+
234
+ setupPlayButtonAccessibility( container );
235
+ container.dispatchEvent( new CustomEvent( 'waveformplayer:play' ) );
236
+ container.dispatchEvent(
237
+ new CustomEvent( 'waveformplayer:pause' )
238
+ );
239
+
240
+ expect( playBtn ).toHaveAttribute( 'aria-label', 'Play' );
241
+ } );
242
+
243
+ it( 'should change aria-label back to Play on ended event', () => {
244
+ const container = document.createElement( 'div' );
245
+ const playBtn = document.createElement( 'button' );
246
+ playBtn.className = 'waveform-btn';
247
+ container.appendChild( playBtn );
248
+
249
+ setupPlayButtonAccessibility( container );
250
+ container.dispatchEvent( new CustomEvent( 'waveformplayer:play' ) );
251
+ container.dispatchEvent(
252
+ new CustomEvent( 'waveformplayer:ended' )
253
+ );
254
+
255
+ expect( playBtn ).toHaveAttribute( 'aria-label', 'Play' );
256
+ } );
257
+
258
+ it( 'should return cleanup function that removes listeners', () => {
259
+ const container = document.createElement( 'div' );
260
+ const playBtn = document.createElement( 'button' );
261
+ playBtn.className = 'waveform-btn';
262
+ container.appendChild( playBtn );
263
+
264
+ const cleanup = setupPlayButtonAccessibility( container );
265
+ cleanup();
266
+
267
+ // After cleanup, events should not change the label.
268
+ container.dispatchEvent( new CustomEvent( 'waveformplayer:play' ) );
269
+ expect( playBtn ).toHaveAttribute( 'aria-label', 'Play' );
270
+ } );
271
+
272
+ it( 'should do nothing when play button not found', () => {
273
+ const container = document.createElement( 'div' );
274
+
275
+ // Should not throw.
276
+ expect( () =>
277
+ setupPlayButtonAccessibility( container )
278
+ ).not.toThrow();
279
+ } );
280
+ } );
281
+
282
+ describe( 'logPlayError', () => {
283
+ let consoleErrorSpy;
284
+
285
+ beforeEach( () => {
286
+ consoleErrorSpy = jest
287
+ .spyOn( console, 'error' )
288
+ .mockImplementation( () => {} );
289
+ } );
290
+
291
+ afterEach( () => {
292
+ consoleErrorSpy.mockRestore();
293
+ } );
294
+
295
+ it( 'should not log AbortError', () => {
296
+ const abortError = new DOMException( 'Aborted', 'AbortError' );
297
+
298
+ logPlayError( abortError );
299
+
300
+ expect( consoleErrorSpy ).not.toHaveBeenCalled();
301
+ } );
302
+
303
+ it( 'should log other errors', () => {
304
+ const otherError = new Error( 'Some other error' );
305
+
306
+ logPlayError( otherError );
307
+
308
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
309
+ 'Playlist play error:',
310
+ otherError
311
+ );
312
+ } );
313
+
314
+ it( 'should log NotAllowedError', () => {
315
+ const notAllowedError = new DOMException(
316
+ 'Not allowed',
317
+ 'NotAllowedError'
318
+ );
319
+
320
+ logPlayError( notAllowedError );
321
+
322
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
323
+ 'Playlist play error:',
324
+ notAllowedError
325
+ );
326
+ } );
327
+ } );
328
+ } );
@@ -0,0 +1,77 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useRef } from '@wordpress/element';
5
+ import { useRefEffect } from '@wordpress/compose';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import { initWaveformPlayer } from './waveform-utils';
11
+
12
+ /**
13
+ * A reusable WaveformPlayer component for the block editor.
14
+ *
15
+ * Renders an audio waveform visualization with play/pause controls.
16
+ * Automatically inherits colors from the parent block's text color.
17
+ *
18
+ * @param {Object} props - Component props.
19
+ * @param {string} props.src - The audio file URL.
20
+ * @param {string} props.title - The track title.
21
+ * @param {string} props.artist - The artist name.
22
+ * @param {string} props.image - The artwork image URL.
23
+ * @param {Function} props.onEnded - Callback when the track finishes playing.
24
+ * @return {Element} The WaveformPlayer element.
25
+ */
26
+ export function WaveformPlayer( { src, title, artist, image, onEnded } ) {
27
+ // Store onEnded in a ref so it doesn't need to be a useRefEffect dependency.
28
+ // The callback changes reference on every render (its dependency chain
29
+ // includes an unstable array), which would cause useRefEffect to destroy
30
+ // and recreate the entire player on every re-render, making it disappear
31
+ // during editor resizes.
32
+ const onEndedRef = useRef( onEnded );
33
+ onEndedRef.current = onEnded;
34
+
35
+ const ref = useRefEffect(
36
+ ( element ) => {
37
+ if ( ! src ) {
38
+ return;
39
+ }
40
+
41
+ let cancelled = false;
42
+ let playerDestroy;
43
+
44
+ function init() {
45
+ if ( cancelled ) {
46
+ return;
47
+ }
48
+ const { destroy } = initWaveformPlayer( element, {
49
+ src,
50
+ title,
51
+ artist,
52
+ image,
53
+ onEnded: () => onEndedRef.current?.(),
54
+ } );
55
+ playerDestroy = destroy;
56
+ }
57
+
58
+ // Defer initialization so the element inherits the correct
59
+ // text color, which is used to derive waveform colors. In the
60
+ // editor iframe, theme styles (CSS custom properties) are
61
+ // injected dynamically, so getComputedStyle may return the
62
+ // default black on first render.
63
+ // Using a requestAnimationFrame loop isn't sufficient to solve the issue.
64
+ // TODO - find a better option than a setTimeout, so we're not relying on an arbitrary number.
65
+ const timeoutId = setTimeout( init, 100 );
66
+
67
+ return () => {
68
+ cancelled = true;
69
+ clearTimeout( timeoutId );
70
+ playerDestroy?.();
71
+ };
72
+ },
73
+ [ src, title, artist, image ]
74
+ );
75
+
76
+ return <div ref={ ref } className="wp-block-playlist__waveform-player" />;
77
+ }