@oscarpalmer/oui 0.22.1 → 0.23.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.
package/src/js/tooltip.ts CHANGED
@@ -1,128 +1,98 @@
1
1
  import {isNullableOrWhitespace} from '@oscarpalmer/atoms/is';
2
+ import {createElement} from '@oscarpalmer/toretto/create';
2
3
  import {on} from '@oscarpalmer/toretto/event';
3
4
  import {findAncestor} from '@oscarpalmer/toretto/find';
4
5
  import {attributable} from './internal/attributable';
5
- import {Floatable} from './internal/floatable';
6
+ import {Floatable, type FloatableOptions} from './internal/floatable';
6
7
 
7
8
  class Tooltip {
8
- elements: HTMLElement[];
9
-
10
9
  floatable: Floatable;
11
10
 
12
- constructor(anchor: HTMLElement, elements: HTMLElement[]) {
13
- const content = getContent();
14
-
15
- this.elements = elements;
16
-
17
- this.floatable = new Floatable({
18
- anchor,
19
- content,
20
- defaultPosition: 'above',
21
- interactive: false,
22
- positionAttribute: ATTRIBUTE_POSITION,
23
- reusable: true,
24
- onAfter: (active: boolean): void => {
25
- if (active) {
26
- content.innerHTML = '';
27
-
28
- content.append(...this.elements);
29
- }
11
+ timer: number | undefined;
30
12
 
31
- if (active || TOOLTIPS_ACTIVE.size > 0) {
32
- return;
33
- }
13
+ constructor(
14
+ public anchor: HTMLElement,
15
+ public content: HTMLElement,
16
+ ) {
17
+ instances.set(content, this);
34
18
 
35
- content.removeAttribute(ATTRIBUTE_CONTENT_ACTIVE);
19
+ anchor.insertAdjacentElement('afterend', content);
36
20
 
37
- contentTimer = setTimeout(() => {
38
- content.innerHTML = '';
21
+ this.floatable = new Floatable(anchor, content, options);
39
22
 
40
- content.remove();
41
- }, delayClose);
42
- },
43
- });
23
+ this.floatable.update();
44
24
  }
45
25
 
46
26
  destroy(): void {
47
- const {floatable} = this;
27
+ instances.delete(this.content);
48
28
 
49
- TOOLTIPS_ACTIVE.delete(this);
50
- TOOLTIPS_PENDING.delete(this);
29
+ this.anchor = null as never;
30
+ this.content = null as never;
31
+ }
32
+ }
51
33
 
52
- TOOLTIPS_ALL.delete(floatable.options.anchor);
53
- TOOLTIPS_ALL.delete(floatable.options.content);
34
+ function activate(event: Event, focus: boolean): void {
35
+ const anchor = findAncestor(event, SELECTOR) as HTMLElement | null;
36
+ const content = findAncestor(event, SELECTOR_CONTENT) as HTMLElement | null;
54
37
 
55
- floatable.destroy();
38
+ if (anchor == null && content == null) {
39
+ if (!focused) {
40
+ closeTooltip(active);
56
41
 
57
- this.elements = [];
58
- this.floatable = undefined as never;
59
- }
60
- }
42
+ active = undefined;
43
+ }
61
44
 
62
- function addTooltip(anchor: HTMLElement): void {
63
- if (TOOLTIPS_ALL.has(anchor)) {
64
45
  return;
65
46
  }
66
47
 
67
- const elements = getElements(anchor);
48
+ const instance = instances.get((anchor ?? content)!);
68
49
 
69
- if (elements != null) {
70
- TOOLTIPS_ALL.set(anchor, new Tooltip(anchor, elements));
50
+ if (instance == null || instance === active) {
51
+ return;
71
52
  }
72
- }
73
53
 
74
- function closeTooltips(current?: Tooltip): void {
75
- const pending = [...TOOLTIPS_PENDING];
54
+ const shouldDelay = active == null;
76
55
 
77
- for (const item of pending) {
78
- if (item === current) {
79
- continue;
80
- }
81
-
82
- TOOLTIPS_PENDING.delete(item);
83
- }
56
+ closeTooltip(active);
84
57
 
85
- const active = [...TOOLTIPS_ACTIVE];
58
+ active = instance;
59
+ focused = focus;
86
60
 
87
- for (const item of active) {
88
- if (item === current) {
89
- continue;
90
- }
61
+ instance.timer = setTimeout(
62
+ () => {
63
+ instance.content.showPopover({
64
+ source: instance.anchor,
65
+ });
91
66
 
92
- TOOLTIPS_ACTIVE.delete(item);
67
+ setTimeout(() => {
68
+ instance.floatable.update();
93
69
 
94
- item.floatable.toggle(false);
95
- }
70
+ instance.content.setAttribute(ATTRIBUTE_CONTENT_ACTIVE, '');
71
+ });
72
+ },
73
+ shouldDelay ? delay : 0,
74
+ );
96
75
  }
97
76
 
98
- function getElements(anchor: HTMLElement): HTMLElement[] | undefined {
99
- let elements = findElements(anchor.getAttribute(ARIA_LABELLEDBY));
100
-
101
- if (elements.length > 0) {
102
- return elements.map(element => element.cloneNode(true)) as HTMLElement[];
77
+ function closeTooltip(instance?: Tooltip): void {
78
+ if (instance == null) {
79
+ return;
103
80
  }
104
81
 
105
- elements = findElements(anchor.getAttribute(ARIA_DESCRIBEDBY));
106
-
107
- if (elements.length > 0) {
108
- return elements.map(element => element.cloneNode(true)) as HTMLElement[];
82
+ if (instance.timer != null) {
83
+ clearTimeout(instance.timer);
109
84
  }
110
85
 
111
- let content = anchor.getAttribute(ARIA_LABEL);
86
+ instance.content?.hidePopover();
87
+ instance.content?.removeAttribute(ATTRIBUTE_CONTENT_ACTIVE);
88
+ }
112
89
 
113
- if (isNullableOrWhitespace(content)) {
114
- content = anchor.getAttribute(ARIA_DESCRIPTION);
115
- }
90
+ function createTooltip(anchor: HTMLElement): void {
91
+ const content = getContent(anchor);
116
92
 
117
- if (isNullableOrWhitespace(content)) {
118
- return;
93
+ if (content != null) {
94
+ instances.set(anchor, new Tooltip(anchor, content));
119
95
  }
120
-
121
- const paragraph = document.createElement('p');
122
-
123
- paragraph.textContent = content;
124
-
125
- return [paragraph];
126
96
  }
127
97
 
128
98
  function findElements(attribute: string | null): HTMLElement[] {
@@ -148,109 +118,105 @@ function findElements(attribute: string | null): HTMLElement[] {
148
118
  return elements;
149
119
  }
150
120
 
151
- function getContent(): HTMLElement {
152
- if (contentElement != null) {
153
- return contentElement;
121
+ function getContent(anchor: HTMLElement): HTMLElement | undefined {
122
+ const elements = getElements(anchor);
123
+
124
+ if (elements == null) {
125
+ return;
154
126
  }
155
127
 
156
- contentElement = document.createElement('div');
128
+ const content = createElement(CONTENT_TAGNAME, {
129
+ popover: 'hint',
130
+ role: 'tooltip',
131
+ tabIndex: -1,
132
+ });
157
133
 
158
- contentElement.setAttribute(ATTRIBUTE_CONTENT, '');
134
+ content.setAttribute(ATTRIBUTE_CONTENT, '');
159
135
 
160
- contentElement.role = 'tooltip';
161
- contentElement.tabIndex = -1;
136
+ content.append(...elements);
162
137
 
163
- return contentElement;
138
+ return content;
164
139
  }
165
140
 
166
- function onActivate(event: Event): void {
167
- onToggle(event, true);
168
- }
141
+ function getElements(anchor: HTMLElement): HTMLElement[] | undefined {
142
+ let elements = findElements(anchor.getAttribute(ARIA_LABELLEDBY));
169
143
 
170
- function onChange(): void {
171
- if (TOOLTIPS_ACTIVE.size === 0) {
172
- return;
144
+ if (elements.length > 0) {
145
+ return elements.map(element => element.cloneNode(true)) as HTMLElement[];
173
146
  }
174
- }
175
147
 
176
- function onClick(event: Event): void {
177
- if (findAncestor(event, SELECTOR_CONTENT) != null) {
178
- event.stopPropagation();
148
+ elements = findElements(anchor.getAttribute(ARIA_DESCRIBEDBY));
149
+
150
+ if (elements.length > 0) {
151
+ return elements.map(element => element.cloneNode(true)) as HTMLElement[];
179
152
  }
180
- }
181
153
 
182
- function onDeactivate(event: Event): void {
183
- onToggle(event, false);
184
- }
154
+ let content = anchor.getAttribute(ARIA_LABEL);
185
155
 
186
- function onToggle(event: Event, activate: boolean): void {
187
- if (toggle != null) {
188
- cancelAnimationFrame(toggle);
156
+ if (isNullableOrWhitespace(content)) {
157
+ content = anchor.getAttribute(ARIA_DESCRIPTION);
189
158
  }
190
159
 
191
- if (findAncestor(event, SELECTOR_CONTENT) != null) {
192
- if (contentTimer != null) {
193
- clearTimeout(contentTimer);
194
- }
195
-
160
+ if (isNullableOrWhitespace(content)) {
196
161
  return;
197
162
  }
198
163
 
199
- toggle = requestAnimationFrame(() => {
200
- toggle = undefined;
201
-
202
- const element = findAncestor(event, SELECTOR) as HTMLElement;
203
-
204
- const tooltip = TOOLTIPS_ALL.get(element);
205
-
206
- if (TOOLTIPS_ACTIVE.size > 0 || TOOLTIPS_PENDING.size > 0) {
207
- closeTooltips(tooltip);
208
- }
209
-
210
- if (tooltip == null || tooltip.floatable.active === activate) {
211
- return;
212
- }
164
+ const paragraph = document.createElement('p');
213
165
 
214
- if (activate) {
215
- clearTimeout(contentTimer);
166
+ paragraph.textContent = content;
216
167
 
217
- TOOLTIPS_PENDING.add(tooltip);
168
+ return [paragraph];
169
+ }
218
170
 
219
- contentTimer = setTimeout(() => {
220
- TOOLTIPS_ACTIVE.add(tooltip);
221
- TOOLTIPS_PENDING.delete(tooltip);
171
+ function onAdd(element: HTMLElement): void {
172
+ if (!instances.has(element)) {
173
+ createTooltip(element);
174
+ }
175
+ }
222
176
 
223
- tooltip.floatable.options.content.style.positionAnchor =
224
- tooltip.floatable.options.anchor.style.anchorName;
177
+ function onFocusin(event: Event): void {
178
+ activate(event, true);
179
+ }
225
180
 
226
- tooltip.floatable.toggle(true);
181
+ function onFocusout(): void {
182
+ if (active == null) {
183
+ return;
184
+ }
227
185
 
228
- setTimeout(() => {
229
- tooltip.floatable.options.content.setAttribute(ATTRIBUTE_CONTENT_ACTIVE, '');
230
- });
231
- }, delayOpen);
186
+ if (focusTimer != null) {
187
+ clearTimeout(focusTimer);
188
+ }
232
189
 
190
+ focusTimer = setTimeout(() => {
191
+ if (
192
+ active == null ||
193
+ document.activeElement == null ||
194
+ findAncestor(document.activeElement, SELECTOR) != null
195
+ ) {
233
196
  return;
234
197
  }
235
198
 
236
- TOOLTIPS_ACTIVE.delete(tooltip);
199
+ closeTooltip(active);
237
200
 
238
- tooltip.floatable.toggle(false);
201
+ active = undefined;
239
202
  });
240
203
  }
241
204
 
242
- function removeTooltip(element: HTMLElement): void {
243
- if (!element.hasAttribute(ATTRIBUTE_CONTENT)) {
244
- TOOLTIPS_ALL.get(element)?.destroy();
245
- }
205
+ function onPointermove(event: Event): void {
206
+ activate(event, false);
246
207
  }
247
208
 
248
- function setTooltipDelay(open?: number, close?: number): void {
249
- delayClose = typeof close === 'number' && close >= 0 ? close : delayClose;
250
- delayOpen = typeof open === 'number' && open >= 0 ? open : delayOpen;
209
+ function onRemove(element: HTMLElement): void {
210
+ instances.get(element)?.destroy();
211
+
212
+ instances.delete(element);
251
213
  }
252
214
 
253
- //
215
+ export function setTooltipDelay(value: number): void {
216
+ if (typeof value === 'number' && !Number.isNaN(value) && value >= 0) {
217
+ delay = value;
218
+ }
219
+ }
254
220
 
255
221
  const ARIA_DESCRIBEDBY = 'aria-describedby';
256
222
 
@@ -260,48 +226,37 @@ const ARIA_LABEL = 'aria-label';
260
226
 
261
227
  const ARIA_LABELLEDBY = 'aria-labelledby';
262
228
 
229
+ const CONTENT_TAGNAME = 'div';
230
+
231
+ const EXPRESSION_WHITESPACE = /\s+/;
232
+
263
233
  const ATTRIBUTE = 'oui-tooltip';
264
234
 
265
235
  const ATTRIBUTE_CONTENT = `${ATTRIBUTE}-content`;
266
236
 
267
237
  const ATTRIBUTE_CONTENT_ACTIVE = `${ATTRIBUTE_CONTENT}-active`;
268
238
 
269
- const ATTRIBUTE_POSITION = `${ATTRIBUTE}-position`;
270
-
271
239
  const SELECTOR = `[${ATTRIBUTE}]`;
272
240
 
273
241
  const SELECTOR_CONTENT = `[${ATTRIBUTE_CONTENT}]`;
274
242
 
275
- const EXPRESSION_WHITESPACE = /\s+/;
276
-
277
- const TOOLTIPS_ACTIVE: Set<Tooltip> = new Set();
278
-
279
- const TOOLTIPS_ALL: WeakMap<HTMLElement, Tooltip> = new WeakMap();
280
-
281
- const TOOLTIPS_PENDING: Set<Tooltip> = new Set();
282
-
283
- let contentElement: HTMLElement | undefined;
284
-
285
- let contentTimer: number | undefined;
286
-
287
- let delayClose = 250;
243
+ const instances = new WeakMap<HTMLElement, Tooltip>();
288
244
 
289
- let delayOpen = 500;
245
+ const options: FloatableOptions = {
246
+ attribute: 'oui-tooltip-position',
247
+ position: 'above',
248
+ };
290
249
 
291
- let toggle: DOMHighResTimeStamp | undefined;
250
+ let delay = 500;
292
251
 
293
- //
252
+ let focused = false;
294
253
 
295
- attributable(ATTRIBUTE, addTooltip, removeTooltip);
254
+ let active: Tooltip | undefined;
296
255
 
297
- on(document, 'click', onClick, {capture: true});
298
- on(document, 'focusin', onActivate);
299
- on(document, 'focusout', onDeactivate);
300
- on(document, 'mousemove', onActivate);
301
- on(window, 'resize', onChange);
302
- on(document, 'scroll', onChange);
303
- on(document, 'touchstart', onActivate);
256
+ let focusTimer: number | undefined;
304
257
 
305
- //
258
+ on(document, 'focusin', onFocusin);
259
+ on(document, 'focusout', onFocusout);
260
+ on(document, 'pointermove', onPointermove);
306
261
 
307
- export {setTooltipDelay};
262
+ attributable(ATTRIBUTE, onAdd, onRemove);