diff --git a/src/elements/core/controllers.ts b/src/elements/core/controllers.ts index c768396594..75a5c07e45 100644 --- a/src/elements/core/controllers.ts +++ b/src/elements/core/controllers.ts @@ -1,3 +1,4 @@ export * from './controllers/connected-abort-controller.js'; export * from './controllers/language-controller.js'; +export * from './controllers/overlay-controller.js'; export * from './controllers/slot-state-controller.js'; diff --git a/src/elements/core/controllers/overlay-controller.ts b/src/elements/core/controllers/overlay-controller.ts new file mode 100644 index 0000000000..309e934731 --- /dev/null +++ b/src/elements/core/controllers/overlay-controller.ts @@ -0,0 +1,255 @@ +import { isServer, type ReactiveController, type ReactiveControllerHost } from 'lit'; + +import { getDocumentWritingMode } from '../dom.js'; +import { AgnosticResizeObserver } from '../observers.js'; +import { sbbOverlayOutsidePointerEventListener } from '../overlay.js'; + +const cssAnchorPositionSupported = !isServer && CSS.supports('anchor-name', '--test'); +// TODO: Support more positions? +const supportedPositions = [ + 'block-end', + 'block-start', + 'end', + 'inline-end', + 'inline-start', + 'start', +]; +let nextId = 0; + +/** + * Controller for managing overlays. Also acts as a polyfill when native + * CSS Anchor Positioning is not supported (enough). + * Applies unique anchor names when using native CSS Anchor Positioning + * or calculates and applies correct positions in polyfill mode. + */ +export class SbbOverlayController implements ReactiveController { + private readonly _resizeObserver = this._usePolyfill + ? new AgnosticResizeObserver(() => this._requestCalculatePosition()) + : null!; + private _abortController?: AbortController; + private _trigger?: HTMLElement; + private _frame?: ReturnType; + private _hostStyles?: CSSStyleDeclaration; + private _anchorName = this._usePolyfill ? '' : `--sbb-overlay-anchor-${++nextId}`; + private _positions: string[] = []; + private _lastPosition?: string; + + /** Get the current position. (e.g. block-end, block-start, etc.) */ + public get currentPosition(): string { + if (this._usePolyfill) { + return this._lastPosition ?? this._positions[0] ?? ''; + } else { + this._hostStyles ??= getComputedStyle(this._host); + return this._hostStyles.getPropertyValue('inset-area'); + } + } + + public constructor( + private _host: ReactiveControllerHost & HTMLElement, + private _usePolyfill = !cssAnchorPositionSupported, + ) { + this._host.addController(this); + } + + public hostConnected(): void { + if (!this._usePolyfill) { + this._host.style.setProperty('position-anchor', this._anchorName); + } + } + + public hostUpdate(): void { + if (isServer || !this._usePolyfill) { + return; + } + this._hostStyles ??= getComputedStyle(this._host); + const positions = [ + this._hostStyles.getPropertyValue('--sbb-overlay-controller-inset-area') || 'block-end', + ...this._hostStyles + .getPropertyValue('--sbb-overlay-controller-position-try-fallbacks') + .split(',') + .map((f) => f.trim()) + .filter((f) => !!f), + ]; + + if (import.meta.env.DEV && positions.some((p) => !supportedPositions.includes(p))) { + const unsupportedPositions = positions + .filter((p) => !supportedPositions.includes(p)) + .sort() + .join(', '); + throw new Error( + `Unsupported position-try-fallbacks ${unsupportedPositions} (Supported: ${supportedPositions.join(', ')})`, + ); + } + + this._positions = positions; + if (this._lastPosition && !this._positions.includes(this._lastPosition)) { + this._lastPosition = undefined; + } + } + + public connect(trigger: HTMLElement): void { + if (isServer) { + return; + } + + this._trigger = trigger; + sbbOverlayOutsidePointerEventListener.connect(this._host); + if (!this._usePolyfill) { + this._trigger.style.setProperty('anchor-name', this._anchorName!); + return; + } + + this._calculatePosition(); + this._abortController?.abort(); + this._abortController = new AbortController(); + + // We need to use capture here to react to all scroll events. + // If capture was not used, then scroll events inside separate scroll + // containers would not be caught. + document.addEventListener('scroll', () => this._requestCalculatePosition(), { + capture: true, + passive: true, + signal: this._abortController.signal, + }); + window.addEventListener('resize', () => this._requestCalculatePosition(), { + passive: true, + signal: this._abortController.signal, + }); + this._resizeObserver.observe(trigger, { box: 'border-box' }); + this._resizeObserver.observe(this._host, { box: 'border-box' }); + } + + public disconnect(): void { + sbbOverlayOutsidePointerEventListener.disconnect(this._host); + if (this._usePolyfill) { + this._abortController?.abort(); + this._resizeObserver.disconnect(); + } else { + this._host.style.removeProperty('anchor-name'); + } + } + + private _requestCalculatePosition(): void { + if (this._frame) { + return; + } + + this._frame = requestAnimationFrame(() => { + this._calculatePosition(); + this._frame = undefined; + }); + } + + private _calculatePosition(): void { + const { offsetHeight: overlayHeight, offsetWidth: overlayWidth } = this._host; + const { innerHeight: viewportHeight, innerWidth: viewportWidth } = window; + const { + top: triggerOffsetBlockStart, + left: triggerOffsetInlineStart, + height: triggerHeight, + width: triggerWidth, + } = this._trigger!.getBoundingClientRect(); + + const ltr = getDocumentWritingMode() === 'ltr'; + const blockStartSpace = triggerOffsetBlockStart; + const blockEndSpace = viewportHeight - triggerHeight - triggerOffsetBlockStart; + + let inlineStartSpace = triggerOffsetInlineStart; + let inlineEndSpace = viewportWidth - triggerWidth - triggerOffsetInlineStart; + if (!ltr) { + inlineStartSpace = inlineEndSpace; + inlineEndSpace = triggerOffsetInlineStart; + } + + const overlayWidthOverlap = (overlayWidth - triggerWidth) / 2; + const overlayHeightOverlap = (overlayHeight - triggerHeight) / 2; + + this._host.style.setProperty('--sbb-overlay-controller-trigger-height', `${triggerHeight}px`); + this._host.style.setProperty('--sbb-overlay-controller-trigger-width', `${triggerWidth}px`); + + // TODO: RTL is probably not working correctly yet. + for (const position of this._positions) { + switch (position) { + default: + case 'block-end': + if ( + overlayHeight <= blockEndSpace && + overlayWidthOverlap <= inlineStartSpace && + overlayWidthOverlap <= inlineEndSpace + ) { + return this._applyOverlayPosition( + position, + triggerOffsetInlineStart - overlayWidthOverlap, + triggerOffsetBlockStart + triggerHeight, + ); + } + break; + case 'block-start': + if ( + overlayHeight <= blockStartSpace && + overlayWidthOverlap <= inlineStartSpace && + overlayWidthOverlap <= inlineEndSpace + ) { + return this._applyOverlayPosition( + position, + triggerOffsetInlineStart - overlayWidthOverlap, + triggerOffsetBlockStart - overlayHeight, + ); + } + break; + case 'end': + if (overlayHeight <= blockEndSpace && overlayWidth <= inlineEndSpace) { + return this._applyOverlayPosition( + position, + triggerOffsetInlineStart + triggerWidth, + triggerOffsetBlockStart + triggerHeight, + ); + } + break; + case 'inline-end': + if ( + overlayWidth <= inlineEndSpace && + overlayHeightOverlap <= blockStartSpace && + overlayHeightOverlap <= blockEndSpace + ) { + return this._applyOverlayPosition( + position, + triggerOffsetInlineStart + triggerWidth, + triggerOffsetBlockStart - overlayHeightOverlap, + ); + } + break; + case 'inline-start': + if ( + overlayWidth <= inlineStartSpace && + overlayHeightOverlap <= blockStartSpace && + overlayHeightOverlap <= blockEndSpace + ) { + return this._applyOverlayPosition( + position, + triggerOffsetInlineStart - overlayWidth, + triggerOffsetBlockStart - overlayHeightOverlap, + ); + } + break; + case 'start': + if (overlayHeight <= blockStartSpace && overlayWidth <= inlineStartSpace) { + return this._applyOverlayPosition( + position, + triggerOffsetInlineStart - overlayWidth, + triggerOffsetBlockStart - overlayHeight, + ); + } + break; + } + } + } + + private _applyOverlayPosition(position: string, inlineStart: number, blockStart: number): void { + if (this._lastPosition !== position) { + this._lastPosition = position; + this._host.style.insetInlineStart = `${inlineStart}px`; + this._host.style.insetBlockStart = `${blockStart}px`; + } + } +} diff --git a/src/elements/core/dom.ts b/src/elements/core/dom.ts index 5920d373a4..7f91829c4f 100644 --- a/src/elements/core/dom.ts +++ b/src/elements/core/dom.ts @@ -1,9 +1,11 @@ export * from './dom/breakpoint.js'; +export * from './dom/contains-pierce-shadow-dom.js'; export * from './dom/find-referenced-element.js'; export * from './dom/get-document-writing-mode.js'; export * from './dom/host-context.js'; export * from './dom/input-element.js'; export * from './dom/set-or-remove-attribute.js'; export * from './dom/platform.js'; +export * from './dom/queue-dom-content-loaded.js'; export * from './dom/scroll.js'; export * from './dom/ssr.js'; diff --git a/src/elements/core/dom/contains-pierce-shadow-dom.ts b/src/elements/core/dom/contains-pierce-shadow-dom.ts new file mode 100644 index 0000000000..8a25125866 --- /dev/null +++ b/src/elements/core/dom/contains-pierce-shadow-dom.ts @@ -0,0 +1,14 @@ +/** Equivalent to `Element.contains` while piercing shadow DOM. */ +export function containsPierceShadowDom(root: HTMLElement, child: HTMLElement | null): boolean { + let current: Node | null = child; + + while (current) { + if (current === root) { + return true; + } + + current = current instanceof ShadowRoot ? current.host : current.parentNode; + } + + return false; +} diff --git a/src/elements/core/dom/queue-dom-content-loaded.ts b/src/elements/core/dom/queue-dom-content-loaded.ts new file mode 100644 index 0000000000..d9c8938a50 --- /dev/null +++ b/src/elements/core/dom/queue-dom-content-loaded.ts @@ -0,0 +1,8 @@ +export function queueDomContentLoaded(action: () => void): void { + const queuedAction = (): void => queueMicrotask(action); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', queuedAction); + } else { + queuedAction(); + } +} diff --git a/src/elements/core/overlay.ts b/src/elements/core/overlay.ts index eeed1ee3d9..5665784c62 100644 --- a/src/elements/core/overlay.ts +++ b/src/elements/core/overlay.ts @@ -1,4 +1,5 @@ export * from './overlay/overlay.js'; export * from './overlay/overlay-option-panel.js'; +export * from './overlay/overlay-outside-pointer-event-listener.js'; export * from './overlay/overlay-trigger-attributes.js'; export * from './overlay/position.js'; diff --git a/src/elements/core/overlay/overlay-outside-pointer-event-listener.ts b/src/elements/core/overlay/overlay-outside-pointer-event-listener.ts new file mode 100644 index 0000000000..027b7a5ebd --- /dev/null +++ b/src/elements/core/overlay/overlay-outside-pointer-event-listener.ts @@ -0,0 +1,97 @@ +import { containsPierceShadowDom, isIOS } from '../dom.js'; + +/** + * Listens globally to pointer events that happen outside the overlay area. + * + * Ref: https://github.com/angular/components/blob/main/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts + */ +export class SbbOverlayOutsidePointerEventListener { + private _overlays = new Set(); + private _pointerDownEventTarget?: HTMLElement; + private _abortController?: AbortController; + private _cursorOriginalValue?: string; + + public connect(overlay: HTMLElement): void { + if (!this._overlays.size) { + this._addGlobalEventListeners(); + if (isIOS) { + // Safari on iOS does not generate click events for non-interactive + // elements. However, we want to receive a click for any element outside + // the overlay. We can force a "clickable" state by setting + // `cursor: pointer` on the document body. See: + // https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#Safari_Mobile + // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html + this._cursorOriginalValue = document.body.style.cursor; + document.body.style.cursor = 'pointer'; + } + } + this._overlays.add(overlay); + } + + public disconnect(overlay: HTMLElement): void { + this._overlays.delete(overlay); + if (!this._overlays.size) { + this._abortController?.abort(); + this._abortController = undefined; + if (isIOS) { + document.body.style.cursor = this._cursorOriginalValue!; + } + } + } + + private _addGlobalEventListeners(): void { + const body = document.body; + this._abortController = new AbortController(); + const options: AddEventListenerOptions = { + capture: true, + passive: true, + signal: this._abortController.signal, + }; + body.addEventListener('pointerdown', this._pointerDownListener, options); + body.addEventListener('click', this._clickListener, options); + body.addEventListener('auxclick', this._clickListener, options); + body.addEventListener('contextmenu', this._clickListener, options); + } + + /** Store pointerdown event target to track origin of click. */ + private _pointerDownListener = (event: PointerEvent): void => { + this._pointerDownEventTarget = event.composedPath()[0] as HTMLElement; + }; + + /** Click event listener that will be attached to the body propagate phase. */ + private _clickListener = (event: MouseEvent): void => { + const target = event.composedPath()[0] as HTMLElement; + // In case of a click event, we want to check the origin of the click + // (e.g. in case where a user starts a click inside the overlay and + // releases the click outside of it). + // This is done by using the event target of the preceding pointerdown event. + // Every click event caused by a pointer device has a preceding pointerdown + // event, unless the click was programmatically triggered (e.g. in a unit test). + const origin = + event.type === 'click' && this._pointerDownEventTarget + ? this._pointerDownEventTarget + : target; + // Reset the stored pointerdown event target, to avoid having it interfere + // in subsequent events. + this._pointerDownEventTarget = undefined; + + // Start checking from the newest overlay. Once we find an overlay for which the + // click is not outside its area, we break the loop. + for (const overlay of Array.from(this._overlays).reverse()) { + if (containsPierceShadowDom(overlay, target) || containsPierceShadowDom(overlay, origin)) { + break; + } + + overlay.dispatchEvent(new CustomEvent('overlayOutsidePointer')); + } + }; +} + +/** The global instance for listening for outside pointer events. */ +export const sbbOverlayOutsidePointerEventListener = new SbbOverlayOutsidePointerEventListener(); + +declare global { + interface GlobalEventHandlersEventMap { + overlayOutsidePointer: CustomEvent; + } +} diff --git a/src/elements/core/styles/core.scss b/src/elements/core/styles/core.scss index 1f93b0fec6..77fa56104a 100644 --- a/src/elements/core/styles/core.scss +++ b/src/elements/core/styles/core.scss @@ -122,3 +122,9 @@ sbb-title + p { input[data-sbb-time-input] { max-width: var(--sbb-time-input-max-width); } + +.sbb-overlay-outlet { + position: fixed; + inset: 0; + pointer-events: none; +} diff --git a/src/elements/tooltip.ts b/src/elements/tooltip.ts new file mode 100644 index 0000000000..4e3505d28a --- /dev/null +++ b/src/elements/tooltip.ts @@ -0,0 +1 @@ +export * from './tooltip/tooltip.js'; diff --git a/src/elements/tooltip/index.ts b/src/elements/tooltip/index.ts new file mode 100644 index 0000000000..9c05c6f92b --- /dev/null +++ b/src/elements/tooltip/index.ts @@ -0,0 +1 @@ +export * from './tooltip.js'; diff --git a/src/elements/tooltip/readme.md b/src/elements/tooltip/readme.md new file mode 100644 index 0000000000..d463e57d0a --- /dev/null +++ b/src/elements/tooltip/readme.md @@ -0,0 +1,44 @@ +> Explain the use and the purpose of the component; add minor details if needed and provide a basic example.
+> If you reference other components, link their documentation at least once (the path must start from _/docs/..._ ).
+> For the examples, use triple backticks with file extension (` ```html ``` `).
+> The following list of paragraphs is only suggested; remove, create and adapt as needed. + +The `sbb-tooltip` is a component . . . + +```html + +``` + +## Slots + +> Describe slot naming and usage and provide an example of slotted content. + +## States + +> Describe the component states (`disabled`, `readonly`, etc.) and provide examples. + +## Style + +> Describe the properties which change the component visualization (`size`, `negative`, etc.) and provide examples. + +## Interactions + +> Describe how it's possible to interact with the component (open and close a `sbb-dialog`, dismiss a `sbb-alert`, etc.) and provide examples. + +## Events + +> Describe events triggered by the component and possibly how to get information from the payload. + +## Keyboard interaction + +> If the component has logic for keyboard navigation (as the `sbb-calendar` or the `sbb-select`) describe it. + +| Keyboard | Action | +| -------------- | ------------- | +| Key | What it does. | + +## Accessibility + +> Describe how accessibility is implemented and if there are issues or suggested best-practice for the consumers. + + diff --git a/src/elements/tooltip/tooltip.scss b/src/elements/tooltip/tooltip.scss new file mode 100644 index 0000000000..a80a0bda04 --- /dev/null +++ b/src/elements/tooltip/tooltip.scss @@ -0,0 +1,100 @@ +@use '../core/styles' as sbb; + +// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component. +@include sbb.box-sizing; + +:host { + display: inline-block; + position: fixed; + pointer-events: auto; + + --sbb-tooltip-padding: var(--sbb-spacing-fixed-2x) var(--sbb-spacing-fixed-4x); + --sbb-tooltip-margin: var(--sbb-spacing-fixed-4x); + --sbb-tooltip-border-radius: var(--sbb-spacing-fixed-6x); + --sbb-tooltip-font-size: var(--sbb-font-size-text-s); + --sbb-tooltip-color: var(--sbb-color-white); + --sbb-tooltip-background-color: var(--sbb-color-midnight); + --sbb-tooltip-arrow-content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 7' fill='none'%3E%3Cpath d='M6.99382e-07 -1.39876e-06L16 0L10.8284 5.17157C9.26633 6.73367 6.73367 6.73367 5.17158 5.17157L6.99382e-07 -1.39876e-06Z' fill='%23151515'/%3E%3C/svg%3E"); + + // The pixel values are hardcoded to work with the above svg image. + --sbb-tooltip-arrow-width: #{sbb.px-to-rem-build(16)}; + --sbb-tooltip-arrow-height: #{sbb.px-to-rem-build(7)}; + --sbb-tooltip-arrow-inset-block-offset: #{sbb.px-to-rem-build(-7)}; + --sbb-tooltip-arrow-inset-inline-offset: #{sbb.px-to-rem-build(-10)}; + + // These values are read by SbbOverlayController, if anchor positioning is not supported. + --sbb-overlay-controller-inset-area: block-start; + --sbb-overlay-controller-position-try-fallbacks: block-end, inline-start, inline-end; + + // TODO: Not currently used, as we force usage of polyfill in tooltip. Revisit when ::tether + // or inset-area transition is supported. https://github.com/w3c/csswg-drafts/issues/9271 + + // position-anchor will be defined by the component. + inset-area: var(--sbb-overlay-controller-inset-area); + + // TODO: Only necessary for chromium browser. Can be removed once position-try-fallbacks is fully supported. + position-try-options: inset-area(block-end), inset-area(inline-start), inset-area(inline-end); + /* stylelint-disable-next-line property-no-unknown */ + position-try-fallbacks: var(--sbb-overlay-controller-position-try-fallbacks); + + // We are using the transitionstart event in tooltip to place the arrow in the correct position. + transition: + // TODO: Not supported yet. + inset-area 1ms, + // Workaround for missing transition support for inset-area. + inset 1ms; +} + +:host([negative]) { + --sbb-tooltip-color: var(--sbb-color-charcoal); + --sbb-tooltip-background-color: var(--sbb-color-milk); +} + +:host(:not([data-show])) { + visibility: hidden; + pointer-events: none; +} + +.sbb-tooltip { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; + padding: var(--sbb-tooltip-padding); + margin: var(--sbb-tooltip-margin); + border-radius: var(--sbb-tooltip-border-radius); + background-color: var(--sbb-tooltip-background-color); + font-size: var(--sbb-tooltip-font-size); + color: var(--sbb-tooltip-color); + + &::after { + content: ''; + position: absolute; + display: inline-block; + background-color: var(--sbb-tooltip-background-color); + mask-image: var(--sbb-tooltip-arrow-content); + mask-type: alpha; + height: var(--sbb-tooltip-arrow-height); + width: var(--sbb-tooltip-arrow-width); + + :host(:is(:not([data-position]), [data-position='block-start'])) & { + inset-block-end: var(--sbb-tooltip-arrow-inset-block-offset); + } + + :host([data-position='block-end']) & { + inset-block-start: var(--sbb-tooltip-arrow-inset-block-offset); + rotate: 180deg; + } + + :host([data-position='inline-start']) & { + inset-inline-end: var(--sbb-tooltip-arrow-inset-inline-offset); + rotate: -90deg; + } + + :host([data-position='inline-end']) & { + inset-inline-start: var(--sbb-tooltip-arrow-inset-inline-offset); + rotate: 90deg; + } + } +} diff --git a/src/elements/tooltip/tooltip.snapshot.spec.ts b/src/elements/tooltip/tooltip.snapshot.spec.ts new file mode 100644 index 0000000000..efdd4324f3 --- /dev/null +++ b/src/elements/tooltip/tooltip.snapshot.spec.ts @@ -0,0 +1,27 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../core/testing/private.js'; + +import type { SbbTooltipElement } from './tooltip.js'; +import './tooltip.js'; + +describe(`sbb-tooltip`, () => { + it('renders', () => { + let element: SbbTooltipElement; + + beforeEach(async () => { + element = await fixture(html`Test`); + }); + + it('DOM', async () => { + await expect(element).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(element).shadowDom.to.be.equalSnapshot(); + }); + + testA11yTreeSnapshot(); + }); +}); diff --git a/src/elements/tooltip/tooltip.spec.ts b/src/elements/tooltip/tooltip.spec.ts new file mode 100644 index 0000000000..f39ce28339 --- /dev/null +++ b/src/elements/tooltip/tooltip.spec.ts @@ -0,0 +1,18 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture } from '../core/testing/private.js'; + +import { SbbTooltipElement } from './tooltip.js'; + +describe('sbb-tooltip', () => { + let element: SbbTooltipElement; + + beforeEach(async () => { + element = await fixture(html`Test`); + }); + + it('renders', async () => { + assert.instanceOf(element, SbbTooltipElement); + }); +}); diff --git a/src/elements/tooltip/tooltip.ssr.spec.ts b/src/elements/tooltip/tooltip.ssr.spec.ts new file mode 100644 index 0000000000..400f3ce7dd --- /dev/null +++ b/src/elements/tooltip/tooltip.ssr.spec.ts @@ -0,0 +1,22 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { ssrHydratedFixture } from '../core/testing/private.js'; + +import { SbbTooltipElement } from './tooltip.js'; + +describe(`sbb-tooltip ssr`, () => { + it('renders', () => { + let root: SbbTooltipElement; + + beforeEach(async () => { + root = await ssrHydratedFixture(html`Test`, { + modules: ['./tooltip.js'], + }); + }); + + it('renders', () => { + assert.instanceOf(root, SbbTooltipElement); + }); + }); +}); diff --git a/src/elements/tooltip/tooltip.stories.ts b/src/elements/tooltip/tooltip.stories.ts new file mode 100644 index 0000000000..ba0f813832 --- /dev/null +++ b/src/elements/tooltip/tooltip.stories.ts @@ -0,0 +1,60 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { InputType } from '@storybook/types'; +import type { + Args, + ArgTypes, + Decorator, + Meta, + StoryContext, + StoryObj, +} from '@storybook/web-components'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import readme from './readme.md?raw'; + +import '../button/mini-button.js'; +import './tooltip.js'; + +const myProp: InputType = { + control: { + type: 'select', + }, + options: ['padding', 'start', 'end'], +}; + +const defaultArgTypes: ArgTypes = { + alignment: myProp, +}; + +const defaultArgs: Args = { + alignment: 'Alignment', +}; + +const Template = (args: Args): TemplateResult => + html`
+ +
`; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + actions: { + handles: [], + }, + backgroundColor: (context: StoryContext) => + context.args.negative ? 'var(--sbb-color-black)' : 'var(--sbb-color-white)', + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-tooltip', +}; + +export default meta; diff --git a/src/elements/tooltip/tooltip.ts b/src/elements/tooltip/tooltip.ts new file mode 100644 index 0000000000..d4bca766f6 --- /dev/null +++ b/src/elements/tooltip/tooltip.ts @@ -0,0 +1,245 @@ +import { + html, + isServer, + LitElement, + type CSSResultGroup, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbConnectedAbortController, SbbOverlayController } from '../core/controllers.js'; +import { isAndroid, isIOS, queueDomContentLoaded } from '../core/dom.js'; +import { SbbDisabledMixin, SbbNegativeMixin } from '../core/mixins.js'; + +import style from './tooltip.scss?lit&inline'; + +const isMobile = isAndroid || isIOS; +const tooltipTriggers = new WeakMap(); +const tooltips = new Set(); +let nextId = 0; + +/** + * Describe the purpose of the component with a single short sentence. + * + * @slot - Use the unnamed slot to add `sbb-TODO` elements. + * @event {CustomEvent} myEventName - TODO: Document this event + */ +@customElement('sbb-tooltip') +export class SbbTooltipElement extends SbbNegativeMixin(SbbDisabledMixin(LitElement)) { + public static override styles: CSSResultGroup = style; + + private static _tooltipOutlet: Element; + + static { + if (!isServer) { + // We don't want to block execution for initialization, + // so we defer it until the DOM content is loaded. + queueDomContentLoaded(() => this._initializeTooltip()); + } + } + + /** The trigger element for the tooltip. */ + public get trigger(): HTMLElement | null { + return this._triggerElement?.deref() ?? null; + } + + /** Whether the tooltip is visible or not. */ + public get isVisible(): boolean { + return this.hasAttribute('data-show'); + } + + private readonly _internals = this.attachInternals(); + private readonly _overlay = new SbbOverlayController(this, true); + private _abortController = new SbbConnectedAbortController(this); + + /** A weak reference to the trigger element, to allow the trigger to be garbage collected. */ + private _triggerElement?: WeakRef; + private _triggerAbortController?: AbortController; + + public constructor() { + super(); + /** @internal */ + this._internals.role = 'tooltip'; + tooltips.add(this); + } + + private static _initializeTooltip(): void { + this._tooltipOutlet = document.createElement('div'); + this._tooltipOutlet.classList.add('sbb-overlay-outlet'); + document.body.appendChild(this._tooltipOutlet); + + // We are using MutationObserver directly here, as it will only be called on client side + // and we do not need to disconnect it, as we want it to work during the full lifetime + // of the page. + new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + this._handleTooltipTrigger(mutation.target as HTMLElement); + } else if (mutation.type === 'childList') { + for (const node of [...mutation.addedNodes, ...mutation.removedNodes].filter( + (n): n is HTMLElement => n.nodeType === n.ELEMENT_NODE, + )) { + this._handleTooltipTrigger(node); + this._findAndHandleTooltipTriggers(node); + } + } + } + }).observe(document.documentElement, { + attributeFilter: ['sbb-tooltip'], + childList: true, + subtree: true, + }); + this._findAndHandleTooltipTriggers(document.body); + } + + private static _findAndHandleTooltipTriggers(root: HTMLElement): void { + root + .querySelectorAll('[sbb-tooltip]') + .forEach((e) => this._handleTooltipTrigger(e)); + } + + private static _handleTooltipTrigger(triggerElement: HTMLElement): void { + const tooltipMessage = triggerElement.getAttribute('sbb-tooltip'); + let tooltip = tooltipTriggers.get(triggerElement); + if (tooltipMessage) { + if (!tooltip) { + tooltip = document.createElement('sbb-tooltip'); + tooltipTriggers.set(triggerElement, tooltip); + this._tooltipOutlet.appendChild(tooltip); + tooltip._attach(triggerElement); + } + tooltip.textContent = tooltipMessage; + } else if (tooltip) { + tooltipTriggers.delete(triggerElement); + tooltip._destroy(); + } + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.id ||= `sbb-tooltip-${++nextId}`; + const options: AddEventListenerOptions = { + signal: this._abortController.signal, + passive: true, + }; + this.addEventListener( + 'mouseleave', + (event) => { + if ( + (!event.relatedTarget || !this.trigger?.contains(event.relatedTarget as Node)) && + this.isVisible + ) { + this.hide(); + } + }, + options, + ); + this.addEventListener( + 'overlayOutsidePointer', + () => { + if (this.isVisible) { + this.hide(); + } + }, + options, + ); + this.addEventListener('transitionstart', () => this._updatePosition(), options); + } + + public show(): void { + if (!this.trigger || this.isVisible) { + return; + } + this._overlay.connect(this.trigger); + this._updatePosition(); + this.toggleAttribute('data-show', true); + } + + public hide(): void { + if (!this.isVisible) { + return; + } + this.removeAttribute('data-show'); + this._overlay.disconnect(); + } + + private _attach(trigger: HTMLElement): void { + if (this._triggerElement && this._triggerElement.deref() !== trigger) { + this._detach(); + } + + this._triggerElement = new WeakRef(trigger); + this._addTriggerEventHandlers(); + } + + private _detach(): void { + this._triggerAbortController?.abort(); + this._triggerElement = undefined; + } + + private _destroy(): void { + this._detach(); + this.remove(); + tooltips.delete(this); + } + + private _updatePosition(): void { + this.setAttribute('data-position', this._overlay.currentPosition); + } + + private _addTriggerEventHandlers(): void { + const trigger = this._triggerElement?.deref(); + if (!trigger) { + return; + } + + this._triggerAbortController?.abort(); + this._triggerAbortController = new AbortController(); + const options: AddEventListenerOptions = { + signal: this._triggerAbortController.signal, + passive: true, + }; + + trigger.addEventListener('focus', () => this.show(), options); + trigger.addEventListener('blur', () => this.hide(), options); + if (isMobile) { + trigger.addEventListener('touchstart', () => {}, options); + } else { + trigger.addEventListener('mouseenter', () => this.show(), options); + trigger.addEventListener( + 'mouseleave', + (event) => { + const newTarget = event.relatedTarget as Node | null; + if (!newTarget || !SbbTooltipElement._tooltipOutlet.contains(newTarget)) { + this.hide(); + } + }, + options, + ); + } + } + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + if (changedProperties.has('disabled')) { + if (this.disabled) { + this._triggerAbortController?.abort(); + this._triggerAbortController = undefined; + } else { + this._addTriggerEventHandlers(); + } + } + } + + protected override render(): TemplateResult { + return html`
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-tooltip': SbbTooltipElement; + } +} diff --git a/src/elements/tooltip/tooltip.visual.spec.ts b/src/elements/tooltip/tooltip.visual.spec.ts new file mode 100644 index 0000000000..d65b6d0be9 --- /dev/null +++ b/src/elements/tooltip/tooltip.visual.spec.ts @@ -0,0 +1,23 @@ +import { html } from 'lit'; + +import { describeViewports, visualDiffStandardStates } from '../core/testing/private.js'; + +import './tooltip.js'; + +describe('sbb-tooltip', () => { + /** + * Add the `viewports` param to test only specific viewport; + * add the `viewportHeight` param to set a fixed height for the browser. + */ + describeViewports({ viewports: ['small', 'large'] }, () => { + // Create visual tests considering the implemented states (default, hover, active, focus) + for (const state of visualDiffStandardStates) { + it( + `${state.name}`, + state.with(async (setup) => { + await setup.withFixture(html`Test`); + }), + ); + } + }); +});