Create Libraries and basic tooltip
This commit is contained in:
1
libs/tooltip/src/index.ts
Normal file
1
libs/tooltip/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/tooltip.component';
|
||||
134
libs/tooltip/src/lib/controllers/position.controller.ts
Normal file
134
libs/tooltip/src/lib/controllers/position.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
125
libs/tooltip/src/lib/controllers/visibility.controller.ts
Normal file
125
libs/tooltip/src/lib/controllers/visibility.controller.ts
Normal 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 */
|
||||
}
|
||||
6
libs/tooltip/src/lib/models/placement.enum.ts
Normal file
6
libs/tooltip/src/lib/models/placement.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const enum Placement {
|
||||
top = 'top',
|
||||
bottom = 'bottom',
|
||||
left = 'left',
|
||||
right = 'right',
|
||||
}
|
||||
59
libs/tooltip/src/lib/styles/index.ts
Normal file
59
libs/tooltip/src/lib/styles/index.ts
Normal 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()}
|
||||
}
|
||||
`;
|
||||
61
libs/tooltip/src/lib/tooltip.component.stories.ts
Normal file
61
libs/tooltip/src/lib/tooltip.component.stories.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
87
libs/tooltip/src/lib/tooltip.component.ts
Normal file
87
libs/tooltip/src/lib/tooltip.component.ts
Normal 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.
|
||||
}
|
||||
7
libs/tooltip/src/lib/tooltip.spec.ts
Normal file
7
libs/tooltip/src/lib/tooltip.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { tooltip } from './tooltip.component';
|
||||
|
||||
describe('tooltip', () => {
|
||||
it('should work', () => {
|
||||
expect(tooltip()).toEqual('tooltip');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user