diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php index c7d10fca217ef..0e378920b154c 100644 --- a/src/wp-admin/includes/class-wp-posts-list-table.php +++ b/src/wp-admin/includes/class-wp-posts-list-table.php @@ -1143,9 +1143,36 @@ public function column_title( $post ) { $title = _draft_or_post_title(); if ( $can_edit_post && 'trash' !== $post->post_status ) { + if ( $this->current_level > 0 ) { + /* + * When displayed in hierarchical mode, $parent_name is only set for + * the "accidental level 0" code path above. Derive it from the post's + * immediate parent for all other hierarchical cases. + */ + if ( ! isset( $parent_name ) && $post->post_parent > 0 ) { + $immediate_parent = get_post( $post->post_parent ); + if ( $immediate_parent instanceof WP_Post ) { + /** This filter is documented in wp-includes/post-template.php */ + $parent_name = apply_filters( 'the_title', $immediate_parent->post_title, $immediate_parent->ID ); + } + } + + if ( isset( $parent_name ) ) { + /* translators: Accessibility text for a subpage row-title link in the pages list table. 1: Post title. 2: Parent page title. */ + $aria_label = sprintf( __( '“%1$s” (subpage of “%2$s”) (Edit)' ), $title, $parent_name ); + } else { + /* translators: Accessibility text for a subpage row-title link when parent title is unavailable. %s: Post title. */ + $aria_label = sprintf( __( '“%s” (subpage) (Edit)' ), $title ); + } + } else { + /* translators: Accessibility label for a post row-title link in the list table. %s: Post title. */ + $aria_label = sprintf( __( '“%s” (Edit)' ), $title ); + } + printf( - '%s%s', + '%s%s', get_edit_post_link( $post->ID ), + esc_attr( $aria_label ), $pad, $title ); diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index 9b8cd37c60e50..5d2406f6125de 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -52,8 +52,8 @@ test.describe( 'Edit Posts', () => { const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); await expect( listTable ).toBeVisible(); - // Click the post title (edit) link - await listTable.getByRole( 'link', { name: title, exact: true } ).click(); + // Click the post title (edit) link. The aria-label includes " (Edit)" suffix. + await listTable.getByRole( 'link', { name: `\u201c${ title }\u201d (Edit)` } ).click(); // Wait for the editor iframe to load, and switch to it as the active content frame. await page @@ -83,8 +83,8 @@ test.describe( 'Edit Posts', () => { const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); await expect( listTable ).toBeVisible(); - // // Focus on the post title link. - await listTable.getByRole( 'link', { name: title, exact: true } ).focus(); + // Focus on the post title link. The aria-label includes " (Edit)" suffix. + await listTable.getByRole( 'link', { name: `\u201c${ title }\u201d (Edit)` } ).focus(); // Tab to the Quick Edit button and press Enter to quick edit. await pageUtils.pressKeys( 'Tab', { times: 2 } ) @@ -121,8 +121,8 @@ test.describe( 'Edit Posts', () => { const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); await expect( listTable ).toBeVisible(); - // Focus on the post title link. - await listTable.getByRole( 'link', { name: title, exact: true } ).focus(); + // Focus on the post title link. The aria-label includes " (Edit)" suffix. + await listTable.getByRole( 'link', { name: `\u201c${ title }\u201d (Edit)` } ).focus(); // Tab to the Trash button and press Enter to delete the post. await pageUtils.pressKeys( 'Tab', { times: 3 } ) diff --git a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js index 5f71ff2e415c5..0ddaf7ccc6593 100644 --- a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js +++ b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js @@ -20,7 +20,7 @@ test.describe( 'Empty Trash', () => { await expect( listTable ).toBeVisible(); // Move post to trash - await listTable.getByRole( 'link', { name: POST_TITLE, exact: true } ).hover(); + await listTable.getByRole( 'link', { name: `\u201c${ POST_TITLE }\u201d (Edit)` } ).hover(); await listTable.getByRole( 'link', { name: `Move “${POST_TITLE}” to the Trash` } ).click(); // Empty trash @@ -40,7 +40,7 @@ test.describe( 'Empty Trash', () => { await expect( listTable ).toBeVisible(); // Move post to trash - await listTable.getByRole( 'link', { name: POST_TITLE, exact: true } ).hover(); + await listTable.getByRole( 'link', { name: `\u201c${ POST_TITLE }\u201d (Edit)` } ).hover(); await listTable.getByRole( 'link', { name: `Move “${POST_TITLE}” to the Trash` } ).click(); await page.getByRole( 'link', { name: 'Trash' } ).click(); diff --git a/tests/phpunit/tests/admin/wpPostsListTable.php b/tests/phpunit/tests/admin/wpPostsListTable.php index 9d2482a034af7..6879d5635dedd 100644 --- a/tests/phpunit/tests/admin/wpPostsListTable.php +++ b/tests/phpunit/tests/admin/wpPostsListTable.php @@ -245,6 +245,128 @@ protected function _test_list_hierarchical_page( array $args, array $expected_id } } + /** + * Tests that a top-level page link has an aria-label with the title and "(Edit)". + * + * @ticket 62006 + * + * @covers WP_Posts_List_Table::column_title + */ + public function test_top_level_page_aria_label() { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $post = self::$top[1]; + $title = apply_filters( 'the_title', $post->post_title, $post->ID ); + + // level=0 → top-level page. + ob_start(); + $this->table->single_row( $post, 0 ); + $output = ob_get_clean(); + + // Expected: "Title" (Edit) — using curly quotes as the translatable string uses “/”. + $this->assertStringContainsString( 'aria-label=', $output ); + $this->assertStringContainsString( esc_attr( $title ), $output ); + $this->assertStringContainsString( '(Edit)', html_entity_decode( $output, ENT_QUOTES | ENT_HTML5 ) ); + $this->assertStringNotContainsString( 'subpage', $output ); + + wp_set_current_user( 0 ); + } + + /** + * Tests that a child page link includes "subpage of" with the parent page title in its aria-label. + * + * @ticket 62006 + * + * @covers WP_Posts_List_Table::column_title + */ + public function test_child_page_aria_label_includes_parent_name() { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $child = self::$children[1][1]; + $parent = self::$top[1]; + $child_title = apply_filters( 'the_title', $child->post_title, $child->ID ); + $parent_title = apply_filters( 'the_title', $parent->post_title, $parent->ID ); + + // level=1 → direct child page. + ob_start(); + $this->table->single_row( $child, 1 ); + $output = ob_get_clean(); + + $decoded = html_entity_decode( $output, ENT_QUOTES | ENT_HTML5 ); + + $this->assertStringContainsString( 'aria-label=', $output ); + $this->assertStringContainsString( 'subpage of', $decoded ); + $this->assertStringContainsString( $child_title, $decoded ); + $this->assertStringContainsString( $parent_title, $decoded ); + $this->assertStringContainsString( '(Edit)', $decoded ); + + wp_set_current_user( 0 ); + } + + /** + * Tests that a grandchild page link includes "subpage of" with its immediate parent title in its aria-label. + * + * @ticket 62006 + * + * @covers WP_Posts_List_Table::column_title + */ + public function test_grandchild_page_aria_label_includes_immediate_parent_name() { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $grandchild = self::$grandchildren[3][1][1]; + $child = self::$children[3][1]; + $grandchild_title = apply_filters( 'the_title', $grandchild->post_title, $grandchild->ID ); + $parent_title = apply_filters( 'the_title', $child->post_title, $child->ID ); + + // level=2 → grandchild page; immediate parent is the child page. + ob_start(); + $this->table->single_row( $grandchild, 2 ); + $output = ob_get_clean(); + + $decoded = html_entity_decode( $output, ENT_QUOTES | ENT_HTML5 ); + + $this->assertStringContainsString( 'aria-label=', $output ); + $this->assertStringContainsString( 'subpage of', $decoded ); + $this->assertStringContainsString( $grandchild_title, $decoded ); + $this->assertStringContainsString( $parent_title, $decoded ); + $this->assertStringContainsString( '(Edit)', $decoded ); + + wp_set_current_user( 0 ); + } + + /** + * Tests that a non-editable page (trashed) does not receive an aria-label on the title span. + * + * @ticket 62006 + * + * @covers WP_Posts_List_Table::column_title + */ + public function test_trashed_page_title_has_no_aria_label() { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $trashed = self::factory()->post->create_and_get( + array( + 'post_type' => 'page', + 'post_status' => 'trash', + 'post_parent' => self::$top[1]->ID, + ) + ); + + ob_start(); + $this->table->single_row( $trashed, 1 ); + $output = ob_get_clean(); + + // Trashed posts render as , not , so no aria-label on the title. + $this->assertStringNotContainsString( 'class="row-title"', $output ); + $this->assertStringNotContainsString( 'aria-label="“', $output ); + + wp_set_current_user( 0 ); + } + /** * @ticket 37407 *