@digital-realty/ix-tooltip 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ /* eslint-disable class-methods-use-this */
2
+ import { html, LitElement, TemplateResult, nothing } from 'lit';
3
+ import { property, state } from 'lit/decorators.js';
4
+ import {
5
+ computePosition,
6
+ Strategy,
7
+ Placement,
8
+ autoUpdate,
9
+ autoPlacement,
10
+ AutoPlacementOptions,
11
+ offset,
12
+ OffsetOptions,
13
+ } from '@floating-ui/dom';
14
+ import './templates/ix-simple-tooltip.js';
15
+
16
+ export class IxTooltip extends LitElement {
17
+ static _placement: Placement | undefined = undefined;
18
+
19
+ static _strategy: Strategy = 'fixed';
20
+
21
+ static _tagName: string = 'ix-tooltip';
22
+
23
+ static _offsetOptions: OffsetOptions = { mainAxis: 10 };
24
+
25
+ static _hidingDuration: number = 500; // milliseconds
26
+
27
+ static _showTooltip: string = 'show-tooltip';
28
+
29
+ static _hideTooltip: string = 'hide-tooltip';
30
+
31
+ // eslint-disable-next-line lit/no-native-attributes
32
+ @property({ type: String, reflect: true }) id: string = 'tooltip';
33
+
34
+ // eslint-disable-next-line lit/no-native-attributes
35
+ @property({ type: String, reflect: true }) role: string = 'tooltip';
36
+
37
+ @property({ type: String, reflect: true }) placement: Placement | undefined =
38
+ IxTooltip._placement;
39
+
40
+ @property({ type: String, reflect: true }) strategy: Strategy =
41
+ IxTooltip._strategy;
42
+
43
+ @state() tooltip: TemplateResult | undefined = undefined;
44
+
45
+ @state() target: HTMLElement | undefined = undefined;
46
+
47
+ @state() hidding: boolean = false;
48
+
49
+ autoPlacementOptions: AutoPlacementOptions | undefined = undefined;
50
+
51
+ offsetOptions: OffsetOptions = IxTooltip._offsetOptions;
52
+
53
+ hidingDuration: number = IxTooltip._hidingDuration;
54
+
55
+ hideTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
56
+
57
+ cleanup: () => void = () => undefined;
58
+
59
+ connectedCallback(): void {
60
+ super.connectedCallback();
61
+ document.addEventListener(IxTooltip._showTooltip, this.show);
62
+ document.addEventListener(IxTooltip._hideTooltip, this.hide);
63
+ document.addEventListener('keydown', this.handleEscapeKey);
64
+ document.addEventListener('focusin', this.handleShow);
65
+ document.addEventListener('focusout', this.handleHide);
66
+ document.addEventListener('mouseover', this.handleShow);
67
+ document.addEventListener('mouseout', this.handleHide);
68
+ }
69
+
70
+ disconnectedCallback(): void {
71
+ super.disconnectedCallback();
72
+ document.removeEventListener(IxTooltip._showTooltip, this.show);
73
+ document.removeEventListener(IxTooltip._hideTooltip, this.hide);
74
+ document.removeEventListener('keydown', this.handleEscapeKey);
75
+ document.removeEventListener('focusin', this.handleShow);
76
+ document.removeEventListener('focusout', this.handleHide);
77
+ document.removeEventListener('mouseover', this.handleShow);
78
+ document.removeEventListener('mouseout', this.handleHide);
79
+ this.hide();
80
+ }
81
+
82
+ handleEscapeKey = (e: KeyboardEvent) => {
83
+ if (this.tooltip && e.key === 'Escape') {
84
+ this.hide();
85
+ }
86
+ };
87
+
88
+ handleShow = (e: Event) => {
89
+ const target = <HTMLElement>e.composedPath()[0]; // I hate the shadowDOM
90
+ const eventTarget = <HTMLElement>e.target;
91
+
92
+ if (
93
+ eventTarget &&
94
+ eventTarget.tagName === IxTooltip._tagName.toUpperCase()
95
+ ) {
96
+ clearTimeout(this.hideTimeout);
97
+ }
98
+
99
+ if (target && target.hasAttribute('data-tooltip')) {
100
+ this.clearHideTimeout();
101
+ document.dispatchEvent(
102
+ new CustomEvent('show-tooltip', {
103
+ detail: {
104
+ target: e.target,
105
+ tooltip: html`<ix-simple-tooltip
106
+ >${target.getAttribute('data-tooltip')}</ix-simple-tooltip
107
+ >`,
108
+ placement: target.getAttribute('data-tooltip-placement'),
109
+ strategy: target.getAttribute('data-tooltip-strategy'),
110
+ },
111
+ }),
112
+ );
113
+ }
114
+ };
115
+
116
+ clearHideTimeout() {
117
+ if (this.hideTimeout) {
118
+ this.hidding = false;
119
+ clearTimeout(this.hideTimeout);
120
+ }
121
+ }
122
+
123
+ handleHide = (e: Event) => {
124
+ if (
125
+ this.tooltip &&
126
+ ((e.type === 'focusout' && this.shadowRoot?.activeElement !== null) ||
127
+ e.type === 'mouseout')
128
+ ) {
129
+ this.clearHideTimeout();
130
+ this.hidding = true;
131
+ this.hideTimeout = setTimeout(() => {
132
+ this.hide();
133
+ }, this.hidingDuration);
134
+ }
135
+ };
136
+
137
+ show = (e: any) => {
138
+ const {
139
+ placement,
140
+ strategy,
141
+ target,
142
+ tooltip,
143
+ autoPlacementOptions,
144
+ offsetOptions,
145
+ hidingDuration,
146
+ } = e.detail;
147
+
148
+ this.target = target;
149
+ this.placement = placement || IxTooltip._placement;
150
+ this.strategy = strategy || IxTooltip._strategy;
151
+ this.tooltip = tooltip;
152
+ this.autoPlacementOptions = autoPlacementOptions;
153
+ this.offsetOptions = offsetOptions || IxTooltip._offsetOptions;
154
+ this.hidingDuration =
155
+ hidingDuration !== undefined ? hidingDuration : IxTooltip._hidingDuration;
156
+ this.cleanup = autoUpdate(target, this, this.updatePosition);
157
+ this.hidding = false;
158
+ };
159
+
160
+ hide = () => {
161
+ this.cleanup();
162
+ this.placement = IxTooltip._placement;
163
+ this.strategy = IxTooltip._strategy;
164
+ this.offsetOptions = IxTooltip._offsetOptions;
165
+ this.hidingDuration = IxTooltip._hidingDuration;
166
+ this.target = undefined;
167
+ this.autoPlacementOptions = undefined;
168
+ this.tooltip = undefined;
169
+ this.hidding = false;
170
+ };
171
+
172
+ getAutoPlacementOptions() {
173
+ const autoPlacementOptions = {
174
+ ...this.autoPlacementOptions,
175
+ };
176
+
177
+ if (this.placement) {
178
+ autoPlacementOptions.allowedPlacements = [this.placement];
179
+ }
180
+
181
+ return autoPlacementOptions;
182
+ }
183
+
184
+ updatePosition = () => {
185
+ if (this.target) {
186
+ computePosition(this.target, this, {
187
+ strategy: this.strategy,
188
+ placement: this.placement,
189
+ middleware: [
190
+ autoPlacement(this.getAutoPlacementOptions()),
191
+ offset(this.offsetOptions),
192
+ ],
193
+ }).then(({ x, y }) => {
194
+ Object.assign(this.style, {
195
+ left: `${x}px`,
196
+ top: `${y}px`,
197
+ position: this.strategy,
198
+ });
199
+ });
200
+ } else {
201
+ this.hide();
202
+ }
203
+ };
204
+
205
+ render() {
206
+ return html`<div
207
+ tabindex="0"
208
+ class="wrapper ${this.hidding ? 'hidding' : ''}"
209
+ style="transition:${this.hidingDuration}ms opacity;"
210
+ >
211
+ ${this.tooltip || nothing}
212
+ </div> `;
213
+ }
214
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { IxTooltip } from './IxTooltip.js';
@@ -0,0 +1,36 @@
1
+ import { css } from 'lit';
2
+ import { IxTooltip } from './IxTooltip.js';
3
+
4
+ export class IxTooltipStyled extends IxTooltip {
5
+ /*
6
+ * Are you about to try and modify the width?
7
+ * Don't try and do it with CSS vars, you won't get very far because this element should not be deeply nested
8
+ * What you should do instead is add this middleware https://floating-ui.com/docs/size
9
+ */
10
+ static override styles = css`
11
+ :host {
12
+ position: fixed;
13
+ top: 0;
14
+ left: 0;
15
+ width: max-content;
16
+ }
17
+
18
+ .wrapper {
19
+ opacity: 1;
20
+
21
+ &:focus {
22
+ outline: 0;
23
+ }
24
+
25
+ &.hidding {
26
+ opacity: 0;
27
+ }
28
+
29
+ &:hover {
30
+ opacity: 1;
31
+ }
32
+ }
33
+ `;
34
+ }
35
+
36
+ window.customElements.define('ix-tooltip', IxTooltipStyled);
@@ -0,0 +1,24 @@
1
+ import { html, css, LitElement } from 'lit';
2
+
3
+ export class IxSimpleTooltip extends LitElement {
4
+ static styles = css`
5
+ :host {
6
+ background: var(
7
+ --ix-simple-tooltip-background-color,
8
+ var(--md-sys-color-on-primary-container, #000)
9
+ );
10
+ border-radius: var(--ix-simple-tooltip-border-radius, 5px);
11
+ color: var(
12
+ --ix-simple-tooltip-text-color,
13
+ var(--md-sys-color-on-primary, #fff)
14
+ );
15
+ padding: var(--ix-simple-tooltip-padding, 0.3rem 0.5rem);
16
+ }
17
+ `;
18
+
19
+ render() {
20
+ return html` <slot></slot>`;
21
+ }
22
+ }
23
+
24
+ window.customElements.define('ix-simple-tooltip', IxSimpleTooltip);
@@ -0,0 +1,75 @@
1
+ // eslint-disable-next-line max-classes-per-file
2
+ import { html, LitElement, css } from 'lit';
3
+ import '../src/ix-tooltip.js';
4
+
5
+ class FancyTooltip extends LitElement {
6
+ static styles = css`
7
+ :host {
8
+ background: white;
9
+ border: 1px solid gray;
10
+ box-shadow: 0 0 6px black;
11
+ display: block;
12
+ padding: 4px;
13
+ }
14
+ `;
15
+
16
+ render() {
17
+ return html`<slot></slot>`;
18
+ }
19
+ }
20
+
21
+ window.customElements.define('fancy-tooltip', FancyTooltip);
22
+
23
+ export class IxTooltipTestHarness extends LitElement {
24
+ protected createRenderRoot(): Element | ShadowRoot {
25
+ return this;
26
+ }
27
+
28
+ handleFancyMouseOver(e: Event) {
29
+ document.dispatchEvent(
30
+ new CustomEvent('show-tooltip', {
31
+ detail: {
32
+ target: e.target,
33
+ tooltip: html`<fancy-tooltip
34
+ ><h2>Some fancy title</h2>
35
+ <p>Some fancy description in a paragraph</p>
36
+ <br /><a href="#">Some fancy link</a></fancy-tooltip
37
+ >`,
38
+ },
39
+ }),
40
+ );
41
+ }
42
+
43
+ render() {
44
+ return html`
45
+ <a
46
+ href="#"
47
+ data-tooltip="tooltip text first element"
48
+ aria-describedby="tooltip"
49
+ >
50
+ first link element
51
+ </a>
52
+ <br />
53
+ <br />
54
+ <a
55
+ href="#"
56
+ data-tooltip="tooltip text second element"
57
+ aria-describedby="tooltip"
58
+ >
59
+ second link element
60
+ </a>
61
+ <br />
62
+ <br />
63
+ <a
64
+ href="#"
65
+ aria-describedby="tooltip"
66
+ @mouseover=${this.handleFancyMouseOver}
67
+ >
68
+ A more complicated tooltip
69
+ </a>
70
+ <ix-tooltip> </ix-tooltip>
71
+ `;
72
+ }
73
+ }
74
+
75
+ window.customElements.define('ix-tooltip-test-harness', IxTooltipTestHarness);
@@ -0,0 +1,109 @@
1
+ import { html } from 'lit';
2
+ import { fixture, expect, aTimeout } from '@open-wc/testing';
3
+ import { sendKeys, sendMouse, resetMouse } from '@web/test-runner-commands';
4
+ import './ix-tooltip-test-harness.js';
5
+ import type { IxTooltipTestHarness } from './ix-tooltip-test-harness.js';
6
+ import { IxTooltip } from '../src/IxTooltip.js';
7
+
8
+ function getMiddleOfElement(element: HTMLElement) {
9
+ const { x, y, width, height } = element.getBoundingClientRect();
10
+
11
+ return {
12
+ x: Math.floor(x + window.scrollX + width / 2),
13
+ y: Math.floor(y + window.scrollY + height / 2),
14
+ };
15
+ }
16
+
17
+ describe('IxTooltip', () => {
18
+ let harnessEl: IxTooltipTestHarness;
19
+ let tooltipEl: IxTooltip;
20
+
21
+ beforeEach(async () => {
22
+ harnessEl = await fixture<IxTooltipTestHarness>(html`
23
+ <ix-tooltip-test-harness></ix-tooltip-test-harness>
24
+ `);
25
+
26
+ tooltipEl = <IxTooltip>harnessEl.querySelector('ix-tooltip');
27
+ });
28
+
29
+ afterEach(async () => {
30
+ await resetMouse();
31
+ });
32
+
33
+ it('renders the test harness element and its DOM', async () => {
34
+ expect(harnessEl).to.exist;
35
+ expect(harnessEl.innerHTML).to.exist;
36
+ });
37
+
38
+ it('renders the tooltip and its shadowDOM', async () => {
39
+ expect(tooltipEl).to.exist;
40
+ expect(tooltipEl.shadowRoot).to.exist;
41
+ });
42
+
43
+ it('shows a tooltip on element tab focus, removes on esc', async () => {
44
+ harnessEl.focus();
45
+ await sendKeys({
46
+ press: 'Tab',
47
+ });
48
+
49
+ let toolTipText = document.activeElement?.getAttribute('data-tooltip');
50
+ expect(toolTipText).to.equal('tooltip text first element');
51
+ expect(
52
+ tooltipEl.shadowRoot?.querySelector('ix-simple-tooltip')?.innerHTML,
53
+ ).to.contain(toolTipText);
54
+
55
+ await sendKeys({
56
+ press: 'Tab',
57
+ });
58
+
59
+ toolTipText = document.activeElement?.getAttribute('data-tooltip');
60
+ expect(toolTipText).to.equal('tooltip text second element');
61
+ expect(
62
+ tooltipEl.shadowRoot?.querySelector('ix-simple-tooltip')?.innerHTML,
63
+ ).to.contain(toolTipText);
64
+
65
+ await sendKeys({
66
+ press: 'Escape',
67
+ });
68
+
69
+ expect(tooltipEl.shadowRoot?.querySelector('ix-simple-tooltip')).to.be.null;
70
+ });
71
+
72
+ it('shows a tooltip on element mouse over, remove on mouse out', async () => {
73
+ const triggerEls = harnessEl.querySelectorAll(
74
+ '[data-tooltip]',
75
+ ) as unknown as HTMLElement[];
76
+
77
+ let { x, y } = getMiddleOfElement(triggerEls[0]);
78
+ await sendMouse({ type: 'move', position: [x, y] });
79
+
80
+ let toolTipText = triggerEls[0].getAttribute('data-tooltip');
81
+ expect(toolTipText).to.equal('tooltip text first element');
82
+ expect(
83
+ tooltipEl.shadowRoot?.querySelector('ix-simple-tooltip')?.innerHTML,
84
+ ).to.contain(toolTipText);
85
+
86
+ ({ x, y } = getMiddleOfElement(triggerEls[1]));
87
+ await sendMouse({ type: 'move', position: [x, y] });
88
+
89
+ toolTipText = triggerEls[1].getAttribute('data-tooltip');
90
+ expect(toolTipText).to.equal('tooltip text second element');
91
+ expect(
92
+ tooltipEl.shadowRoot?.querySelector('ix-simple-tooltip')?.innerHTML,
93
+ ).to.contain(toolTipText);
94
+
95
+ await sendMouse({ type: 'move', position: [500, 500] });
96
+
97
+ expect(tooltipEl.shadowRoot?.querySelector('.wrapper')).to.have.class(
98
+ 'hidding',
99
+ );
100
+
101
+ await aTimeout(500);
102
+
103
+ expect(tooltipEl.shadowRoot?.querySelector('.wrapper')).to.not.have.class(
104
+ 'hidding',
105
+ );
106
+
107
+ expect(tooltipEl.shadowRoot?.querySelector('ix-simple-tooltip')).to.be.null;
108
+ });
109
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2021",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "noEmitOnError": true,
7
+ "lib": ["es2021", "dom", "DOM.Iterable"],
8
+ "strict": true,
9
+ "esModuleInterop": false,
10
+ "allowSyntheticDefaultImports": true,
11
+ "experimentalDecorators": true,
12
+ "importHelpers": true,
13
+ "outDir": "dist",
14
+ "sourceMap": true,
15
+ "inlineSources": true,
16
+ "rootDir": "./",
17
+ "declaration": true,
18
+ "incremental": true,
19
+ "skipLibCheck": true
20
+ },
21
+ "include": ["**/*.ts"]
22
+ }
@@ -0,0 +1,27 @@
1
+ // import { hmrPlugin, presets } from '@open-wc/dev-server-hmr';
2
+
3
+ /** Use Hot Module replacement by adding --hmr to the start command */
4
+ const hmr = process.argv.includes('--hmr');
5
+
6
+ export default /** @type {import('@web/dev-server').DevServerConfig} */ ({
7
+ open: '/demo/',
8
+ /** Use regular watch mode if HMR is not enabled. */
9
+ watch: !hmr,
10
+ /** Resolve bare module imports */
11
+ nodeResolve: {
12
+ exportConditions: ['browser', 'development'],
13
+ },
14
+
15
+ /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
16
+ // esbuildTarget: 'auto'
17
+
18
+ /** Set appIndex to enable SPA routing */
19
+ // appIndex: 'demo/index.html',
20
+
21
+ plugins: [
22
+ /** Use Hot Module Replacement by uncommenting. Requires @open-wc/dev-server-hmr plugin */
23
+ // hmr && hmrPlugin({ exclude: ['**/*/node_modules/**/*'], presets: [presets.lit] }),
24
+ ],
25
+
26
+ // See documentation for all available options
27
+ });
@@ -0,0 +1,41 @@
1
+ import { playwrightLauncher } from '@web/test-runner-playwright';
2
+
3
+ const filteredLogs = ['Running in dev mode', 'Lit is in dev mode'];
4
+
5
+ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({
6
+ /** Test files to run */
7
+ files: 'dist/test/**/*.test.js',
8
+
9
+ /** Resolve bare module imports */
10
+ nodeResolve: {
11
+ exportConditions: ['browser', 'development'],
12
+ },
13
+
14
+ /** Filter out lit dev mode logs */
15
+ filterBrowserLogs(log) {
16
+ for (const arg of log.args) {
17
+ if (typeof arg === 'string' && filteredLogs.some(l => arg.includes(l))) {
18
+ return false;
19
+ }
20
+ }
21
+ return true;
22
+ },
23
+
24
+ /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
25
+ // esbuildTarget: 'auto',
26
+
27
+ /** Amount of browsers to run concurrently */
28
+ // concurrentBrowsers: 2,
29
+
30
+ /** Amount of test files per browser to test concurrently */
31
+ // concurrency: 1,
32
+
33
+ /** Browsers to run tests on */
34
+ browsers: [
35
+ playwrightLauncher({ product: 'chromium' }),
36
+ // playwrightLauncher({ product: 'firefox' }),
37
+ // playwrightLauncher({ product: 'webkit' }),
38
+ ],
39
+
40
+ // See documentation for all available options
41
+ });