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,42 @@
{
"extends": [
"../../.eslintrc.json"
],
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": [
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": "error"
}
}
]
}

View File

@@ -0,0 +1,35 @@
import type { StorybookConfig } from '@storybook/web-components-vite';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { mergeConfig } from 'vite';
const config: StorybookConfig = {
stories: [
'../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'
],
addons: ['@storybook/addon-essentials' , '@storybook/addon-interactions' ],
framework: {
name: '@storybook/web-components-vite',
options: {
},
},
viteFinal: async (config) =>
mergeConfig(config, {
plugins: [nxViteTsPaths()],
}),
};
export default config;
// To customize your Vite configuration you can use the viteFinal field.
// Check https://storybook.js.org/docs/react/builders/vite#configuration
// and https://nx.dev/packages/storybook/documents/custom-builder-configs

View File

11
libs/tooltip/README.md Normal file
View File

@@ -0,0 +1,11 @@
# tooltip
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build tooltip` to build the library.
## Running unit tests
Run `nx test tooltip` to execute the unit tests via [Jest](https://jestjs.io).

10
libs/tooltip/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "@z-elements/tooltip",
"version": "0.0.1",
"dependencies": {
"tslib": "^2.3.0"
},
"type": "commonjs",
"main": "./src/index.js",
"typings": "./src/index.d.ts"
}

78
libs/tooltip/project.json Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "tooltip",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/tooltip/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": [
"{options.outputPath}"
],
"options": {
"outputPath": "dist/libs/tooltip",
"main": "libs/tooltip/src/index.ts",
"tsConfig": "libs/tooltip/tsconfig.lib.json",
"assets": [
"libs/tooltip/*.md"
]
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"libs/tooltip/**/*.ts",
"libs/tooltip/package.json"
]
}
},
"test": {
"executor": "@nx/vite:test",
"outputs": [
"coverage/libs/tooltip"
],
"options": {
"passWithNoTests": true,
"reportsDirectory": "../../coverage/libs/tooltip"
}
},
"storybook": {
"executor": "@nx/storybook:storybook",
"options": {
"port": 4400,
"configDir": "libs/tooltip/.storybook"
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@nx/storybook:build",
"outputs": [
"{options.outputDir}"
],
"options": {
"outputDir": "dist/storybook/tooltip",
"configDir": "libs/tooltip/.storybook"
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"test-storybook": {
"executor": "nx:run-commands",
"options": {
"command": "test-storybook -c libs/tooltip/.storybook --url=http://localhost:4400"
}
}
},
"tags": []
}

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');
});
});

View File

@@ -0,0 +1,28 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"types": [
"vitest"
]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.storybook.json"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": [
"node"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts",
"**/*.stories.ts",
"**/*.stories.js"
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"]
},
"include": [
"vite.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts"
],
"include": [
"src/**/*.stories.ts",
"src/**/*.stories.js",
"src/**/*.stories.jsx",
"src/**/*.stories.tsx",
"src/**/*.stories.mdx",
".storybook/*.js",
".storybook/*.ts"
]
}

View File

@@ -0,0 +1,24 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
cacheDir: '../../node_modules/.vite/tooltip',
plugins: [nxViteTsPaths()],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
globals: true,
cache: {
dir: '../../node_modules/.vitest',
},
environment: 'node',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
},
});