Skip to content

Commit

Permalink
fix(sbb-header): shows the header if it has visible focus within (#2237)
Browse files Browse the repository at this point in the history
  • Loading branch information
dauriamarco authored Jan 11, 2024
1 parent ca643e7 commit 37061ad
Show file tree
Hide file tree
Showing 11 changed files with 154 additions and 35 deletions.
34 changes: 34 additions & 0 deletions src/components/core/a11y/focus-visible-within-controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
25 changes: 17 additions & 8 deletions src/components/core/a11y/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>();

Expand All @@ -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;
}

Expand All @@ -51,7 +57,7 @@ export function getFocusableElements(
}
}
}
getFocusables(elements, filterFunc);
getFocusables(elements, properties.filterFunc);

return [...focusableEls];
}
Expand All @@ -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 {
Expand All @@ -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;

Expand Down
1 change: 1 addition & 0 deletions src/components/core/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -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';
10 changes: 5 additions & 5 deletions src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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') {
Expand All @@ -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
Expand Down
61 changes: 60 additions & 1 deletion src/components/header/header/header.e2e.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`
<sbb-header hide-on-scroll>
<sbb-header-action id="action-1">Action 1</sbb-header-action>
<sbb-header-action id="action-2">Action 2</sbb-header-action>
</sbb-header>
<div style="height: 2000px;"></div>
`);

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`
<sbb-header hide-on-scroll>
Expand Down
7 changes: 6 additions & 1 deletion src/components/header/header/header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
14 changes: 13 additions & 1 deletion src/components/header/header/header.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -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<HTMLElement>,
);
Expand All @@ -170,6 +178,10 @@ export class SbbHeaderElement extends LitElement {
}
}

protected override checkChildren(): void {
this._focusHandler.disconnect();
}

protected override render(): TemplateResult {
return html`
<header class="sbb-header">
Expand Down
10 changes: 5 additions & 5 deletions src/components/menu/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ref } from 'lit/directives/ref.js';

import {
assignId,
FocusTrap,
FocusHandler,
getNextElementIndex,
interactivityChecker,
IS_FOCUSABLE_QUERY,
Expand Down Expand Up @@ -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}`;

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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';
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 37061ad

Please sign in to comment.