From 3e4b8e9a2610e2b95904612c0b1c7ce08474b812 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 12 Aug 2026 11:08:43 +1000 Subject: [PATCH 1/2] [#308] Pinned the sticky header to the top of the window when alerts are shown. --- tests/behat/bootstrap/FeatureContext.php | 22 ++++++++++++++++ tests/behat/features/admin_navigation.feature | 4 +-- tests/behat/features/sticky_header.feature | 22 ++++++++++++++++ .../03-organisms/header/header.scss | 15 ----------- .../components/04-templates/page/page.scss | 26 +++++++++++++++++++ .../components/04-templates/page/page.twig | 25 ++++++++++-------- 6 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 tests/behat/features/sticky_header.feature diff --git a/tests/behat/bootstrap/FeatureContext.php b/tests/behat/bootstrap/FeatureContext.php index 1d8ea01e..bba752f2 100644 --- a/tests/behat/bootstrap/FeatureContext.php +++ b/tests/behat/bootstrap/FeatureContext.php @@ -177,6 +177,28 @@ public function elementAssertStacksAbove(string $selector, string $other_selecto } } + /** + * Assert that an element sits flush against the top of the viewport. + * + * The behat-steps "at the top of the viewport" step accepts any offset + * between zero and the viewport height, so it passes for an element that + * merely happens to be on screen. A sticky element has to be flush. + * + * @code + * Then the element ".ct-header" should be pinned to the top of the viewport + * @endcode + * + * @javascript + */ + #[Then('the element :selector should be pinned to the top of the viewport')] + public function elementAssertPinnedToViewportTop(string $selector): void { + $offset = (int) round((float) $this->elementExecuteJs($selector, 'return {{ELEMENT}}.getBoundingClientRect().top;')); + + if ($offset !== 0) { + throw new \RuntimeException(sprintf('Expected element "%s" to be pinned to the top of the viewport, but it is %dpx from it.', $selector, $offset)); + } + } + /** * Assert that an element resolves to a computed style value. * diff --git a/tests/behat/features/admin_navigation.feature b/tests/behat/features/admin_navigation.feature index 89e77c50..fdd689ca 100644 --- a/tests/behat/features/admin_navigation.feature +++ b/tests/behat/features/admin_navigation.feature @@ -15,7 +15,7 @@ Feature: Administration navigation for site administrator Scenario: Site administrator sees the administration top bar above the sticky site header Given I am logged in as a user with the "civictheme_site_administrator" role When I visit "/" - Then the element ".top-bar" should stack above the element ".ct-header--sticky" + Then the element ".top-bar" should stack above the element ".ct-page__header--sticky" @api @javascript Scenario: Site administrator sees the open mobile navigation above the administration top bar @@ -24,4 +24,4 @@ Feature: Administration navigation for site administrator And I set the viewport to 390 by 844 And I trigger the JS event "click" on the element ".ct-mobile-navigation-trigger" And I wait for 1 second - Then the element ".ct-header--sticky" should stack above the element ".top-bar" + Then the element ".ct-page__header--sticky" should stack above the element ".top-bar" diff --git a/tests/behat/features/sticky_header.feature b/tests/behat/features/sticky_header.feature new file mode 100644 index 00000000..0c923122 --- /dev/null +++ b/tests/behat/features/sticky_header.feature @@ -0,0 +1,22 @@ +@header @p1 @drevops +Feature: Sticky site header + + As a site visitor + I want the site header to stay at the top of the window while I scroll + So that I can navigate away from anywhere on a long page + + @api @javascript + Scenario: Site visitor sees the header pinned to the top of the window while an alert is shown above it + Given the following "civictheme_alert" content: + | title | moderation_state | field_c_n_alert_type | field_c_n_body:value | :format | field_c_n_date_range:value | :end_value | + | [TEST] Site Alert | published | information | [TEST] Alert body copy. | civictheme_rich_text | 2020-01-01T00:00:00 | 2099-01-01T00:00:00 | + + And I am an anonymous user + + When I visit "/" + And I wait for 2 seconds + Then I should see the text "[TEST] Site Alert" + And the element ".ct-header" should appear after the element ".ct-alert" + + When I scroll to the element ".ct-footer" + Then the element ".ct-header" should be pinned to the top of the viewport diff --git a/web/themes/custom/drevops/components/03-organisms/header/header.scss b/web/themes/custom/drevops/components/03-organisms/header/header.scss index 2f5f3289..24021b69 100644 --- a/web/themes/custom/drevops/components/03-organisms/header/header.scss +++ b/web/themes/custom/drevops/components/03-organisms/header/header.scss @@ -7,21 +7,6 @@ border-bottom: solid ct-particle(0.125); - // Custom: Add sticky header support with fixed positioning. - &--sticky { - position: fixed; - width: 100%; - z-index: $ct-header-sticky-zindex; - - // The mobile navigation flyout sits inside the header, so the header's - // stacking context caps how high the flyout panel can rise. Lift the - // header to the flyout tier while the flyout is open, so the panel keeps - // covering everything else the way a full-screen overlay should. - &:has([data-flyout-expanded]) { - z-index: $ct-flyout-zindex; - } - } - &__top { padding-top: ct-spacing(); padding-bottom: ct-spacing(); diff --git a/web/themes/custom/drevops/components/04-templates/page/page.scss b/web/themes/custom/drevops/components/04-templates/page/page.scss index d5aedf68..6d81ff55 100644 --- a/web/themes/custom/drevops/components/04-templates/page/page.scss +++ b/web/themes/custom/drevops/components/04-templates/page/page.scss @@ -5,6 +5,32 @@ .ct-page { $root: &; + // Custom: Carry the sticky positioning on a rail of zero height rather than + // on the header itself. The rail reserves no space, so page content still + // starts at the top of the page and scrolls under the header, and anything + // rendered ahead of the header - alerts are injected there once they load - + // scrolls away before the header pins. A fixed header would instead park at + // its static position, which those alerts push down the page. + #{$root}__header { + &--sticky { + position: sticky; + // Administration chrome that reserves space at the top of the viewport + // publishes it through this variable, so the header pins below the + // administration top bar instead of underneath it. + top: var(--drupal-displace-offset-top, 0px); + height: 0; + z-index: $ct-header-sticky-zindex; + + // The mobile navigation flyout sits inside the header, so the rail's + // stacking context caps how high the flyout panel can rise. Lift the + // rail to the flyout tier while the flyout is open, so the panel keeps + // covering everything else the way a full-screen overlay should. + &:has([data-flyout-expanded]) { + z-index: $ct-flyout-zindex; + } + } + } + @include ct-component-theme($root) using($root, $theme) { @include ct-component-property($root, $theme, background-color); } diff --git a/web/themes/custom/drevops/components/04-templates/page/page.twig b/web/themes/custom/drevops/components/04-templates/page/page.twig index 10fd1073..1a3a6e6c 100644 --- a/web/themes/custom/drevops/components/04-templates/page/page.twig +++ b/web/themes/custom/drevops/components/04-templates/page/page.twig @@ -66,17 +66,20 @@ {% block header_block %} {# Custom: Use include() instead of {% include only %} and add is_sticky support. #} - {{ include('civictheme:header', { - theme: header_theme, - is_sticky: header_is_sticky, - content_top1: header_top_1, - content_top2: header_top_2, - content_top3: header_top_3, - content_middle1: header_middle_1, - content_middle2: header_middle_2, - content_middle3: header_middle_3, - content_bottom1: header_bottom_1, - }, with_context: false) }} + {% set header_sticky_class = header_is_sticky ? 'ct-page__header--sticky' : '' %} +
+ {{ include('civictheme:header', { + theme: header_theme, + is_sticky: header_is_sticky, + content_top1: header_top_1, + content_top2: header_top_2, + content_top3: header_top_3, + content_middle1: header_middle_1, + content_middle2: header_middle_2, + content_middle3: header_middle_3, + content_bottom1: header_bottom_1, + }, with_context: false) }} +
{% endblock %} {% block banner_block %} From e601d822c2fb45da56a6dd6124a61fedbdf0ff77 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 12 Aug 2026 11:33:42 +1000 Subject: [PATCH 2/2] [#308] Satisfied stylelint on the sticky rail declaration. --- .../custom/drevops/components/04-templates/page/page.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/themes/custom/drevops/components/04-templates/page/page.scss b/web/themes/custom/drevops/components/04-templates/page/page.scss index 6d81ff55..81411b0b 100644 --- a/web/themes/custom/drevops/components/04-templates/page/page.scss +++ b/web/themes/custom/drevops/components/04-templates/page/page.scss @@ -14,10 +14,11 @@ #{$root}__header { &--sticky { position: sticky; + // Administration chrome that reserves space at the top of the viewport // publishes it through this variable, so the header pins below the // administration top bar instead of underneath it. - top: var(--drupal-displace-offset-top, 0px); + top: var(--drupal-displace-offset-top, 0); height: 0; z-index: $ct-header-sticky-zindex;