Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/wp-admin/includes/class-wp-posts-list-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<a class="row-title" href="%s">%s%s</a>',
'<a class="row-title" href="%s" aria-label="%s">%s%s</a>',
get_edit_post_link( $post->ID ),
esc_attr( $aria_label ),
$pad,
$title
);
Expand Down
12 changes: 6 additions & 6 deletions tests/e2e/specs/edit-posts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 } )
Expand Down Expand Up @@ -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 } )
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/specs/empty-trash-restore-trashed-posts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down
122 changes: 122 additions & 0 deletions tests/phpunit/tests/admin/wpPostsListTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 &#8220;/&#8221;.
$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 <span>, not <a class="row-title">, so no aria-label on the title.
$this->assertStringNotContainsString( 'class="row-title"', $output );
$this->assertStringNotContainsString( 'aria-label="&#8220;', $output );

wp_set_current_user( 0 );
}

/**
* @ticket 37407
*
Expand Down
Loading