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
*