Skip to content

Commit

Permalink
feat: improve stacking context for overlay components (#3349)
Browse files Browse the repository at this point in the history
By using the Popover API internally, an overlay is always on top,
regardless of the stacking context. Currently, we only use the Popover
API for the stacking context and are not taking advantage of other
possible benefits.

---------

Co-authored-by: Lukas Spirig <[email protected]>
  • Loading branch information
2 people authored and github-actions committed Jan 16, 2025
1 parent dd248e8 commit 7ea0640
Show file tree
Hide file tree
Showing 41 changed files with 210 additions and 45 deletions.
17 changes: 12 additions & 5 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,11 +348,18 @@ to all contained interactive elements.

### Stacking

As we can't use popover API yet, stacking of overlay context is done manually.
However, this can interfere with the z-index of your components.
Therefore, every overlay component provides a CSS variable to override its z-index.
Additionally, there is the global CSS variable `--sbb-overlay-default-z-index` that has a default z-index of 1000.
With this, developers have the chance to change the z-index either globally or on component level.
Our overlay components internally use the Popover API to take advantage of positioning elements on the top layer.
As of January 2025, the browser support for the Popover API is at
[90%](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover#browser_compatibility)
and available for all [our supported browsers](https://github.com/sbb-design-systems/lyne-components?tab=readme-ov-file#-browser-and-screen-reader-support).
If you need to support older browsers, you can either attempt to use a polyfill or
manually configure the stacking order of the overlays.
Doing it manually may interfere with the z-index of your components.
Therefore, each overlay component provides a CSS variable to override its z-index.
Additionally, there is a global CSS variable `--sbb-overlay-default-z-index` which has a default z-index of 1000.
This allows developers to change the z-index either globally or at component level.
In some cases, it's not possible to break out of a particular stacking context. In this case, keep the trigger within
your component, but moving the overlay component out of the stacking context can help.

### Fonts

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
export const snapshots = {};

snapshots["sbb-autocomplete-grid Chrome-Firefox DOM"] =
`<sbb-autocomplete-grid data-state="closed">
`<sbb-autocomplete-grid
data-state="closed"
popover="manual"
>
<sbb-autocomplete-grid-row
id="sbb-autocomplete-grid-row-1"
role="row"
Expand Down Expand Up @@ -93,6 +96,7 @@ snapshots["sbb-autocomplete-grid Safari DOM"] =
`<sbb-autocomplete-grid
data-state="closed"
id="sbb-autocomplete-grid-1"
popover="manual"
role="grid"
>
<sbb-autocomplete-grid-row
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,15 @@ describe(`sbb-autocomplete-grid`, () => {
await didOpenEventSpy.calledOnce();
expect(didOpenEventSpy.count).to.be.equal(1);
expect(input).to.have.attribute('aria-expanded', 'true');
expect(element).to.match(':popover-open');

await sendKeys({ press: 'Escape' });
await willCloseEventSpy.calledOnce();
expect(willCloseEventSpy.count).to.be.equal(1);
await didCloseEventSpy.calledOnce();
expect(didCloseEventSpy.count).to.be.equal(1);
expect(input).to.have.attribute('aria-expanded', 'false');
expect(element).not.to.match(':popover-open');

await sendKeys({ press: 'ArrowDown' });
await willOpenEventSpy.calledTimes(2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ snapshots["sbb-autocomplete renders standalone Safari DOM"] =
data-state="closed"
id="sbb-autocomplete-1"
origin="origin"
popover="manual"
role="listbox"
trigger="trigger"
>
Expand Down Expand Up @@ -86,6 +87,7 @@ snapshots["sbb-autocomplete renders in form field Safari DOM"] =
<sbb-autocomplete
data-state="closed"
id="sbb-autocomplete-3"
popover="manual"
role="listbox"
>
<sbb-option
Expand Down Expand Up @@ -153,6 +155,7 @@ snapshots["sbb-autocomplete renders standalone Chrome-Firefox DOM"] =
`<sbb-autocomplete
data-state="closed"
origin="origin"
popover="manual"
trigger="trigger"
>
<sbb-option
Expand Down Expand Up @@ -233,7 +236,10 @@ snapshots["sbb-autocomplete renders in form field Chrome-Firefox DOM"] =
autocomplete="off"
role="combobox"
>
<sbb-autocomplete data-state="closed">
<sbb-autocomplete
data-state="closed"
popover="manual"
>
<sbb-option
aria-selected="false"
data-slot-names="unnamed"
Expand Down
4 changes: 4 additions & 0 deletions src/elements/autocomplete/autocomplete-base-element.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
var(--sbb-overlay-default-z-index)
);

display: none;
}

:host([data-state]:not([data-state='closed'])) {
display: block;
}

Expand Down
10 changes: 8 additions & 2 deletions src/elements/autocomplete/autocomplete-base-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ref } from 'lit/directives/ref.js';

