Skip to content

Commit

Permalink
feat(sbb-tooltip): initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
kyubisation committed Jul 25, 2024
1 parent 5a6ef04 commit cbdc4f6
Show file tree
Hide file tree
Showing 18 changed files with 925 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/elements/core/controllers.ts
Original file line number Diff line number Diff line change
@@ -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';
255 changes: 255 additions & 0 deletions src/elements/core/controllers/overlay-controller.ts
Original file line number Diff line number Diff line change
@@ -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<typeof requestAnimationFrame>;
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`;
}
}
}
2 changes: 2 additions & 0 deletions src/elements/core/dom.ts
Original file line number Diff line number Diff line change
@@ -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';
14 changes: 14 additions & 0 deletions src/elements/core/dom/contains-pierce-shadow-dom.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions src/elements/core/dom/queue-dom-content-loaded.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
1 change: 1 addition & 0 deletions src/elements/core/overlay.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit cbdc4f6

Please sign in to comment.