List child pages of current page but limit to specific year

Due to wp_list_pages() leveraging get_pages() to actually retrieve the Page posts, and because get_pages() implements it’s own query logic independent of WP_Query‘s, it’s not possible to use arbitrary query variables which are not directly supported by get_pages(), including year, mothnum, day, date_query, etc.

In order to continue using wp_list_pages() to this end, I think the most elegant solution would be to implement a custom Walker_Page class which you might pass to wp_list_pages() as an argument in order to customize the generated HTML.

As an added benefit, this solution continues to use a single get_pages() query instead of one query per year, and can fluidly handle grand-children pages and continue to scale as more years are added.

Tailored Walker_Page

In WordPress “Walkers” are classes which are designed to take lists representing hierarchical or tree-like data like that of a like of hierarchical post-types, taxonomies, or navigation menu items and turn them into markup for display.

wp_list_pages() provides a parameter to pass a custom Walker to it in place of the Walker_Page class it uses by default, allowing you to customize the markup the function produces.

// mytheme/lib/class-year-accordion-walker-page.php
// (or functions.php, or some other theme library code location)

/**
 * A Walker_Page customized to additionally group first-level pages into lists based
 * on their publication year.
 **/
class Year_Accordion_Walker_Page extends Walker_Page {
  /**
   * Creates the opening markup for a year's accordion group.
   *
   * @param string  &$output The string to append output to.
   * @param int     $year The year which to build group markup for.
   * @param boolean $is_active If this year matches the publication date for the current page, as indicated by "child_of".
   * @param array   $args Any additional arguments passed to `walk()`.
   **/
  public function start_accordion_group( &$output, $year, $is_active = false, $args = [] ) {
    // Setup HTML classes to be added to various parts of the markup.
    $container_classes = [];
    $header_classes    = [ 'ui-accordion-header' ];
    $content_classes   = [ 'ui-accordion-content' ];

    if( $is_active ) {
      $container_classes[] = 'current_page_ancestor';
      $header_classes[]    = 'ui-accordion-header-active';
      $content_classes[]   = 'ui-accordion-content-active';
    }

    // Add this accordion group's opening markup to the output, joining each line with the configured indentation.
    $output .= implode(
      "\n\t",
      [
        '<li class="' . implode( ' ', $container_classes ) . '">',
        '<h3 class="' . implode( ' ', $header_classes ) . '">' . $year . '</h3>',
        '<ul class="' . implode( ' ', $content_classes ) . '">',
      ]
    );
  }

  /**
   * Creates the closing markup for a year's accordion group.
   *
   * @param string  &$output The string to append output to.
   * @param int     $year The year which to build group markup for.
   * @param boolean $is_active If this year matches the publication date for the current page, as indicated by "child_of".
   * @param array   $args Any additional arguments passed to `walk()`.
   **/
  public function end_accordion_group( &$output, $year, $is_active = false, $args = [] ) {
    $output .= implode(
      "\n",
      [
        "\t</ul>", // Close the accordion group's page list.
        '</li>', // Close the accordion group's container li.
      ]
    );
  }

  /**
   * Display array of Pages, grouped by the topmost pages' publication year.
   *
   * $max_depth = 0 means display all levels.
   * $max_depth > 0 specifies the number of display levels.
   *
   * @param array $pages An array of WP_Post items.
   * @param int   $max_depth The maximum hierarchical depth.
   * @param mixed ...$args Optional additional arguments.
   * @return string Markup representing $pages grouped by publication year.
   **/
  public function walk( $pages, $max_depth = 0, ...$args ) {
    $output="";
    
    // Invalid parameter or nothing to walk.
    if ( $max_depth < 0 || empty( $pages ) )
      return $output;

    $options                 = $args[0]; // When used with `walk_page_tree()` this is an assoc array of any additional arguments.
    $current_page_id         = $args[1]; // When used with `walk_page_tree()` this is the current post ID.
    $active_page_year        = null; // Keep track of which year the current_page_id Page was published in for custom classes, etc.
    $parent_field            = $this->db_fields['parent']; // Inherited from `Walker_Page` - will always be `ID`.
    $parent_id               = isset( $options['child_of'] ) ? $options['child_of'] : 0; // The ID of the top-level common parent, if any.
    $top_level_pages_by_year = []; // A mapping of year => top-level pages (parent is $parent_id, or no parent if $parent_id is not set).
    $child_pages_by_parent   = []; // A mapping of parent_id => pages, collecting all pages whose parent is not $parent_id (or who have a parent, if $parent_id is not set)

    // Split $pages into $child_pages_by_parent and $top_level_pages_by_year.
    foreach( $pages as $page ) {
      $year = date( 'Y', strtotime( $page->post_date ) );

      if( $page->ID === $current_page_id )
        $active_page_year = $year; // If this is the current page, record the year in which it was published.

      // If this is a grandchild page, we don't care about it's publish date - just add it to the map of parent_id => child pages.
      if( ( empty( $parent_id ) && ! empty( $page->$parent_field ) ) || ( ! empty( $parent_id ) && $page->$parent_field !== $parent_id ) ) {
        if( ! isset( $child_pages_by_parent[ $page->$parent_field ] ) )
          $child_pages_by_parent[ $page->$parent_field ] = [];

        $child_pages_by_parent[ $page->$parent_field ][] = $page;
        continue;
      }

      if( ! isset( $top_level_pages_by_year[ $year ] ) )
        $top_level_pages_by_year[ $year ] = [];
      
      $top_level_pages_by_year[ $year ][] = $page; // Add the top level page to it's respective year list.
    }

    // Build the markup, one year at a time.
    foreach( $top_level_pages_by_year as $year => $pages ) {
      // Build the opening markup for this year's accordion group.
      $this->start_accordion_group( $output, $year, $year == $active_page_year, $args );

      // Add each top-level page in the group's child tree markup as per usual.
      foreach( $pages as $page )
        $this->display_element( $page, $child_pages_by_parent, $max_depth, 1, $args, $output );

      // Close this year's accordion group.
      $this->end_accordion_group( $output, $year, $year == $active_page_year, $args );
    }

    return $output;
  }
}

With the above class available, wp_list_pages() can be called to utilize it as such:

// Template file
wp_list_pages(
  [
    'title_li'    => '&',
    'child_of'    => $id,
    'show_date'   => 'modified',
    'date_format' => $date_format,
    'walker'      => new Year_Accordion_Walker_Page,
  ]
); 

Finishing Touches

For the icing on the cake, you might even create a “template tag” function to load and leverage this class when necessary:

// functions.php
// (or other library file loaded by functions.php)

/**
 * Display pages in an accordion, grouped by year. Loads the customized
 * Walker_Page if it's not already available.
 *
 * @param array $args Array of wp_list_pages() arguments.
 **/
function wpse390524_page_year_accordion( $args = [] ) {
  if( ! class_exists( 'Year_Accordion_Walker_Page' ) )
    require_once get_stylesheet_directory() . '/lib/class-year-accordion-walker-page.php';

  $args['walker'] = new Year_Accordion_Walker_Page;

  wp_list_pages( $args );
}
// Template file
wpse390524_page_year_accordion(
  [
    'title_li'    => '&',
    'child_of'    => $id,
    'show_date'   => 'modified',
    'date_format' => $date_format,
  ]
);