Create Libraries and basic tooltip

This commit is contained in:
2023-08-25 22:09:18 +02:00
parent 82561d8dc7
commit 14c1b97622
81 changed files with 9117 additions and 2036 deletions

View File

@@ -0,0 +1 @@
export * from './lib/tooltip.component';

View File

@@ -0,0 +1,134 @@
import { ReactiveController, ReactiveControllerHost } from 'lit';
import { Subject, debounceTime, fromEvent, takeUntil, tap } from 'rxjs';
import { Placement } from '../models/placement.enum';
import TooltipComponent from '../tooltip.component';
/**
* Calculate the placement of the tooltip.
*
* If the tooltip does not fit in the screen it will try to find a better position. Does not stick to the sided of the screen.
* Moves along the item on screen resize after a slight delay.
*
*/
export class PositionController implements ReactiveController {
/** Private variables */
private readonly _host: ReactiveControllerHost<TooltipComponent>;
private _hostTarget: HTMLElement | undefined;
private readonly destroy$ = new Subject<void>();
/** Protected variables */
/** Public variables */
public top: number = 0;
public left: number = 0;
public position: Placement;
/** constructor & lifecycle */
constructor(host: ReactiveControllerHost<TooltipComponent>) {
(this._host = host).addController(this);
}
public hostConnected() {
this._hostTarget = this._host.targetHost;
this.position = this._host.preferredPlacement;
if (!this._hostTarget) {
throw new Error('TooltipComponent targetHost is undefined');
}
this.update();
fromEvent(window, 'resize')
.pipe(
debounceTime(300),
tap(() => this.update()),
takeUntil(this.destroy$),
)
.subscribe();
}
public hostDisconnected() {
this.destroy$.next();
this.destroy$.complete();
}
/** Public methods */
public update() {
let topLeft = this.getPositions(this._host.preferredPlacement);
const preferredPosition = this._host.preferredPlacement;
const exhaustedPositions: Placement[] = [preferredPosition];
const availablePosition: Placement[] = [Placement.top, Placement.left, Placement.right, Placement.bottom];
this.position = this._host.preferredPlacement;
while (!this.doesPostionFitInScreen(topLeft) && exhaustedPositions.length < 4) {
topLeft = this.getPositions(availablePosition.find((p) => !exhaustedPositions.includes(p))!);
exhaustedPositions.push(availablePosition.find((p) => !exhaustedPositions.includes(p))!);
this.position = availablePosition.find((p) => !exhaustedPositions.includes(p))!;
}
console.log(this.position);
this.top = topLeft.top;
this.left = topLeft.left;
this._host.requestUpdate();
}
/** Protected methods */
protected getPositions(position: Placement): { top: number; left: number } {
const domRect = this.getElementBoundingClientRect(this._hostTarget!);
const topLeft = {
top: 0,
left: 0,
};
switch (position) {
case Placement.top:
topLeft.top = domRect.top - this._host.contentElement?.offsetHeight;
topLeft.left = domRect.left;
break;
case 'bottom':
topLeft.top = domRect.bottom;
topLeft.left = domRect.left;
break;
case 'left':
topLeft.top = domRect.top + domRect.height / 2 - this._host.contentElement?.offsetHeight / 2;
topLeft.left = domRect.left - this._host.contentElement?.offsetWidth;
break;
case 'right':
topLeft.top = domRect.top + domRect.height / 2 - this._host.contentElement?.offsetHeight / 2;
topLeft.left = domRect.right;
break;
}
return topLeft;
}
/** Private methods */
private getElementBoundingClientRect(element: Element): DOMRect {
return element.getBoundingClientRect();
}
private doesPostionFitInScreen(topLeft: { top: number; left: number }): boolean {
if (topLeft.top < 0) {
return false;
}
if (topLeft.left < 0) {
return false;
}
if (this.top + this._host.contentElement?.offsetHeight > window.innerHeight) {
return false;
}
if (this.left + this._host.contentElement?.offsetWidth > window.innerWidth) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,125 @@
import { ReactiveController, ReactiveControllerHost } from 'lit';
import {
BehaviorSubject,
Subject,
debounceTime,
distinctUntilChanged,
filter,
finalize,
fromEvent,
map,
merge,
scan,
takeUntil,
tap,
} from 'rxjs';
import TooltipComponent from '../tooltip.component';
const enum TooltipState {
open = 'open',
closed = 'closed',
forceOpen = 'forceOpen',
forceClosed = 'forceClosed',
}
export class VisibilityController implements ReactiveController {
/** Private variables */
private readonly _host: ReactiveControllerHost<TooltipComponent>;
private readonly destroy$ = new Subject<void>();
/** Protected variables */
protected isVisible$ = new BehaviorSubject<boolean>(false);
protected emitShow$: Subject<void> = new Subject<void>();
protected emitHide$: Subject<void> = new Subject<void>();
/** Public variables */
public isVisible = this.isVisible$.asObservable();
public show$ = this.emitShow$.asObservable();
public hide$ = this.emitHide$.asObservable();
/** constructor & lifecylce */
constructor(host: ReactiveControllerHost<TooltipComponent>) {
this._host = host;
this._host.addController(this);
}
hostConnected() {
const hostTarget = document.querySelector<HTMLElement>(`[aria-describedby="${this._host.id}"]`);
if (!hostTarget) {
return;
}
const openEvent$ = merge(
fromEvent(hostTarget, 'mouseenter').pipe(map(() => TooltipState.open)),
fromEvent(hostTarget, 'focus').pipe(map(() => TooltipState.open)),
fromEvent(hostTarget, 'click').pipe(map(() => TooltipState.open)),
fromEvent(this._host, 'mouseover').pipe(
filter(() => this._host.interactive),
map(() => TooltipState.forceOpen),
),
);
const closeEvent$ = merge(
fromEvent(hostTarget, 'blur').pipe(map(() => TooltipState.closed)),
fromEvent(hostTarget, 'mouseleave').pipe(map(() => TooltipState.closed)),
fromEvent<MouseEvent>(this._host, 'click').pipe(
filter(() => this._host.interactive),
filter((event: MouseEvent) => event.target === this._host),
map(() => TooltipState.forceClosed),
),
fromEvent<KeyboardEvent>(document, 'keydown').pipe(
debounceTime(100),
filter((event: KeyboardEvent) => !event.repeat), // Ignore auto-repeated keydown events
filter((event: KeyboardEvent) => event.key === 'Escape'),
map(() => TooltipState.forceClosed),
),
);
merge(openEvent$, closeEvent$)
.pipe(
distinctUntilChanged(),
takeUntil(this.destroy$),
scan((acc, curr) => {
if (acc === TooltipState.forceOpen && curr !== TooltipState.forceClosed) {
return TooltipState.forceOpen;
}
return curr;
}),
tap((state) => {
if (state === TooltipState.open || state === TooltipState.forceOpen) {
this.show();
}
if (state === TooltipState.closed || state === TooltipState.forceClosed) {
this.hide();
}
}),
finalize(() => this.hide()),
)
.subscribe();
}
hostDisconnected() {
this.destroy$.next();
this.destroy$.complete();
}
/** Public methods */
public show(): void {
this.isVisible$.next(true);
this.emitShow$.next();
}
public hide(): void {
this.isVisible$.next(false);
this.emitHide$.next();
}
/** Protected methods */
/** Private methods */
}

View File

@@ -0,0 +1,6 @@
export const enum Placement {
top = 'top',
bottom = 'bottom',
left = 'left',
right = 'right',
}

View File

@@ -0,0 +1,59 @@
import { VariableHelper } from '@hijlkema-codes/internal/z-styles';
import { css } from 'lit';
export const basicStyles = css`
:host {
display: block;
position: fixed;
inset: 0;
pointer-events: none;
}
:host([interactive][open]) {
pointer-events: auto;
background-color: var(--_tooltip--backdrop-color);
backdrop-filter: blur(2px);
}
div {
position: absolute;
top: calc(1px * var(--_pos-top, 0) + var(--_offset--top));
left: calc(1px * var(--_pos-left, 0) + var(--_offset--left));
display: none;
}
div[open] {
display: block;
}
`;
export const positioning = css`
[data-position='top'] {
--_offset--top: calc(-1px * var(--_tooltip--offset));
--_offset--left: 0px;
}
[data-position='right'] {
--_offset--top: 0px;
--_offset--left: calc(1px * var(--_tooltip--offset));
}
[data-position='bottom'] {
--_offset--top: calc(1px * var(--_tooltip--offset));
--_offset--left: 0px;
}
[data-position='left'] {
--_offset--top: 0px;
--_offset--left: calc(-1px * var(--_tooltip--offset));
}
`;
export const variables = css`
:host {
${VariableHelper.fromProperty('--_tooltip--offset').withGroupModifier('tooltip').withVariableName('offset').withDefaultValue(0).toCss()}
${VariableHelper.fromProperty('--_tooltip--backdrop-color')
.withVariableName('backdrop-color')
.withGroupModifier('tooltip')
.withDefaultValue('rgb(0 0 0 / 0.5)')
.toCss()}
}
`;

View File

@@ -0,0 +1,61 @@
import { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import { Placement } from './models/placement.enum';
import './tooltip.component';
import TooltipComponent from './tooltip.component';
export default {
title: 'Atoms/Tooltip',
component: 'z-tooltip',
render: (args) => html`
<style>
p {
margin: 0;
background: #fff;
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid #ccc;
}
h2 {
margin: 0;
}
:root {
--tooltip--offset: 16;
}
</style>
<h2 aria-describedby="op1" tabindex="0">Tooltip</h2>
<z-tooltip id="op1" ?interactive="${args.interactive}" preferred-placement="${args.preferredPlacement}">
<p>Tooltip Content</p>
</z-tooltip>
`,
args: {
interactive: false,
preferredPlacement: Placement.bottom,
},
argTypes: {
preferredPlacement: {
options: ['top', 'bottom', 'left', 'right'],
control: {
type: 'select',
},
},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<TooltipComponent>;
type Story = StoryObj<TooltipComponent>;
export const Primary: Story = {};
export const Interactive: Story = {
args: {
interactive: true,
},
};

View File

@@ -0,0 +1,87 @@
import { DestroyController } from '@z-elements/_internal/controllers';
import { asyncDirective } from '@z-elements/_internal/directives';
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { takeUntil, tap } from 'rxjs';
import { PositionController } from './controllers/position.controller';
import { VisibilityController } from './controllers/visibility.controller';
import { Placement } from './models/placement.enum';
import { basicStyles, positioning, variables } from './styles';
@customElement('z-tooltip')
export default class TooltipComponent extends LitElement {
/** Private variables */
private readonly _visibilityController = new VisibilityController(this);
private readonly _positionController = new PositionController(this);
private readonly _destroyController = new DestroyController(this);
/** Protected variables */
/** Public variables */
static override get styles() {
return [basicStyles, positioning, variables];
}
@property({ type: Boolean }) public interactive = false;
@property({ type: String, attribute: 'preferred-placement' }) public preferredPlacement: Placement = Placement.top;
@property({ type: Boolean, reflect: true }) public open = false;
@property({ type: String, reflect: true }) public override role = 'tooltip';
@property({ type: Number, reflect: true }) public override tabIndex = -1;
public get targetHost(): HTMLElement | null {
return document.querySelector<HTMLElement>(`[aria-describedby="${this.id}"]`);
}
@query('div[role="dialog"]')
public contentElement: HTMLDivElement | undefined;
/** constructor & lifecycle */
override connectedCallback(): void {
super.connectedCallback();
this._visibilityController.hide();
this._visibilityController.isVisible
.pipe(
tap((isVisible: boolean) => {
this.open = isVisible;
}),
takeUntil(this._destroyController.destroy),
)
.subscribe();
this._visibilityController.show$
.pipe(
tap(() => {
this._positionController.update();
}),
takeUntil(this._destroyController.destroy),
)
.subscribe();
}
/** Public methods */
/** Protected methods */
protected override render(): unknown {
return html` <div
role="dialog"
?open="${asyncDirective(this._visibilityController.isVisible)}"
data-position="${this._positionController.position}"
style="${styleMap({
'--_pos-top': this._positionController.top,
'--_pos-left': this._positionController.left,
})}"
>
<slot></slot>
</div>`;
}
/** Private methods */
// TODO(css): Styling api.
}

View File

@@ -0,0 +1,7 @@
import { tooltip } from './tooltip.component';
describe('tooltip', () => {
it('should work', () => {
expect(tooltip()).toEqual('tooltip');
});
});