diff --git a/assets/apps/customizer-controls/src/builder/scss/_customizer-adjustments.scss b/assets/apps/customizer-controls/src/builder/scss/_customizer-adjustments.scss index 6e2d3268c4..6fa834e7e5 100644 --- a/assets/apps/customizer-controls/src/builder/scss/_customizer-adjustments.scss +++ b/assets/apps/customizer-controls/src/builder/scss/_customizer-adjustments.scss @@ -12,7 +12,14 @@ ul[id^="sub-accordion-panel-hfg_"] { } #accordion-section-neve_header_presets, - #accordion-section-neve_footer_copyright_section { + #accordion-section-neve_footer_copyright_section, + #accordion-section-neve_global_footer_settings { margin-top: 20px; } + + // Only some sites get the copyright section; when it is there, the two form one group. + #accordion-section-neve_footer_copyright_section + + #accordion-section-neve_global_footer_settings { + margin-top: 0; + } } diff --git a/assets/apps/customizer-controls/src/scss/_general.scss b/assets/apps/customizer-controls/src/scss/_general.scss index 8140f9f19a..73f3935611 100644 --- a/assets/apps/customizer-controls/src/scss/_general.scss +++ b/assets/apps/customizer-controls/src/scss/_general.scss @@ -75,7 +75,8 @@ #sub-accordion-panel-hfg_header #accordion-section-neve_header_presets, -#sub-accordion-panel-hfg_footer #accordion-section-neve_footer_copyright_section { +#sub-accordion-panel-hfg_footer #accordion-section-neve_footer_copyright_section, +#sub-accordion-panel-hfg_footer #accordion-section-neve_global_footer_settings { display: list-item !important; } diff --git a/e2e-tests/specs/customizer/header-footer-visibility/header-footer-visibility.spec.ts b/e2e-tests/specs/customizer/header-footer-visibility/header-footer-visibility.spec.ts new file mode 100644 index 0000000000..d5ecabe8f3 --- /dev/null +++ b/e2e-tests/specs/customizer/header-footer-visibility/header-footer-visibility.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test'; +import { setCustomizeSettings } from '../../../utils'; + +const HEADER_WRAPPER = 'header.header'; +const FOOTER_WRAPPER = '#site-footer'; +const SKIP_LINK = 'a.neve-skip-link'; + +test.describe( 'Global header and footer visibility', function () { + test( 'Shows both toggles in the builder panels', async function ( { page } ) { + // The builder panels hide every section they do not know, so a registered + // control can still be invisible. Only the browser catches that. + await page.goto( '/wp-admin/customize.php' ); + await page.waitForSelector( '#customize-theme-controls' ); + + const panels = { + hfg_header: { + section: 'neve_pro_global_header_settings', + control: 'neve_disable_header', + }, + hfg_footer: { + section: 'neve_global_footer_settings', + control: 'neve_disable_footer', + }, + }; + + for ( const [ panel, item ] of Object.entries( panels ) ) { + await page.click( `#accordion-panel-${ panel } h3` ); + + const section = page.locator( `#accordion-section-${ item.section }` ); + await expect( section ).toBeVisible(); + + await section.locator( 'h3' ).click(); + const control = page.locator( `#customize-control-${ item.control }` ); + await expect( control ).toBeVisible(); + + // Assert on the structure. The label text is translated. + await expect( + control.locator( 'input[type="checkbox"]' ) + ).toHaveCount( 1 ); + + await page.goto( '/wp-admin/customize.php' ); + await page.waitForSelector( '#customize-theme-controls' ); + } + } ); + + test( 'Renders both parts when nothing is turned off', async function ( { + page, + request, + baseURL, + } ) { + await setCustomizeSettings( + 'hf-visible', + { neve_disable_header: false, neve_disable_footer: false }, + { request, baseURL } + ); + await page.goto( '/hello-world/?test_name=hf-visible' ); + + await expect( page.locator( HEADER_WRAPPER )).toHaveCount( 1 ); + await expect( page.locator( FOOTER_WRAPPER )).toHaveCount( 1 ); + } ); + + test( 'Drops the header wrapper and keeps the skip link', async function ( { + page, + request, + baseURL, + } ) { + await setCustomizeSettings( + 'hf-no-header', + { neve_disable_header: true }, + { request, baseURL } + ); + await page.goto( '/hello-world/?test_name=hf-no-header' ); + + // The wrapper used to stay behind, empty, holding only the skip link. + await expect( page.locator( HEADER_WRAPPER )).toHaveCount( 0 ); + await expect( page.locator( SKIP_LINK )).toHaveCount( 1 ); + + // The rest of the page is untouched. + await expect( page.locator( 'main#content' )).toHaveCount( 1 ); + await expect( page.locator( FOOTER_WRAPPER )).toHaveCount( 1 ); + } ); + + test( 'Drops the footer and leaves the header alone', async function ( { + page, + request, + baseURL, + } ) { + await setCustomizeSettings( + 'hf-no-footer', + { neve_disable_footer: true }, + { request, baseURL } + ); + await page.goto( '/hello-world/?test_name=hf-no-footer' ); + + await expect( page.locator( FOOTER_WRAPPER )).toHaveCount( 0 ); + await expect( page.locator( HEADER_WRAPPER )).toHaveCount( 1 ); + } ); + + test( 'Reaches an archive, where the page metabox never did', async function ( { + page, + request, + baseURL, + } ) { + await setCustomizeSettings( + 'hf-archive', + { neve_disable_header: true, neve_disable_footer: true }, + { request, baseURL } + ); + await page.goto( '/?s=hello&test_name=hf-archive' ); + + await expect( page.locator( 'body.search' )).toHaveCount( 1 ); + await expect( page.locator( HEADER_WRAPPER )).toHaveCount( 0 ); + await expect( page.locator( FOOTER_WRAPPER )).toHaveCount( 0 ); + } ); +} ); diff --git a/header-footer-grid/Core/Builder/Footer.php b/header-footer-grid/Core/Builder/Footer.php index d36c6e8522..482e05ca67 100644 --- a/header-footer-grid/Core/Builder/Footer.php +++ b/header-footer-grid/Core/Builder/Footer.php @@ -162,6 +162,8 @@ function ( $value ) { * @access public */ public function customize_register( WP_Customize_Manager $wp_customize ) { + $this->customize_global_footer( $wp_customize ); + if ( $this->has_valid_addons() || ! Migration_Flags::is_new_user_after_v41() ) { return parent::customize_register( $wp_customize ); } @@ -192,10 +194,47 @@ public function customize_register( WP_Customize_Manager $wp_customize ) { 'setting' => 'footer_copyright_content', ] ); - + return parent::customize_register( $wp_customize ); } + /** + * Registers the footer options section, the counterpart of the global header settings. + * + * @param WP_Customize_Manager $wp_customize The Customize Manager. + * + * @return void + */ + private function customize_global_footer( WP_Customize_Manager $wp_customize ) { + $wp_customize->add_section( + 'neve_global_footer_settings', + [ + 'title' => __( 'Footer Options', 'neve' ), + 'priority' => 201, + 'panel' => 'hfg_footer', + ] + ); + + $wp_customize->add_setting( + 'neve_disable_footer', + [ + 'default' => false, + 'transport' => 'refresh', + 'sanitize_callback' => 'neve_sanitize_checkbox', + ] + ); + + $wp_customize->add_control( + 'neve_disable_footer', + [ + 'label' => esc_html__( 'Disable Footer', 'neve' ), + 'section' => 'neve_global_footer_settings', + 'type' => 'neve_toggle_control', + 'priority' => 5, + ] + ); + } + /** * Add footer component. * diff --git a/header-footer-grid/Core/Builder/Header.php b/header-footer-grid/Core/Builder/Header.php index 975c271ffe..50e165f8a3 100644 --- a/header-footer-grid/Core/Builder/Header.php +++ b/header-footer-grid/Core/Builder/Header.php @@ -39,6 +39,7 @@ class Header extends Abstract_Builder { const BACKGROUND_HEADING = 'background_heading'; const ADVANCED_STYLE = 'advanced_style'; const BACKGROUND_SETTING = 'background'; + const DISABLE_HEADER = 'neve_disable_header'; /** * Default color for global header background @@ -111,6 +112,8 @@ public function init() { */ public function customize_register( WP_Customize_Manager $wp_customize ) { if ( ! neve_is_new_builder() ) { + $this->customize_legacy_global_header( $wp_customize ); + return parent::customize_register( $wp_customize ); } @@ -206,12 +209,89 @@ private function move_pro_controls( WP_Customize_Manager $wp_customize, WP_Custo ksort( $global_settings_tabs->tabs ); } + /** + * Arguments of the setting behind the toggle that hides the header on every page. + * + * @return array{default: false, transport: string, sanitize_callback: string} + */ + private function get_disable_header_setting_args(): array { + return [ + 'default' => false, + 'transport' => 'refresh', + 'sanitize_callback' => 'neve_sanitize_checkbox', + ]; + } + + /** + * Arguments of the toggle that hides the header on every page. + * + * @return array{label: string, section: string, type: string, priority: int} + */ + private function get_disable_header_control_args(): array { + return [ + 'label' => esc_html__( 'Disable Header', 'neve' ), + 'section' => 'neve_pro_global_header_settings', + 'type' => 'neve_toggle_control', + 'priority' => 5, + ]; + } + + /** + * Registers the header toggle for the legacy builder. + * + * The legacy builder has no global header settings tabs, so the toggle is a plain control + * and any Neve Pro controls in the section keep showing as before. + * + * @param WP_Customize_Manager $wp_customize The Customize Manager. + * + * @return void + */ + private function customize_legacy_global_header( WP_Customize_Manager $wp_customize ) { + if ( $wp_customize->get_section( 'neve_pro_global_header_settings' ) === null ) { + $wp_customize->add_section( + 'neve_pro_global_header_settings', + [ + 'title' => __( 'Global Header Settings', 'neve' ), + 'priority' => 200, + 'panel' => 'hfg_header', + ] + ); + } + + $setting_args = $this->get_disable_header_setting_args(); + + // Theme Check reads the sanitize_callback key from the add_setting() call itself. + $wp_customize->add_setting( + self::DISABLE_HEADER, + [ + 'default' => $setting_args['default'], + 'transport' => $setting_args['transport'], + 'sanitize_callback' => $setting_args['sanitize_callback'], + ] + ); + $wp_customize->add_control( self::DISABLE_HEADER, $this->get_disable_header_control_args() ); + } + /** * Registers controls for global header background */ private function customize_global_header() { $section_id = 'neve_pro_global_header_settings'; + // Not a conditional header setting: it hides every header layout at once. + SettingsManager::get_instance()->add( + array_merge( + $this->get_disable_header_setting_args(), + $this->get_disable_header_control_args(), + [ + 'id' => self::DISABLE_HEADER, + 'noformat' => true, + 'group' => $section_id, + 'tab' => 'general', + ] + ) + ); + SettingsManager::get_instance()->add( [ 'id' => self::BACKGROUND_HEADING, diff --git a/header.php b/header.php index 6825cdf20a..8fb79a6ce0 100644 --- a/header.php +++ b/header.php @@ -75,31 +75,45 @@ do_action( 'neve_before_header_wrapper_hook' ); ?> -
> - - - - > + + + + +
> +
+ -
> - - - - > + + - if ( apply_filters( 'neve_filter_toggle_content_parts', true, 'header' ) === true ) { - $this->handle_theme_part( 'header', $template ); - } + handle_theme_part( 'header', $template ); + } + + do_action( 'neve_after_header_hook' ); + $header_markup = (string) ob_get_clean(); - do_action( 'neve_after_header_hook' ); + if ( $has_header || trim( $header_markup ) !== '' ) { ?> +
> +
+ '; + // The observer watches its own parent unless it is given a target. Its parent here is + // the page wrapper, which is never out of the viewport, so it watches this anchor instead. + echo '
'; + echo ''; // We use 2 `amp-animation` elements to trigger the visibility of the button. The first one is for making the button visible echo ' diff --git a/inc/views/tweaks.php b/inc/views/tweaks.php index 5860508b67..683c89894f 100644 --- a/inc/views/tweaks.php +++ b/inc/views/tweaks.php @@ -17,5 +17,29 @@ class Tweaks extends Base_View { public function init() { // Remove gallery default style. add_filter( 'use_default_gallery_style', '__return_false' ); + add_filter( 'neve_filter_toggle_content_parts', array( $this, 'filter_global_visibility' ), 200, 2 ); + } + + /** + * Hide the header or the footer when they are turned off for the whole site. + * + * Runs after the post meta filter, because the metabox writes 'off' by default and + * that value cannot tell an untouched post from a deliberate one. + * + * @param bool $status Whether the part is rendered or not. + * @param string $context The part name. + * + * @return bool + */ + public function filter_global_visibility( $status, $context ) { + if ( $context !== 'header' && $context !== 'footer' ) { + return $status; + } + + if ( get_theme_mod( 'neve_disable_' . $context, false ) ) { + return false; + } + + return $status; } } diff --git a/tests/stubs/amp.php b/tests/stubs/amp.php new file mode 100644 index 0000000000..3b4516df33 --- /dev/null +++ b/tests/stubs/amp.php @@ -0,0 +1,20 @@ +previous_customizer = isset( $GLOBALS['wp_customize'] ) ? $GLOBALS['wp_customize'] : null; + + // Another test in the suite declares the WooCommerce class; the theme then + // calls the conditional tags that come with it. + require_once __DIR__ . '/stubs/woocommerce-cart.php'; + require_once __DIR__ . '/stubs/amp.php'; + + $this->mark_header_and_footer_hooks(); + } + + /** + * Teardown. + */ + public function tearDown(): void { + if ( $this->previous_customizer === null ) { + unset( $GLOBALS['wp_customize'] ); + } else { + $GLOBALS['wp_customize'] = $this->previous_customizer; + } + + unset( $GLOBALS['neve_tests_is_amp'] ); + remove_theme_mod( 'neve_disable_header' ); + remove_theme_mod( 'neve_disable_footer' ); + remove_theme_mod( 'neve_migrated_builders' ); + + parent::tearDown(); + } + + /** + * Print a marker on each header and footer hook, so the tests can tell which ones ran. + * + * @return void + */ + private function mark_header_and_footer_hooks() { + $hooks = array( + 'neve_before_header_wrapper_hook', + 'neve_before_header_hook', + 'neve_do_header', + 'neve_after_header_hook', + 'neve_after_header_wrapper_hook', + 'neve_before_footer_hook', + 'neve_do_footer', + 'neve_after_footer_hook', + ); + + foreach ( $hooks as $hook ) { + add_action( + $hook, + function () use ( $hook ) { + echo '[' . esc_html( $hook ) . ']'; + } + ); + } + } + + /** + * Render the theme header. + * + * @return string + */ + private function render_header() { + // Keep the markup under test free of whatever plugins print in the head. + remove_all_actions( 'wp_head' ); + + ob_start(); + load_template( get_theme_file_path( 'header.php' ), false ); + + return (string) ob_get_clean(); + } + + /** + * Render the theme footer. + * + * @return string + */ + private function render_footer() { + remove_all_actions( 'wp_footer' ); + + ob_start(); + load_template( get_theme_file_path( 'footer.php' ), false ); + + return (string) ob_get_clean(); + } + + /** + * Make a published page that carries meta. + * + * @param array $meta Meta keys and values. + * + * @return int + */ + private function make_page( $meta = array() ) { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + + foreach ( $meta as $key => $value ) { + update_post_meta( $page_id, $key, $value ); + } + + return $page_id; + } + + /** + * Whether the part is rendered. + * + * @param string $context The part name. + * + * @return bool + */ + private function renders( $context ) { + return apply_filters( 'neve_filter_toggle_content_parts', true, $context ); + } + + /** + * The parts this feature covers. + * + * @return array + */ + public function header_and_footer() { + return array( + 'header' => array( 'header' ), + 'footer' => array( 'footer' ), + ); + } + + /* + |-------------------------------------------------------------------------- + | What the toggle decides + |-------------------------------------------------------------------------- + */ + + /** + * The header and the footer are rendered when no global toggle is set. + */ + public function test_header_and_footer_render_by_default() { + $this->assertTrue( $this->renders( 'header' ) ); + $this->assertTrue( $this->renders( 'footer' ) ); + } + + /** + * Each toggle hides its own part and leaves the other one alone. + * + * @param string $part The part name. + * + * @dataProvider header_and_footer + */ + public function test_each_toggle_hides_only_its_own_part( $part ) { + $other = $part === 'header' ? 'footer' : 'header'; + set_theme_mod( 'neve_disable_' . $part, true ); + + $this->assertFalse( $this->renders( $part ) ); + $this->assertTrue( $this->renders( $other ) ); + } + + /** + * The toggles leave every other content part alone. + */ + public function test_other_content_parts_are_untouched() { + set_theme_mod( 'neve_disable_header', true ); + set_theme_mod( 'neve_disable_footer', true ); + + $this->assertTrue( $this->renders( 'title' ) ); + $this->assertTrue( $this->renders( 'featured-image' ) ); + $this->assertFalse( apply_filters( 'neve_filter_toggle_content_parts', false, 'sidebar' ) ); + } + + /* + |-------------------------------------------------------------------------- + | The toggle against the post meta + |-------------------------------------------------------------------------- + */ + + /** + * The global toggle wins over a page that carries the default meta value. + * + * The metabox stores 'off' both for an untouched page and for one that was turned on + * and off again, so 'off' must not count as a deliberate opt in. + * + * @param string $part The part name. + * + * @dataProvider header_and_footer + */ + public function test_global_toggle_wins_over_an_off_meta_value( $part ) { + set_theme_mod( 'neve_disable_' . $part, true ); + $this->go_to( get_permalink( $this->make_page( array( 'neve_meta_disable_' . $part => 'off' ) ) ) ); + + $this->assertFalse( $this->renders( $part ) ); + } + + /** + * A single page still turns the parts off on its own. + * + * @param string $part The part name. + * + * @dataProvider header_and_footer + */ + public function test_post_meta_still_disables_a_part_on_its_own( $part ) { + $this->go_to( get_permalink( $this->make_page( array( 'neve_meta_disable_' . $part => 'on' ) ) ) ); + + $this->assertFalse( $this->renders( $part ) ); + } + + /** + * Meta on one page does not leak to another. + */ + public function test_meta_does_not_leak_between_pages() { + $with_meta = $this->make_page( array( 'neve_meta_disable_header' => 'on' ) ); + $plain = $this->make_page(); + + $this->go_to( get_permalink( $with_meta ) ); + $this->assertFalse( $this->renders( 'header' ) ); + + $this->go_to( get_permalink( $plain ) ); + $this->assertTrue( $this->renders( 'header' ) ); + } + + /** + * The global toggle reaches the views that have no metabox. + * + * @param string $context The view to open. + * + * @dataProvider views_without_a_metabox + */ + public function test_global_toggle_reaches_views_without_a_metabox( $context ) { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + if ( $context === 'category' ) { + $category_id = self::factory()->category->create(); + wp_set_post_categories( $post_id, array( $category_id ) ); + $url = get_category_link( $category_id ); + } else { + $url = home_url( '/?s=neve' ); + } + + set_theme_mod( 'neve_disable_header', true ); + $this->go_to( $url ); + + // Guard the fixture: the assertion only means something in the right view. + $this->assertFalse( is_singular(), 'The metabox covers singular views, this must not be one.' ); + $this->assertFalse( $this->renders( 'header' ) ); + } + + /** + * Views the metabox never reached. + * + * @return array + */ + public function views_without_a_metabox() { + return array( + 'category archive' => array( 'category' ), + 'search results' => array( 'search' ), + ); + } + + /* + |-------------------------------------------------------------------------- + | The markup + |-------------------------------------------------------------------------- + */ + + /** + * The header wrapper is rendered when the header is on. + */ + public function test_header_wrapper_is_rendered_when_enabled() { + $markup = $this->render_header(); + + $this->assertStringContainsString( 'assertStringContainsString( '[neve_do_header]', $markup ); + } + + /** + * Leave nothing hooked right before and right after the header, the way a site without Neve Pro is. + * + * @return void + */ + private function unhook_around_the_header() { + remove_all_actions( 'neve_before_header_hook' ); + remove_all_actions( 'neve_after_header_hook' ); + } + + /** + * Turn the header off globally or for the current page. + * + * @return array + */ + public function provide_header_switches() { + return array( + 'global toggle' => array( 'global' ), + 'page meta' => array( 'meta' ), + ); + } + + /** + * Turn the header off. + * + * @param string $switch Either global or meta. + * + * @return void + */ + private function disable_header( $switch ) { + if ( $switch === 'global' ) { + set_theme_mod( 'neve_disable_header', true ); + + return; + } + + $this->go_to( get_permalink( $this->make_page( array( 'neve_meta_disable_header' => 'on' ) ) ) ); + } + + /** + * A disabled header with nothing hooked around it leaves no empty wrapper behind. + * + * @dataProvider provide_header_switches + * + * @param string $switch Either global or meta. + */ + public function test_disabled_header_leaves_no_empty_wrapper_behind( $switch ) { + $this->unhook_around_the_header(); + $this->disable_header( $switch ); + $markup = $this->render_header(); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( '[neve_do_header]', $markup ); + } + + /** + * A disabled header keeps what other code prints around it, such as the Neve Pro page header. + * + * @dataProvider provide_header_switches + * + * @param string $switch Either global or meta. + */ + public function test_disabled_header_keeps_content_hooked_around_it( $switch ) { + $this->disable_header( $switch ); + $markup = $this->render_header(); + + $this->assertStringContainsString( 'assertStringContainsString( '[neve_before_header_hook]', $markup ); + $this->assertStringContainsString( '[neve_after_header_hook]', $markup ); + $this->assertStringNotContainsString( '[neve_do_header]', $markup ); + } + + /** + * Hooks that print only whitespace do not bring the empty wrapper back. + */ + public function test_whitespace_around_a_disabled_header_leaves_no_wrapper() { + $this->unhook_around_the_header(); + add_action( + 'neve_after_header_hook', + function () { + echo "\n\t \n"; + } + ); + set_theme_mod( 'neve_disable_header', true ); + + $this->assertStringNotContainsString( 'render_header() ); + } + + /** + * On AMP the skip link hides on the pages that infinite scroll appends, as it did inside the header. + */ + public function test_the_skip_link_hides_on_appended_amp_pages() { + $this->assertDoesNotMatchRegularExpression( '/class="neve-skip-link[^>]*next-page-hide/', $this->render_header() ); + + $GLOBALS['neve_tests_is_amp'] = true; + set_theme_mod( 'neve_disable_header', true ); + + $this->assertMatchesRegularExpression( '/class="neve-skip-link[^>]*next-page-hide/', $this->render_header() ); + } + + /** + * A disabled header keeps the skip link, the page skeleton and the wrapper hooks. + */ + public function test_disabled_header_keeps_the_skip_link_and_the_skeleton() { + set_theme_mod( 'neve_disable_header', true ); + $markup = $this->render_header(); + + $this->assertStringContainsString( 'neve-skip-link', $markup ); + $this->assertStringContainsString( '
', $markup ); + $this->assertStringContainsString( '
assertStringContainsString( '[neve_before_header_wrapper_hook]', $markup ); + $this->assertStringContainsString( '[neve_after_header_wrapper_hook]', $markup ); + } + + /** + * The footer is rendered when it is on. + */ + public function test_footer_is_rendered_when_enabled() { + $this->assertStringContainsString( '[neve_do_footer]', $this->render_footer() ); + } + + /** + * A disabled footer runs no footer hooks and still closes the page. + */ + public function test_disabled_footer_runs_no_hooks_and_closes_the_page() { + set_theme_mod( 'neve_disable_footer', true ); + $markup = $this->render_footer(); + + $this->assertStringNotContainsString( '[neve_do_footer]', $markup ); + $this->assertStringNotContainsString( '[neve_before_footer_hook]', $markup ); + $this->assertStringContainsString( '
', $markup ); + $this->assertStringContainsString( '', $markup ); + } + + /* + |-------------------------------------------------------------------------- + | The block theme template + |-------------------------------------------------------------------------- + */ + + /** + * Render the header of the block theme compatibility layer on a new page. + * + * @param array $meta Meta keys and values of the page. + * + * @return string + */ + private function render_fse_header( $meta = array() ) { + $fse = new \Neve\Compatibility\Fse(); + $this->go_to( get_permalink( $this->make_page( $meta ) ) ); + + $template = $fse->get_template_slug(); + $this->assertSame( 'page', $template, 'Expected the page template.' ); + set_theme_mod( \Neve\Compatibility\Fse::FSE_ENABLED_SLUG, true ); + set_theme_mod( 'neve_fse_' . $template, true ); + + ob_start(); + $fse->handle_header(); + + return (string) ob_get_clean(); + } + + /** + * The block theme header wrapper is rendered when the header is on. + */ + public function test_fse_header_wrapper_is_rendered_when_enabled() { + $markup = $this->render_fse_header(); + + $this->assertStringContainsString( 'assertStringContainsString( '[neve_do_header]', $markup ); + } + + /** + * Render the block theme header with the header turned off globally or for the page. + * + * @param string $switch Either global or meta. + * + * @return string + */ + private function render_disabled_fse_header( $switch ) { + if ( $switch === 'global' ) { + set_theme_mod( 'neve_disable_header', true ); + + return $this->render_fse_header(); + } + + return $this->render_fse_header( array( 'neve_meta_disable_header' => 'on' ) ); + } + + /** + * A disabled header leaves no empty wrapper behind in the block theme template either. + * + * @dataProvider provide_header_switches + * + * @param string $switch Either global or meta. + */ + public function test_fse_disabled_header_leaves_no_wrapper_behind( $switch ) { + $this->unhook_around_the_header(); + $markup = $this->render_disabled_fse_header( $switch ); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( '[neve_do_header]', $markup ); + $this->assertStringContainsString( 'neve-skip-link', $markup ); + $this->assertStringContainsString( '
render_disabled_fse_header( $switch ); + + $this->assertStringContainsString( 'assertStringContainsString( '[neve_after_header_hook]', $markup ); + $this->assertStringNotContainsString( '[neve_do_header]', $markup ); + } + + /** + * On AMP the block theme skip link hides on the pages that infinite scroll appends, too. + */ + public function test_the_fse_skip_link_hides_on_appended_amp_pages() { + $GLOBALS['neve_tests_is_amp'] = true; + + $this->assertMatchesRegularExpression( '/class="neve-skip-link[^>]*next-page-hide/', $this->render_fse_header() ); + } + + /* + |-------------------------------------------------------------------------- + | The customizer controls + |-------------------------------------------------------------------------- + */ + + /** + * Run a full customizer cycle, with every other module registering too. + * + * The builders hook into customize_register only in the admin, so they are called directly. + * + * @return WP_Customize_Manager + */ + private function register_customizer() { + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + + global $wp_customize; + $wp_customize = new WP_Customize_Manager(); + + $loader = new \Neve\Customizer\Loader(); + $loader->init(); + do_action( 'customize_register', $wp_customize ); + + foreach ( array( 'header', 'footer' ) as $builder ) { + \HFG\Main::get_instance()->get_builder( $builder )->customize_register( $wp_customize ); + } + + return $wp_customize; + } + + /** + * Assert that a toggle saves a boolean and starts off. + * + * @param WP_Customize_Manager $wp_customize The Customize Manager. + * @param string $id The setting id. + * + * @return void + */ + private function assert_toggle_setting( $wp_customize, $id ) { + $control = $wp_customize->get_control( $id ); + $this->assertNotNull( $control, 'The ' . $id . ' toggle is missing.' ); + $this->assertSame( 'neve_toggle_control', $control->type ); + + $setting = $wp_customize->get_setting( $id ); + $this->assertFalse( $setting->default ); + $this->assertSame( 'refresh', $setting->transport ); + $this->assertTrue( $setting->sanitize( 'on' ) ); + $this->assertFalse( $setting->sanitize( '' ) ); + } + + /** + * The header toggle opens the general tab of the global header settings. + */ + public function test_the_header_toggle_sits_in_the_global_header_settings() { + $wp_customize = $this->register_customizer(); + + $this->assert_toggle_setting( $wp_customize, 'neve_disable_header' ); + + $section = $wp_customize->get_section( 'neve_pro_global_header_settings' ); + $this->assertNotNull( $section ); + $this->assertSame( 'hfg_header', $section->panel ); + $this->assertSame( 'neve_pro_global_header_settings', $wp_customize->get_control( 'neve_disable_header' )->section ); + + // The tabs only show the controls they list, and the first tab is the one that opens. + $tabs = $wp_customize->get_control( 'neve_pro_global_header_settings_tabs' ); + $this->assertSame( array( 'general', 'style' ), array_keys( $tabs->tabs ) ); + $this->assertArrayHasKey( 'neve_disable_header', $tabs->controls['general'] ); + } + + /** + * Sites still on the legacy builder keep both toggles. + * + * The legacy builder has no global header settings tabs, so the header toggle is a plain control there. + */ + public function test_the_toggles_stay_on_the_legacy_builder() { + set_theme_mod( 'neve_migrated_builders', false ); + + $wp_customize = $this->register_customizer(); + + $this->assert_toggle_setting( $wp_customize, 'neve_disable_header' ); + $this->assert_toggle_setting( $wp_customize, 'neve_disable_footer' ); + + $section = $wp_customize->get_section( 'neve_pro_global_header_settings' ); + $this->assertNotNull( $section ); + $this->assertSame( 'hfg_header', $section->panel ); + $this->assertSame( 'neve_pro_global_header_settings', $wp_customize->get_control( 'neve_disable_header' )->section ); + $this->assertNull( $wp_customize->get_control( 'neve_pro_global_header_settings_tabs' ) ); + } + + /** + * On the legacy builder, a global header settings section that Neve Pro registered first is kept. + */ + public function test_the_legacy_builder_keeps_an_existing_global_header_section() { + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + + set_theme_mod( 'neve_migrated_builders', false ); + + global $wp_customize; + $wp_customize = new WP_Customize_Manager(); + $wp_customize->add_section( + 'neve_pro_global_header_settings', + array( + 'title' => 'Registered first', + 'priority' => 100, + 'panel' => 'hfg_header', + ) + ); + + \HFG\Main::get_instance()->get_builder( 'header' )->customize_register( $wp_customize ); + + $section = $wp_customize->get_section( 'neve_pro_global_header_settings' ); + $this->assertSame( 'Registered first', $section->title ); + $this->assertSame( 100, $section->priority ); + $this->assertSame( 'neve_pro_global_header_settings', $wp_customize->get_control( 'neve_disable_header' )->section ); + } + + /** + * Sites that started before 4.0.1 and sites that started after it. + * + * @return array + */ + public function provide_user_since_versions() { + return array( + 'older site, no copyright section' => array( 'unknown', false ), + 'newer site, copyright section' => array( '4.2.0', true ), + ); + } + + /** + * The footer toggle gets its own global settings section, with or without the copyright section. + * + * @dataProvider provide_user_since_versions + * + * @param string $user_since The version the site started on. + * @param bool $has_copyright Whether the copyright section registers. + */ + public function test_the_footer_toggle_sits_in_the_global_footer_settings( $user_since, $has_copyright ) { + update_option( \Neve\Core\Migration_Flags::USER_SINCE_VERSION, $user_since ); + + $wp_customize = $this->register_customizer(); + + $this->assert_toggle_setting( $wp_customize, 'neve_disable_footer' ); + + $section = $wp_customize->get_section( 'neve_global_footer_settings' ); + $this->assertNotNull( $section ); + $this->assertSame( 'hfg_footer', $section->panel ); + $this->assertSame( 'neve_global_footer_settings', $wp_customize->get_control( 'neve_disable_footer' )->section ); + + // The copyright section sits right above it on newer sites. + $this->assertSame( $has_copyright, $wp_customize->get_section( 'neve_footer_copyright_section' ) !== null ); + } + + /** + * The builder panels hide every section by default, so the toggles' sections have to be named in the styles. + * + * Without this rule the sections register, render and stay invisible. + */ + public function test_the_sections_are_whitelisted_in_the_builder_panel_styles() { + $path = get_theme_file_path( 'assets/apps/customizer-controls/src/scss/_general.scss' ); + $this->assertFileExists( $path ); + + $scss = file_get_contents( $path ); + + $panels = array( + 'hfg_header' => 'neve_pro_global_header_settings', + 'hfg_footer' => 'neve_global_footer_settings', + ); + + foreach ( $panels as $panel => $section ) { + $this->assertStringContainsString( + '#sub-accordion-panel-' . $panel . ' #accordion-section-' . $section, + (string) $scss, + 'Sections in the builder panels stay hidden unless this rule names them.' + ); + } + } + /* + |-------------------------------------------------------------------------- + | Other plugins that render into the header + |-------------------------------------------------------------------------- + */ + + /** + * The AMP state elements outlive a disabled header. + * + * They are printed around the header but one of them drives the WooCommerce shop + * sidebar, which is still on the page when the header is gone. + */ + public function test_amp_states_survive_a_disabled_header() { + $this->unhook_around_the_header(); + $GLOBALS['neve_tests_is_amp'] = true; + $amp = new \Neve\Compatibility\Amp(); + $amp->register_hooks(); + + $with_header = $this->render_header(); + $this->assertStringContainsString( 'assertStringContainsString( 'nvAmpWooSidebarExpanded', $with_header ); + + set_theme_mod( 'neve_disable_header', true ); + $without_header = $this->render_header(); + + $this->assertStringNotContainsString( 'assertStringContainsString( 'nvAmpWooSidebarExpanded', $without_header ); + $this->assertStringContainsString( 'nvAmpMenuExpanded', $without_header ); + } + + /** + * The AMP scroll to top animations outlive a disabled header. + * + * The button is hidden until an animation shows it, so it never appears when the + * animations are gone. + */ + public function test_amp_scroll_to_top_animations_survive_a_disabled_header() { + $this->unhook_around_the_header(); + $GLOBALS['neve_tests_is_amp'] = true; + $scroll_to_top = new \Neve\Views\Scroll_To_Top(); + $scroll_to_top->init(); + + $with_header = $this->render_header(); + $this->assertStringContainsString( 'assertStringContainsString( 'id="showAnim"', $with_header ); + + set_theme_mod( 'neve_disable_header', true ); + $without_header = $this->render_header(); + + $this->assertStringNotContainsString( 'assertStringContainsString( 'id="showAnim"', $without_header ); + $this->assertStringContainsString( 'id="hideAnim"', $without_header ); + } + + /** + * The AMP position observer watches an element that scrolls out of view. + * + * Left without a target it watches its own parent, which is the page wrapper. The + * wrapper is never out of the viewport, so the animation that shows the button + * never starts. + */ + public function test_amp_scroll_to_top_observer_watches_an_element_that_scrolls_away() { + $GLOBALS['neve_tests_is_amp'] = true; + $scroll_to_top = new \Neve\Views\Scroll_To_Top(); + $scroll_to_top->init(); + + set_theme_mod( 'neve_disable_header', true ); + $markup = $this->render_header(); + + $this->assertStringContainsString( 'id="nv-scroll-to-top-anchor"', $markup ); + $this->assertStringContainsString( 'target="nv-scroll-to-top-anchor"', $markup ); + } + + /** + * The AMP infinite scroll steps aside when the footer is off. + * + * It wraps the footer and replaces the pagination, so a page without the footer + * keeps its regular pagination instead of losing both. + */ + public function test_amp_infinite_scroll_steps_aside_without_the_footer() { + update_option( 'posts_per_page', 1 ); + $post_ids = self::factory()->post->create_many( 3 ); + set_theme_mod( 'neve_pagination_type', 'infinite' ); + set_theme_mod( 'neve_default_sidebar_layout', 'full-width' ); + + $this->go_to( home_url( '/' ) ); + $GLOBALS['post'] = get_post( $post_ids[0] ); + $amp = new \Neve\Compatibility\Amp(); + + $this->assertTrue( $amp->should_display_infinite_scroll() ); + + set_theme_mod( 'neve_disable_footer', true ); + + $this->assertFalse( $amp->should_display_infinite_scroll() ); + } +}