From 37061ad1703c4d5661c3ab5270124a652012cd58 Mon Sep 17 00:00:00 2001 From: Marco D'Auria <101181211+dauriamarco@users.noreply.github.com> Date: Thu, 11 Jan 2024 11:07:34 +0100 Subject: [PATCH] fix(sbb-header): shows the header if it has visible focus within (#2237) --- .../a11y/focus-visible-within-controller.ts | 34 +++++++++++ src/components/core/a11y/focus.ts | 25 +++++--- src/components/core/a11y/index.ts | 1 + src/components/dialog/dialog.ts | 10 +-- src/components/header/header/header.e2e.ts | 61 ++++++++++++++++++- src/components/header/header/header.scss | 7 ++- src/components/header/header/header.ts | 14 ++++- src/components/menu/menu/menu.ts | 10 +-- .../navigation-section/navigation-section.ts | 7 +-- .../navigation/navigation/navigation.ts | 10 +-- src/components/tooltip/tooltip/tooltip.ts | 10 +-- 11 files changed, 154 insertions(+), 35 deletions(-) create mode 100644 src/components/core/a11y/focus-visible-within-controller.ts diff --git a/src/components/core/a11y/focus-visible-within-controller.ts b/src/components/core/a11y/focus-visible-within-controller.ts new file mode 100644 index 0000000000..fb389b5e3b --- /dev/null +++ b/src/components/core/a11y/focus-visible-within-controller.ts @@ -0,0 +1,34 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { toggleDatasetEntry } from '../dom'; + +import { sbbInputModalityDetector } from './input-modality-detector'; + +// Determine whether the element has a visible focus within. +export class FocusVisibleWithinController implements ReactiveController { + private _focusinHanlder = (): void => { + toggleDatasetEntry( + this._host, + 'hasVisibleFocusWithin', + sbbInputModalityDetector.mostRecentModality === 'keyboard', + ); + }; + + private _focusoutHanlder = (): void => { + toggleDatasetEntry(this._host, 'hasVisibleFocusWithin', false); + }; + + public constructor(private _host: ReactiveControllerHost & HTMLElement) { + this._host.addController(this); + } + + public hostConnected(): void { + this._host.addEventListener('focusin', this._focusinHanlder); + this._host.addEventListener('focusout', this._focusoutHanlder); + } + + public hostDisconnected(): void { + this._host.removeEventListener('focusin', this._focusinHanlder); + this._host.removeEventListener('focusout', this._focusoutHanlder); + } +} diff --git a/src/components/core/a11y/focus.ts b/src/components/core/a11y/focus.ts index 32b0161e23..b6d233dddf 100644 --- a/src/components/core/a11y/focus.ts +++ b/src/components/core/a11y/focus.ts @@ -16,8 +16,11 @@ export const IS_FOCUSABLE_QUERY = [ // Note: the use of this function for more complex scenarios (with many nested elements) may be expensive. export function getFocusableElements( elements: HTMLElement[], - filterFunc?: (el: HTMLElement) => boolean, - findFirstFocusable?: boolean, + properties?: { + filterFunc?: (el: HTMLElement) => boolean; + findFirstFocusable?: boolean; + includeInvisibleElements?: boolean; + }, ): HTMLElement[] { const focusableEls = new Set(); @@ -35,11 +38,14 @@ export function getFocusableElements( continue; } - if (el.matches(IS_FOCUSABLE_QUERY) && interactivityChecker.isVisible(el)) { + if ( + el.matches(IS_FOCUSABLE_QUERY) && + (properties.includeInvisibleElements ?? interactivityChecker.isVisible(el)) + ) { focusableEls.add(el); } - if (findFirstFocusable && focusableEls.size > 0) { + if (properties.findFirstFocusable && focusableEls.size > 0) { break; } @@ -51,7 +57,7 @@ export function getFocusableElements( } } } - getFocusables(elements, filterFunc); + getFocusables(elements, properties.filterFunc); return [...focusableEls]; } @@ -60,11 +66,14 @@ export function getFirstFocusableElement( elements: HTMLElement[], filterFunc?: (el: HTMLElement) => boolean, ): HTMLElement | null { - const focusableElements = getFocusableElements(elements, filterFunc, true); + const focusableElements = getFocusableElements(elements, { + filterFunc: filterFunc, + findFirstFocusable: true, + }); return focusableElements.length ? focusableElements[0] : null; } -export class FocusTrap { +export class FocusHandler { private _controller = new AbortController(); public trap(element: HTMLElement, filterFunc?: (el: HTMLElement) => boolean): void { @@ -79,7 +88,7 @@ export class FocusTrap { const elementChildren: HTMLElement[] = Array.from( element.shadowRoot.children, ) as HTMLElement[]; - const focusableElements = getFocusableElements(elementChildren, filterFunc); + const focusableElements = getFocusableElements(elementChildren, { filterFunc }); const firstFocusable = focusableElements[0] as HTMLElement; const lastFocusable = focusableElements[focusableElements.length - 1] as HTMLElement; diff --git a/src/components/core/a11y/index.ts b/src/components/core/a11y/index.ts index 27a08661c8..802bbd10c8 100644 --- a/src/components/core/a11y/index.ts +++ b/src/components/core/a11y/index.ts @@ -1,6 +1,7 @@ export * from './arrow-navigation'; export * from './assign-id'; export * from './fake-event-detection'; +export * from './focus-visible-within-controller'; export * from './focus'; export * from './interactivity-checker'; export * from './input-modality-detector'; diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index a27e551dcb..11ecbdf036 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, nothing, TemplateResult } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; -import { FocusTrap, IS_FOCUSABLE_QUERY, setModalityOnNextFocus } from '../core/a11y'; +import { FocusHandler, IS_FOCUSABLE_QUERY, setModalityOnNextFocus } from '../core/a11y'; import { LanguageController } from '../core/common-behaviors'; import { ScrollHandler, @@ -149,7 +149,7 @@ export class SbbDialogElement extends LitElement { private _dialogCloseElement: HTMLElement; private _dialogController: AbortController; private _windowEventsController: AbortController; - private _focusTrap = new FocusTrap(); + private _focusHandler = new FocusHandler(); private _scrollHandler = new ScrollHandler(); private _returnValue: any; private _isPointerDownEventOnDialog: boolean; @@ -257,7 +257,7 @@ export class SbbDialogElement extends LitElement { this._handlerRepository.disconnect(); this._dialogController?.abort(); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); this._dialogContentResizeObserver.disconnect(); this._removeInstanceFromGlobalCollection(); removeInertMechanism(); @@ -344,7 +344,7 @@ export class SbbDialogElement extends LitElement { this._setDialogFocus(); // Use timeout to read label after focused element setTimeout(() => this._setAriaLiveRefContent()); - this._focusTrap.trap(this); + this._focusHandler.trap(this); this._dialogContentResizeObserver.observe(this._dialogContentElement); this._attachWindowEvents(); } else if (event.animationName === 'close' && this._state === 'closing') { @@ -359,7 +359,7 @@ export class SbbDialogElement extends LitElement { closeTarget: this._dialogCloseElement, }); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); this._dialogContentResizeObserver.disconnect(); this._removeInstanceFromGlobalCollection(); // Enable scrolling for content below the dialog if no dialog is open diff --git a/src/components/header/header/header.e2e.ts b/src/components/header/header/header.e2e.ts index a66c4499f7..52634e3a8a 100644 --- a/src/components/header/header/header.e2e.ts +++ b/src/components/header/header/header.e2e.ts @@ -1,5 +1,5 @@ import { assert, expect, fixture } from '@open-wc/testing'; -import { setViewport } from '@web/test-runner-commands'; +import { sendKeys, setViewport } from '@web/test-runner-commands'; import { html } from 'lit/static-html.js'; import { EventSpy, waitForLitRender, mockScrollTo, waitForCondition } from '../../core/testing'; @@ -68,6 +68,65 @@ describe('sbb-header', () => { expect(element).not.to.have.attribute('data-visible'); }); + it('should hide/show on scroll', async () => { + await fixture(html` + + Action 1 + Action 2 + +
+ `); + + element = document.querySelector('sbb-header'); + expect(element.scrollOrigin).not.to.be.undefined; + expect(element.offsetHeight).to.be.equal(96); + expect(document.documentElement.offsetHeight).to.be.equal(2096); + + // Scroll bottom (0px to 400px): header fixed. + mockScrollTo({ top: 400 }); + await waitForLitRender(element); + + // Scroll top (400px to 200px): header fixed and visible, with shadow and animated. + mockScrollTo({ top: 200 }); + + await waitForLitRender(element); + + expect(element).to.have.attribute('data-shadow'); + expect(element).to.have.attribute('data-animated'); + expect(element).to.have.attribute('data-fixed'); + expect(element).to.have.attribute('data-visible'); + + // Scroll bottom (0px to 400px): header fixed. + mockScrollTo({ top: 400 }); + await waitForLitRender(element); + + expect(element).to.have.attribute('data-animated'); + expect(element).to.have.attribute('data-fixed'); + expect(element).not.to.have.attribute('data-shadow'); + expect(element).not.to.have.attribute('data-visible'); + expect(element).not.to.have.attribute('data-has-visible-focus-within'); + expect(window.getComputedStyle(element).getPropertyValue('--sbb-header-transform')).to.equal( + 'translate3d(0, -100%, 0)', + ); + + // Focus an element inside the header + await sendKeys({ press: 'Tab' }); + expect(element).to.have.attribute('data-has-visible-focus-within'); + expect(window.getComputedStyle(element).getPropertyValue('--sbb-header-transform')).to.equal( + 'translate3d(0, 0, 0)', + ); + + // Scroll top (100 to 0px): initial situation. + mockScrollTo({ top: 0 }); + + await waitForLitRender(element); + + expect(element).not.to.have.attribute('data-shadow'); + expect(element).not.to.have.attribute('data-animated'); + expect(element).not.to.have.attribute('data-fixed'); + expect(element).not.to.have.attribute('data-visible'); + }); + it('should close menu on scroll', async () => { await fixture(html` diff --git a/src/components/header/header/header.scss b/src/components/header/header/header.scss index f140807f23..0ce1fc6641 100644 --- a/src/components/header/header/header.scss +++ b/src/components/header/header/header.scss @@ -43,6 +43,11 @@ --sbb-header-transition-timing: cubic-bezier(0, 0, 0.2, 1); } +:host([hide-on-scroll][data-fixed][data-has-visible-focus-within]) { + --sbb-header-transition-duration: 0; + --sbb-header-transform: translate3d(0, 0, 0); +} + .sbb-header { position: var(--sbb-header-position); inset: 0 var(--sbb-header-inset-inline-end) auto 0; @@ -60,7 +65,7 @@ timing-function: var(--sbb-header-transition-timing); } - :host([data-shadow]) & { + :host(:is([data-shadow], [data-has-visible-focus-within])) & { @include sbb.shadow-level-9-soft; } diff --git a/src/components/header/header/header.ts b/src/components/header/header/header.ts index 5aee425da9..02584d77db 100644 --- a/src/components/header/header/header.ts +++ b/src/components/header/header/header.ts @@ -1,6 +1,8 @@ import { CSSResultGroup, html, LitElement, TemplateResult } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { FocusHandler, FocusVisibleWithinController } from '../../core/a11y'; +import { SlotChildObserver } from '../../core/common-behaviors'; import { findReferencedElement, isBrowser, toggleDatasetEntry } from '../../core/dom'; import style from './header.scss?lit&inline'; @@ -15,7 +17,7 @@ const IS_MENU_OPENED_QUERY = "[aria-controls][aria-expanded='true']"; * @slot logo - Slot used to render the logo on the right side (sbb-logo as default). */ @customElement('sbb-header') -export class SbbHeaderElement extends LitElement { +export class SbbHeaderElement extends SlotChildObserver(LitElement) { public static override styles: CSSResultGroup = style; /** @@ -46,6 +48,7 @@ export class SbbHeaderElement extends LitElement { private _scrollEventsController: AbortController; private _scrollFunction: () => void; private _lastScroll = 0; + private _focusHandler = new FocusHandler(); private _updateScrollOrigin( newValue: string | HTMLElement | Document, @@ -63,12 +66,14 @@ export class SbbHeaderElement extends LitElement { public override connectedCallback(): void { super.connectedCallback(); this._setListenerOnScrollElement(this.scrollOrigin); + new FocusVisibleWithinController(this); } /** Removes the scroll listener, if previously attached. */ public override disconnectedCallback(): void { super.disconnectedCallback(); this._scrollEventsController?.abort(); + this._focusHandler.disconnect(); } /** Sets the value of `_scrollElement` and `_scrollFunction` and possibly adds the function on the correct element. */ @@ -158,6 +163,9 @@ export class SbbHeaderElement extends LitElement { } private _closeOpenOverlays(): void { + if (this.hasAttribute('data-has-visible-focus-within')) { + return; + } const overlayTriggers: HTMLElement[] = Array.from( this.querySelectorAll(IS_MENU_OPENED_QUERY) as NodeListOf, ); @@ -170,6 +178,10 @@ export class SbbHeaderElement extends LitElement { } } + protected override checkChildren(): void { + this._focusHandler.disconnect(); + } + protected override render(): TemplateResult { return html`
diff --git a/src/components/menu/menu/menu.ts b/src/components/menu/menu/menu.ts index f13adff01f..2b8c62dd4b 100644 --- a/src/components/menu/menu/menu.ts +++ b/src/components/menu/menu/menu.ts @@ -4,7 +4,7 @@ import { ref } from 'lit/directives/ref.js'; import { assignId, - FocusTrap, + FocusHandler, getNextElementIndex, interactivityChecker, IS_FOCUSABLE_QUERY, @@ -110,7 +110,7 @@ export class SbbMenuElement extends SlotChildObserver(LitElement) { private _menuController: AbortController; private _windowEventsController: AbortController; private _abort = new ConnectedAbortController(this); - private _focusTrap = new FocusTrap(); + private _focusHandler = new FocusHandler(); private _scrollHandler = new ScrollHandler(); private _menuId = `sbb-menu-${++nextId}`; @@ -219,7 +219,7 @@ export class SbbMenuElement extends SlotChildObserver(LitElement) { super.disconnectedCallback(); this._menuController?.abort(); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); removeInertMechanism(); } @@ -303,7 +303,7 @@ export class SbbMenuElement extends SlotChildObserver(LitElement) { this._didOpen.emit(); applyInertMechanism(this); this._setMenuFocus(); - this._focusTrap.trap(this); + this._focusHandler.trap(this); this._attachWindowEvents(); } else if (event.animationName === 'close' && this._state === 'closing') { this._state = 'closed'; @@ -317,7 +317,7 @@ export class SbbMenuElement extends SlotChildObserver(LitElement) { }); this._didClose.emit(); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); // Starting from breakpoint medium, enable scroll this._scrollHandler.enableScroll(); diff --git a/src/components/navigation/navigation-section/navigation-section.ts b/src/components/navigation/navigation-section/navigation-section.ts index 4c48494571..548d56085f 100644 --- a/src/components/navigation/navigation-section/navigation-section.ts +++ b/src/components/navigation/navigation-section/navigation-section.ts @@ -297,10 +297,9 @@ export class SbbNavigationSectionElement extends UpdateScheduler(LitElement) { const navigationChildren: HTMLElement[] = Array.from( this.closest('sbb-navigation').shadowRoot.children, ) as HTMLElement[]; - const navigationFocusableElements = getFocusableElements( - navigationChildren, - (el) => el.nodeName === 'SBB-NAVIGATION-SECTION', - ); + const navigationFocusableElements = getFocusableElements(navigationChildren, { + filterFunc: (el) => el.nodeName === 'SBB-NAVIGATION-SECTION', + }); const sectionChildren: HTMLElement[] = Array.from(this.shadowRoot.children) as HTMLElement[]; const sectionFocusableElements = getFocusableElements(sectionChildren); diff --git a/src/components/navigation/navigation/navigation.ts b/src/components/navigation/navigation/navigation.ts index f45e3ed4e3..3f7099f0c7 100644 --- a/src/components/navigation/navigation/navigation.ts +++ b/src/components/navigation/navigation/navigation.ts @@ -2,7 +2,7 @@ import { LitElement, CSSResultGroup, TemplateResult, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; -import { FocusTrap, assignId, setModalityOnNextFocus } from '../../core/a11y'; +import { FocusHandler, assignId, setModalityOnNextFocus } from '../../core/a11y'; import { LanguageController, UpdateScheduler } from '../../core/common-behaviors'; import { ScrollHandler, @@ -121,7 +121,7 @@ export class SbbNavigationElement extends UpdateScheduler(LitElement) { private _windowEventsController: AbortController; private _abort = new ConnectedAbortController(this); private _language = new LanguageController(this); - private _focusTrap = new FocusTrap(); + private _focusHandler = new FocusHandler(); private _scrollHandler = new ScrollHandler(); private _isPointerDownEventOnNavigation: boolean; private _navigationObserver = new AgnosticMutationObserver((mutationsList: MutationRecord[]) => @@ -214,7 +214,7 @@ export class SbbNavigationElement extends UpdateScheduler(LitElement) { this._state = 'opened'; this._didOpen.emit(); applyInertMechanism(this); - this._focusTrap.trap(this, this._trapFocusFilter); + this._focusHandler.trap(this, this._trapFocusFilter); this._attachWindowEvents(); this._setNavigationFocus(); } else if (event.animationName === 'close' && this._state === 'closing') { @@ -226,7 +226,7 @@ export class SbbNavigationElement extends UpdateScheduler(LitElement) { this._triggerElement?.focus(); this._didClose.emit(); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); // Enable scrolling for content below the navigation this._scrollHandler.enableScroll(); @@ -322,7 +322,7 @@ export class SbbNavigationElement extends UpdateScheduler(LitElement) { super.disconnectedCallback(); this._navigationController?.abort(); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); this._navigationObserver.disconnect(); removeInertMechanism(); } diff --git a/src/components/tooltip/tooltip/tooltip.ts b/src/components/tooltip/tooltip/tooltip.ts index 23eb0efcb2..d9ce9f0368 100644 --- a/src/components/tooltip/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip/tooltip.ts @@ -3,7 +3,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { ref } from 'lit/directives/ref.js'; import { - FocusTrap, + FocusHandler, IS_FOCUSABLE_QUERY, assignId, getFirstFocusableElement, @@ -135,7 +135,7 @@ export class SbbTooltipElement extends LitElement { private _isPointerDownEventOnTooltip: boolean; private _tooltipController: AbortController; private _windowEventsController: AbortController; - private _focusTrap = new FocusTrap(); + private _focusHandler = new FocusHandler(); private _hoverTrigger = false; private _openTimeout: ReturnType; private _closeTimeout: ReturnType; @@ -236,7 +236,7 @@ export class SbbTooltipElement extends LitElement { super.disconnectedCallback(); this._tooltipController?.abort(); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); tooltipsRef.delete(this as SbbTooltipElement); } @@ -389,7 +389,7 @@ export class SbbTooltipElement extends LitElement { this._didOpen.emit(); this.inert = false; this._setTooltipFocus(); - this._focusTrap.trap(this); + this._focusHandler.trap(this); this._attachWindowEvents(); } else if (event.animationName === 'close' && this._state === 'closing') { this._state = 'closed'; @@ -402,7 +402,7 @@ export class SbbTooltipElement extends LitElement { elementToFocus?.focus(); this._didClose.emit({ closeTarget: this._tooltipCloseElement }); this._windowEventsController?.abort(); - this._focusTrap.disconnect(); + this._focusHandler.disconnect(); } }