diff --git a/bin/ss-pages.test.stub b/bin/ss-pages.test.stub index c761c2303a..16477c07c3 100755 --- a/bin/ss-pages.test.stub +++ b/bin/ss-pages.test.stub @@ -4,7 +4,7 @@ describe( 'Starter Sites VR - {{site}}', () => { let frontpage = "{{site}}" cy.visit(frontpage ); cy.captureDocument(); - cy.get( '#nv-primary-navigation-main' ).then( $headerMenu => { + cy.get( '[id^="nv-primary-navigation"]' ).then( $headerMenu => { [ ...$headerMenu.find( '.menu-item a' ) ].forEach( $url => { let url = $url.href; if(url.includes("#")){ @@ -13,8 +13,11 @@ describe( 'Starter Sites VR - {{site}}', () => { if(frontpage.replace(/\/*$/, "") === url.replace(/\/*$/, "")){ return; } + if(pages.includes(url)){ + return; + } - pages.push( $url.href ); + pages.push( url ); } ); } ); } ); diff --git a/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json b/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json new file mode 100644 index 0000000000..7845b249f7 --- /dev/null +++ b/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json @@ -0,0 +1,4 @@ +{ + "hfg_footer_layout_v2": "{\"desktop\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[{\"id\":\"footer-menu\"}],\"c-right\":[],\"right\":[]}},\"mobile\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[{\"id\":\"footer-menu\"}],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]}}}", + "nav_menu_locations[footer]": 177 +} diff --git a/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json b/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json new file mode 100644 index 0000000000..2ea0e0f511 --- /dev/null +++ b/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json @@ -0,0 +1,4 @@ +{ + "hfg_header_layout_v2": "{\"desktop\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"primary-menu\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]}},\"mobile\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"primary-menu\"},{\"id\":\"nav-icon\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"sidebar\":[{\"id\":\"primary-menu\"}]}}", + "nav_menu_locations[primary]": 177 +} diff --git a/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts index 28464f3feb..6aedc601fe 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts @@ -1,6 +1,21 @@ -import { test, expect, Page, Locator } from '@playwright/test'; -import { setCustomizeSettings } from '../../../utils'; +import { test, expect, Page, Locator, Frame } from '@playwright/test'; +import { loginWithRequest, setCustomizeSettings } from '../../../utils'; import data from '../../../fixtures/customizer/hfg/footer-menu-setup.json'; +import menuData from '../../../fixtures/customizer/hfg/footer-menu-both-devices-setup.json'; + +const PREVIEW_FRAME = 'iframe[name="customize-preview-0"]'; + +/** + * Collect the ids of every footer menu list in a document. + * + * @param {Frame} frame Frame to read from. + */ +const footerMenuIds = (frame: Frame): Promise => + frame.evaluate(() => + Array.from(document.querySelectorAll('.hfg_footer ul.footer-menu')).map( + (el) => el.id + ) + ); test.describe('Footer Menu component', function () { let page: Page; @@ -35,3 +50,137 @@ test.describe('Footer Menu component', function () { } }); }); + +test.describe( + 'Footer Menu component in both desktop and mobile layouts', + function () { + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('hfgFooterMenuBothDevices', menuData, { + request, + baseURL, + }); + }); + + test('Both layout variants are rendered', async ({ page }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect( + page.locator( + '.hfg_footer .footer--row[data-show-on="desktop"] .nav-menu-footer' + ) + ).toHaveCount(1); + + await expect( + page.locator( + '.hfg_footer .footer--row[data-show-on="mobile"] .nav-menu-footer' + ) + ).toHaveCount(1); + }); + + test('Menu IDs are unique across the whole document', async ({ + page, + }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect(page.locator('ul#footer-menu')).toHaveCount(0); + await expect( + page.locator('ul#footer-menu-desktop-bottom') + ).toHaveCount(1); + await expect(page.locator('ul#footer-menu-mobile-top')).toHaveCount( + 1 + ); + + const duplicated = await page.evaluate(() => { + const ids = Array.from( + document.querySelectorAll('.hfg_footer [id]') + ).map((el) => el.id); + + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + + expect(duplicated).toEqual([]); + }); + + test('The footer-menu class is kept for styling', async ({ page }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect( + page.locator('.hfg_footer ul.footer-menu') + ).toHaveCount(2); + }); + + test('Menu IDs survive a selective refresh of the component', async ({ + page, + }) => { + const previewUrl = '/?test_name=hfgFooterMenuBothDevices'; + await loginWithRequest( + '/wp-admin/customize.php?url=' + encodeURIComponent(previewUrl), + page + ); + + await page.waitForSelector('.wp-full-overlay-sidebar', { + state: 'visible', + }); + + const preview = page.frameLocator(PREVIEW_FRAME); + await preview + .locator('.hfg_footer ul.footer-menu') + .first() + .waitFor(); + + const frame = page.frame({ name: 'customize-preview-0' }); + if (!frame) { + throw new Error('Customizer preview frame not found.'); + } + + // Every placement has to advertise where it sits, otherwise selective refresh + // renders the partial once and copies that markup into all of them. + const contexts = await frame.evaluate(() => + Array.from( + document.querySelectorAll('.builder-item--footer-menu') + ).map((el) => + el.getAttribute('data-customize-partial-placement-context') + ) + ); + expect(contexts).toEqual([ + '{"device":"desktop","row":"bottom"}', + '{"device":"mobile","row":"top"}', + ]); + + const before = await footerMenuIds(frame); + expect(before).toEqual([ + 'footer-menu-desktop-bottom', + 'footer-menu-mobile-top', + ]); + + // Changing a footer-menu setting refreshes the component partial. + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).wp + .customize('footer-menu_style') + .set('style-border-bottom'); + }); + + // The style class only lands through a re-render, so this also proves the + // refresh actually ran before the ids are re-checked. + await expect( + preview + .locator( + '.footer--row[data-show-on="desktop"] .nav-menu-footer' + ) + .first() + ).toHaveClass(/style-border-bottom/); + await expect( + preview + .locator( + '.footer--row[data-show-on="mobile"] .nav-menu-footer' + ) + .first() + ).toHaveClass(/style-border-bottom/); + + const after = await footerMenuIds(frame); + expect(after).toEqual(before); + await expect(preview.locator('ul#footer-menu')).toHaveCount(0); + }); + } +); diff --git a/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts new file mode 100644 index 0000000000..afdeb1c5a4 --- /dev/null +++ b/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts @@ -0,0 +1,127 @@ +import { test, expect, Frame } from '@playwright/test'; +import { loginWithRequest, setCustomizeSettings } from '../../../utils'; +import data from '../../../fixtures/customizer/hfg/primary-menu-both-devices-setup.json'; + +const PREVIEW_FRAME = 'iframe[name="customize-preview-0"]'; + +/** + * Collect the ids of every primary menu list in a document. + * + * @param {Frame} frame Frame to read from. + */ +const primaryMenuIds = (frame: Frame): Promise => + frame.evaluate(() => + Array.from( + document.querySelectorAll('.hfg_header ul.primary-menu-ul') + ).map((el) => el.id) + ); + +test.describe( + 'Primary Menu component in both desktop and mobile layouts', + function () { + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('hfgPrimaryMenuBothDevices', data, { + request, + baseURL, + }); + }); + + test('Menu IDs are unique across the whole document', async ({ + page, + }) => { + await page.goto('/?test_name=hfgPrimaryMenuBothDevices'); + + // The row alone is not enough to tell the placements apart: both layouts + // use the same row names. + await expect( + page.locator('ul#nv-primary-navigation-main') + ).toHaveCount(0); + + await expect( + page.locator('ul#nv-primary-navigation-desktop-main') + ).toHaveCount(1); + await expect( + page.locator('ul#nv-primary-navigation-mobile-main') + ).toHaveCount(1); + await expect( + page.locator('ul#nv-primary-navigation-mobile-sidebar') + ).toHaveCount(1); + + const duplicated = await page.evaluate(() => { + const ids = Array.from( + document.querySelectorAll('.hfg_header [id]') + ).map((el) => el.id); + + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + + expect(duplicated).toEqual([]); + }); + + test('Menu IDs survive a selective refresh of the component', async ({ + page, + }) => { + const previewUrl = '/?test_name=hfgPrimaryMenuBothDevices'; + await loginWithRequest( + '/wp-admin/customize.php?url=' + encodeURIComponent(previewUrl), + page + ); + + await page.waitForSelector('.wp-full-overlay-sidebar', { + state: 'visible', + }); + + const preview = page.frameLocator(PREVIEW_FRAME); + await preview + .locator('.hfg_header ul.primary-menu-ul') + .first() + .waitFor(); + + const frame = page.frame({ name: 'customize-preview-0' }); + if (!frame) { + throw new Error('Customizer preview frame not found.'); + } + + const before = await primaryMenuIds(frame); + expect(before).toEqual([ + 'nv-primary-navigation-desktop-main', + 'nv-primary-navigation-mobile-main', + 'nv-primary-navigation-mobile-sidebar', + ]); + + // Every placement has to advertise where it sits, otherwise selective refresh + // renders the partial once and copies that markup into all of them. + const contexts = await frame.evaluate(() => + Array.from( + document.querySelectorAll('.builder-item--primary-menu') + ).map((el) => + el.getAttribute('data-customize-partial-placement-context') + ) + ); + expect(contexts).toEqual([ + '{"device":"desktop","row":"main"}', + '{"device":"mobile","row":"main"}', + '{"device":"mobile","row":"sidebar"}', + ]); + + // Refresh the partial and check that all placements were refreshed and that the menu ids are still unique. + const refreshed = await frame.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sr = (window as any).wp.customize.selectiveRefresh; + const partial = sr.partial('primary-menu_partial'); + if (!partial) { + throw new Error('primary-menu_partial is not registered.'); + } + const placements = await partial.refresh(); + + return placements.length; + }); + expect(refreshed).toBe(3); + + expect(await primaryMenuIds(frame)).toEqual(before); + await expect( + preview.locator('ul#nv-primary-navigation-main') + ).toHaveCount(0); + }); + } +); diff --git a/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts index 995431cb90..0cc59c7b23 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts @@ -17,21 +17,23 @@ test.describe('Menu item alignment', function () { await page.locator('.mobile-left .navbar-toggle').click(); await expect( page.locator( - '#nv-primary-navigation-sidebar .menu-item-title-wrap:has-text("About The Tests")' + '#nv-primary-navigation-mobile-sidebar .menu-item-title-wrap:has-text("About The Tests")' ) ).toHaveCSS('text-align', 'left'); // Located by label: menu item IDs are DB auto-increments and differ // between a fresh CI import and a long-lived local database. await page - .locator('#nv-primary-navigation-sidebar > li.menu-item-has-children') + .locator( + '#nv-primary-navigation-mobile-sidebar > li.menu-item-has-children' + ) .filter({ hasText: 'Level 1' }) .locator('.caret-wrap') .first() .click(); await expect( page.locator( - '#nv-primary-navigation-sidebar .menu-item-title-wrap:has-text("Level 2")' + '#nv-primary-navigation-mobile-sidebar .menu-item-title-wrap:has-text("Level 2")' ) ).toHaveCSS('text-align', 'left'); }); diff --git a/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts index 7132e6a303..bc614638b9 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts @@ -11,7 +11,7 @@ test.describe('Menu item alignment', function () { .click(); const firstLevelItem = page - .locator('#nv-primary-navigation-sidebar') + .locator('#nv-primary-navigation-mobile-sidebar') .getByRole('link', { name: 'Page Markup And Formatting', }); @@ -23,7 +23,7 @@ test.describe('Menu item alignment', function () { await page.getByRole('button', { name: 'Toggle Level 2' }).click(); const secondLevelItem = page - .locator('#nv-primary-navigation-sidebar') + .locator('#nv-primary-navigation-mobile-sidebar') .getByRole('link', { name: 'Level 3b', }); diff --git a/header-footer-grid/Core/Components/Abstract_Component.php b/header-footer-grid/Core/Components/Abstract_Component.php index a4287b0819..3e88c8d310 100644 --- a/header-footer-grid/Core/Components/Abstract_Component.php +++ b/header-footer-grid/Core/Components/Abstract_Component.php @@ -22,6 +22,7 @@ use Neve\Core\Styles\Dynamic_Selector; use Neve\Views\Font_Manager; use WP_Customize_Manager; +use WP_Customize_Partial; /** * Class Abstract_Component @@ -617,16 +618,37 @@ public function customize_register( WP_Customize_Manager $wp_customize ) { /** * Render component markup. * - * @param string $device Current device. + * @param string|WP_Customize_Partial $device Current device. + * @param array $container_context Placement context, when rendering a partial. */ - public function render( $device = '' ) { + public function render( $device = '', $container_context = array() ) { + $is_partial_render = $device instanceof WP_Customize_Partial; + $previous_device = Abstract_Builder::$current_device; + $previous_row = Abstract_Builder::$current_row; + + if ( $is_partial_render ) { + $device = isset( $container_context['device'] ) ? (string) $container_context['device'] : ''; + + if ( $device !== '' ) { + Abstract_Builder::$current_device = $device; + } + if ( isset( $container_context['row'] ) && $container_context['row'] !== '' ) { + Abstract_Builder::$current_row = (string) $container_context['row']; + } + } + $args = []; - if ( ! empty( $device ) && in_array( $device, [ 'desktop', 'tablet', 'mobile' ] ) ) { + if ( ! empty( $device ) && in_array( $device, [ 'desktop', 'tablet', 'mobile' ], true ) ) { $args['device'] = $device; } self::$current_component = $this->get_id(); Abstract_Builder::$current_builder = $this->get_builder_id(); Main::get_instance()->load( 'component-wrapper', '', $args ); + + if ( $is_partial_render ) { + Abstract_Builder::$current_device = $previous_device; + Abstract_Builder::$current_row = $previous_row; + } } /** diff --git a/header-footer-grid/Core/Components/Nav.php b/header-footer-grid/Core/Components/Nav.php index 9e1a1e4958..924a1fbf24 100644 --- a/header-footer-grid/Core/Components/Nav.php +++ b/header-footer-grid/Core/Components/Nav.php @@ -11,6 +11,7 @@ namespace HFG\Core\Components; +use HFG\Core\Builder\Header; use HFG\Core\Settings\Manager as SettingsManager; use HFG\Main; use Neve\Core\Settings\Mods; @@ -35,6 +36,27 @@ class Nav extends Abstract_Component { const EXPAND_DROPDOWNS = 'expand_dropdowns'; const DROPDOWNS_EXPANDED_CLASS = 'dropdowns-expanded'; + /** + * Build the menu id for the placement currently being rendered. + * + * @return string + */ + public static function get_menu_id() { + $menu_id = self::NAV_MENU_ID; + + $parts = [ + \HFG\current_device( Header::BUILDER_NAME ), + \HFG\current_row( Header::BUILDER_NAME ), + ]; + foreach ( $parts as $part ) { + if ( ! empty( $part ) ) { + $menu_id .= '-' . $part; + } + } + + return $menu_id; + } + /** * Nav constructor. * diff --git a/header-footer-grid/Core/Components/NavFooter.php b/header-footer-grid/Core/Components/NavFooter.php index 927136b7c6..839eea3488 100644 --- a/header-footer-grid/Core/Components/NavFooter.php +++ b/header-footer-grid/Core/Components/NavFooter.php @@ -124,7 +124,7 @@ public function add_settings() { 'fallback' => 'inherit', ], [ - 'selector' => '.builder-item--' . $this->get_id() . ' .nav-menu-footer:not(.style-full-height) #footer-menu li:hover > a', + 'selector' => '.builder-item--' . $this->get_id() . ' .nav-menu-footer:not(.style-full-height) .footer-menu li:hover > a', 'prop' => 'color', 'fallback' => 'inherit', ], diff --git a/header-footer-grid/templates/component-wrapper.php b/header-footer-grid/templates/component-wrapper.php index dba1171754..7ca9034eea 100644 --- a/header-footer-grid/templates/component-wrapper.php +++ b/header-footer-grid/templates/component-wrapper.php @@ -28,10 +28,24 @@ $item_classes = join( ' ', $item_classes ); +// The placement context is used to restore the device and row +// when rendering a component in a selective refresh partial. +$placement_context = array(); +if ( is_customize_preview() ) { + $placement_device = current_device(); + $placement_row = current_row(); + if ( ! empty( $placement_device ) ) { + $placement_context['device'] = $placement_device; + } + if ( ! empty( $placement_row ) ) { + $placement_context['row'] = $placement_row; + } +} + ?>
+ data-item-id="get_id() ); ?>"> render_css(); current_component()->render_component(); diff --git a/header-footer-grid/templates/components/component-nav-footer.php b/header-footer-grid/templates/components/component-nav-footer.php index 8e05102679..f9c40ae9a5 100644 --- a/header-footer-grid/templates/components/component-nav-footer.php +++ b/header-footer-grid/templates/components/component-nav-footer.php @@ -19,6 +19,13 @@ $container_classes[] = 'm-style'; } +$menu_id = NavFooter::COMPONENT_ID; +$device = current_device(); +$row = current_row(); +if ( ! empty( $device ) && ! empty( $row ) ) { + $menu_id .= '-' . $device . '-' . $row; +} + ?>