import { SbbOpenCloseBaseElement } from '../core/base-elements.js';
import { SbbConnectedAbortController } from '../core/controllers.js';
import { forceType } from '../core/decorators.js';
import { forceType, hostAttributes } from '../core/decorators.js';
import { findReferencedElement, isSafari, isZeroAnimationDuration } from '../core/dom.js';
import { SbbNegativeMixin, SbbHydrationMixin } from '../core/mixins.js';
import {
Expand All @@ -30,7 +30,11 @@ import style from './autocomplete-base-element.scss?lit&inline';
*/
const ariaRoleOnHost = isSafari;

export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin(
export
@hostAttributes({
popover: 'manual',
})
abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin(
SbbHydrationMixin(SbbOpenCloseBaseElement),
) {
public static override styles: CSSResultGroup = style;
Expand Down Expand Up @@ -101,6 +105,7 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin(
return;
}

this.showPopover?.();
this.state = 'opening';
this._setOverlayPosition();

Expand Down Expand Up @@ -345,6 +350,7 @@ export abstract class SbbAutocompleteBaseElement extends SbbNegativeMixin(

private _handleClosing(): void {
this.state = 'closed';
this.hidePopover?.();
this.triggerElement?.setAttribute('aria-expanded', 'false');
this.resetActiveElement();
this._optionContainer.scrollTop = 0;
Expand Down
2 changes: 2 additions & 0 deletions src/elements/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,15 @@ describe(`sbb-autocomplete`, () => {
await didOpenEventSpy.calledOnce();
expect(didOpenEventSpy.count).to.be.equal(1);
expect(input).to.have.attribute('aria-expanded', 'true');
expect(element).to.match(':popover-open');

await sendKeys({ press: 'Escape' });
await willCloseEventSpy.calledOnce();
expect(willCloseEventSpy.count).to.be.equal(1);
await didCloseEventSpy.calledOnce();
expect(didCloseEventSpy.count).to.be.equal(1);
expect(input).to.have.attribute('aria-expanded', 'false');
expect(element).not.to.match(':popover-open');

await sendKeys({ press: 'ArrowDown' });
await willOpenEventSpy.calledTimes(2);
Expand Down
8 changes: 4 additions & 4 deletions src/elements/core/dom/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function pageScrollDisabled(): boolean {
* content shift caused by the disappearance/appearance of the scrollbar.
*/
export class SbbScrollHandler {
private _position!: string;
private _height!: string;
private _overflow!: string;
private _marginInlineEnd!: string;

Expand All @@ -17,14 +17,14 @@ export class SbbScrollHandler {
}

// Save any pre-existing styles to reapply them to the body when enabling the scroll again.
this._position = document.body.style.position;
this._height = document.body.style.height;
this._overflow = document.body.style.overflow;
this._marginInlineEnd = document.body.style.marginInlineEnd;

const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

document.body.style.overflow = 'hidden';
document.body.style.position = 'relative';
document.body.style.height = '100%';
document.body.style.marginInlineEnd = `${scrollbarWidth}px`;
document.body.style.setProperty('--sbb-scrollbar-width', `${scrollbarWidth}px`);

Expand All @@ -37,7 +37,7 @@ export class SbbScrollHandler {
}

// Revert body inline styles.
document.body.style.position = this._position || '';
document.body.style.height = this._height || '';
document.body.style.overflow = this._overflow || '';
document.body.style.marginInlineEnd = this._marginInlineEnd || '';
document.body.style.setProperty('--sbb-scrollbar-width', '0');
Expand Down
23 changes: 21 additions & 2 deletions src/elements/core/styles/core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,33 @@ sbb-form-field {
sbb-navigation,
sbb-navigation-section,
sbb-overlay,
sbb-popover,
sbb-select,
sbb-skiplink-list,
sbb-toast,
sbb-popover
sbb-toast
):not(:defined) {
display: none;
}

// Hide popover artifacts
[popover]:where(
sbb-autocomplete,
sbb-autocomplete-grid,
sbb-dialog,
sbb-menu,
sbb-navigation,
sbb-overlay,
sbb-popover,
sbb-toast
) {
margin: 0;
padding: 0;
border: none;
width: auto;
background-color: transparent;
color: inherit;
}

// Ensure stable breadcrumb height during hydrating
sbb-breadcrumb-group:not(:defined) {
display: block;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ snapshots["sbb-datepicker-toggle renders Shadow DOM"] =
data-state="closed"
hide-close-button=""
id="sbb-popover-2"
popover="manual"
>
<sbb-calendar>
</sbb-calendar>
Expand Down Expand Up @@ -54,6 +55,7 @@ snapshots["sbb-datepicker-toggle in form-field renders Shadow DOM"] =
data-state="closed"
hide-close-button=""
id="sbb-popover-4"
popover="manual"
>
<sbb-calendar>
</sbb-calendar>
Expand Down Expand Up @@ -84,6 +86,7 @@ snapshots["sbb-datepicker-toggle in form-field renders disabled Shadow DOM"] =
data-state="closed"
hide-close-button=""
id="sbb-popover-6"
popover="manual"
>
<sbb-calendar>
</sbb-calendar>
Expand Down Expand Up @@ -114,6 +117,7 @@ snapshots["sbb-datepicker-toggle in form-field with calendar parameters Shadow D
data-state="closed"
hide-close-button=""
id="sbb-popover-8"
popover="manual"
>
<sbb-calendar wide="">
</sbb-calendar>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ snapshots["sbb-dialog renders an open dialog DOM"] =
`<sbb-dialog
backdrop="opaque"
data-state="opened"
popover="manual"
>
<sbb-dialog-title
aria-level="2"
Expand Down
10 changes: 7 additions & 3 deletions src/elements/dialog/dialog/dialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
var(--sbb-dialog-animation-easing);
--sbb-dialog-actions-border: var(--sbb-border-width-1x) solid var(--sbb-color-cloud);

display: block;
display: none;
position: fixed;
inset: var(--sbb-dialog-inset);
z-index: var(--sbb-dialog-z-index, var(--sbb-overlay-default-z-index));
Expand All @@ -53,7 +53,11 @@
}
}

:host(:is([data-state='opened'], [data-state='opening'])) {
:host([data-state]:not([data-state='closed'])) {
display: block;
}

:host(:is([data-state='opening'], [data-state='opened'])) {
--sbb-dialog-pointer-events: all;
--sbb-dialog-backdrop-color: var(--sbb-color-milk);

Expand All @@ -63,7 +67,7 @@
}
}

:host([backdrop='translucent']:is([data-state='opened'], [data-state='opening'])) {
:host([backdrop='translucent']:is([data-state='opening'], [data-state='opened'])) {
--sbb-dialog-backdrop-color: var(--sbb-color-black-alpha-50);
}

Expand Down
2 changes: 2 additions & 0 deletions src/elements/dialog/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ async function openDialog(element: SbbDialogElement): Promise<void> {
await waitForLitRender(element);

expect(element).to.have.attribute('data-state', 'opened');
expect(element).to.match(':popover-open');
}

describe('sbb-dialog', () => {
Expand Down Expand Up @@ -92,6 +93,7 @@ describe('sbb-dialog', () => {
await waitForLitRender(element);

expect(element).to.have.attribute('data-state', 'closed');
expect(element).not.to.match(':popover-open');
expect(ariaLiveRef.textContent).to.be.equal('');
});

Expand Down
4 changes: 4 additions & 0 deletions src/elements/dialog/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ class SbbDialogElement extends SbbOverlayBaseElement {
if (!this.willOpen.emit()) {
return;
}

this.showPopover?.();
this.state = 'opening';

// Add this dialog to the global collection
Expand All @@ -110,6 +112,8 @@ class SbbDialogElement extends SbbOverlayBaseElement {
this._setHideHeaderDataAttribute(false);
this._dialogContentElement?.scrollTo(0, 0);
this.state = 'closed';
this.hidePopover?.();

this.inertController.deactivate();
setModalityOnNextFocus(this.lastFocusedElement);
// Manually focus last focused element
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ snapshots["sbb-menu renders DOM"] =
`<sbb-menu
data-state="closed"
id="sbb-menu-0"
popover="manual"
trigger="menu-trigger"
>
<sbb-block-link
Expand Down Expand Up @@ -78,6 +79,7 @@ snapshots["sbb-menu renders with list DOM"] =
`<sbb-menu
data-state="closed"
id="sbb-menu-2"
popover="manual"
trigger="menu-trigger"
>
<sbb-menu-button
Expand Down
2 changes: 2 additions & 0 deletions src/elements/menu/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe(`sbb-menu`, () => {
expect(didOpenEventSpy.count).to.be.equal(1);

expect(element).to.have.attribute('data-state', 'opened');
expect(element).to.match(':popover-open');
});

it('closes on Esc keypress', async () => {
Expand Down Expand Up @@ -87,6 +88,7 @@ describe(`sbb-menu`, () => {
expect(didCloseEventSpy.count).to.be.equal(1);

expect(element).to.have.attribute('data-state', 'closed');
expect(element).not.to.match(':popover-open');
});

it('keyboard navigation', async () => {
Expand Down
Loading

0 comments on commit 7ea0640

Please sign in to comment.