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..0556aa24c7a21 100644
--- a/src/wp-admin/includes/class-wp-posts-list-table.php
+++ b/src/wp-admin/includes/class-wp-posts-list-table.php
@@ -1137,7 +1137,17 @@ public function column_title( $post ) {
echo '
' . $locked_avatar . ' ' . $locked_text . "
\n";
}
- $pad = str_repeat( '— ', $this->current_level );
+ /**
+ * Filters the string used to indicate hierarchy level in the posts list table.
+ *
+ * The string is repeated once per level, so a child two levels deep will
+ * have the separator string prepended twice.
+ *
+ * @param string $separator The string used to indicate hierarchy level. Default '— '.
+ * @param WP_Post $post The current post object.
+ */
+ $separator = apply_filters( 'post_title_child_separator', '— ', $post );
+ $pad = str_repeat( $separator, $this->current_level );
echo '';
$title = _draft_or_post_title();
diff --git a/tests/phpunit/tests/admin/wpPostsListTable.php b/tests/phpunit/tests/admin/wpPostsListTable.php
index 9d2482a034af7..34651201df7c4 100644
--- a/tests/phpunit/tests/admin/wpPostsListTable.php
+++ b/tests/phpunit/tests/admin/wpPostsListTable.php
@@ -309,6 +309,88 @@ public function test_empty_trash_button_should_not_be_shown_if_there_are_no_post
$this->assertStringNotContainsString( 'id="delete_all"', $output );
}
+ /**
+ * Tests that the default em-dash separator is output for child pages.
+ *
+ * @ticket 39106
+ *
+ * @covers WP_Posts_List_Table::column_title
+ */
+ public function test_column_title_uses_default_separator_for_child_pages() {
+ // A child page has post_parent > 0, so column_title() will auto-calculate current_level.
+ $child = self::$children[1][1];
+
+ $this->table->set_hierarchical_display( true );
+
+ ob_start();
+ $this->table->column_title( $child );
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( '— ', $output );
+ }
+
+ /**
+ * Tests that the post_title_child_separator filter replaces the separator.
+ *
+ * @ticket 39106
+ *
+ * @covers WP_Posts_List_Table::column_title
+ */
+ public function test_post_title_child_separator_filter_changes_separator() {
+ $child = self::$children[1][1];
+
+ $this->table->set_hierarchical_display( true );
+
+ add_filter(
+ 'post_title_child_separator',
+ static function () {
+ return '> ';
+ }
+ );
+
+ ob_start();
+ $this->table->column_title( $child );
+ $output = ob_get_clean();
+
+ remove_all_filters( 'post_title_child_separator' );
+
+ $this->assertStringContainsString( '> ', $output );
+ $this->assertStringNotContainsString( '— ', $output );
+ }
+
+ /**
+ * Tests that the post_title_child_separator filter receives the current WP_Post object.
+ *
+ * @ticket 39106
+ *
+ * @covers WP_Posts_List_Table::column_title
+ */
+ public function test_post_title_child_separator_filter_receives_post_object() {
+ $child = self::$children[1][1];
+
+ $this->table->set_hierarchical_display( true );
+
+ $received_post = null;
+ add_filter(
+ 'post_title_child_separator',
+ static function ( $separator, $post ) use ( &$received_post ) {
+ $received_post = $post;
+ return $separator;
+ },
+ 10,
+ 2
+ );
+
+ ob_start();
+ $this->table->column_title( $child );
+ ob_get_clean();
+
+ remove_all_filters( 'post_title_child_separator' );
+
+ $this->assertInstanceOf( WP_Post::class, $received_post );
+ $this->assertSame( $child->ID, $received_post->ID );
+ }
+
/**
* @ticket 42066
*