@oscarpalmer/oui 0.21.0 → 0.22.0

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