@oscarpalmer/oui 0.24.0 → 0.25.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/popover.ts CHANGED
@@ -4,75 +4,133 @@ import type {RemovableEventListener} from '@oscarpalmer/toretto/models';
4
4
  import {
5
5
  ATTRIBUTE_FLOATABLE,
6
6
  createFloatable,
7
- removeFloatable,
8
- type Floatable,
9
- type FloatableOptions,
10
- } from './floatable';
11
- import {createFocusTrap, removeFocusTrap, type FocusTrap} from './focus-trap/embedded';
7
+ getOnBeforeToggleListener,
8
+ type OuiFloatable,
9
+ type OuiFloatableOptions,
10
+ } from './floatable/embedded';
11
+ import {createFocusTrap, type FocusTrap} from './focus-trap/embedded';
12
12
  import {attributable} from './internal/attributable';
13
13
 
14
- class Popover {
15
- anchor: HTMLElement | null;
16
- floatable?: Floatable;
17
- focusTrap?: FocusTrap;
18
- listener: RemovableEventListener;
14
+ // #region Types
19
15
 
20
- constructor(public element: HTMLElement) {
21
- const anchor = element.ownerDocument.querySelector(`[popovertarget="${element.id}"]`);
16
+ class OuiPopover {
17
+ readonly #state: OuiPopoverState;
22
18
 
23
- if (!isHTMLOrSVGElement(anchor)) {
24
- throw new Error(`No anchor found for popover with id '${element.id}'.`);
25
- }
19
+ /**
20
+ * Is the popover open?
21
+ */
22
+ get open(): boolean {
23
+ return this.#state.content.checkVisibility();
24
+ }
26
25
 
27
- if (element.contains(anchor)) {
28
- throw new Error('Popover content cannot contain its anchor.');
29
- }
26
+ constructor(state: OuiPopoverState) {
27
+ this.#state = state;
30
28
 
31
- this.anchor = anchor as HTMLElement;
29
+ this.#state.floatable.update();
30
+ }
32
31
 
33
- this.floatable = createFloatable(this.anchor, element, options, false);
32
+ /**
33
+ * Closes the popover
34
+ */
35
+ hide(): void {
36
+ this.#state.floatable.hide();
37
+ }
34
38
 
35
- this.focusTrap = createFocusTrap(element, {
36
- noescape: true,
37
- });
39
+ /**
40
+ * Opens the popover
41
+ */
42
+ show(): void {
43
+ this.#state.floatable.show();
44
+ }
45
+ }
38
46
 
39
- this.listener = on(element, EVENT_TOGGLE, event => {
40
- if (event.newState === STATE_OPEN) {
41
- ignoreFocus = false;
47
+ type OuiPopoverState = {
48
+ anchor: HTMLElement;
49
+ content: HTMLElement;
50
+ floatable: OuiFloatable;
51
+ focusTrap?: FocusTrap;
52
+ listeners: RemovableEventListener[];
53
+ popover: OuiPopover;
54
+ };
42
55
 
43
- this.floatable?.update();
56
+ // #endregion
44
57
 
45
- element.focus();
46
- } else if (!ignoreFocus) {
47
- this.anchor?.focus();
48
- }
49
- });
58
+ // #region Functions
50
59
 
51
- this.element.setAttribute(ATTRIBUTE_CONTENT, '');
60
+ function destroyPopover(state: OuiPopoverState): void {
61
+ state.floatable.destroy();
62
+ state.focusTrap?.destroy();
52
63
 
53
- this.floatable?.update();
64
+ for (const listener of state.listeners) {
65
+ listener();
54
66
  }
55
67
 
56
- destroy(): void {
57
- this.focusTrap?.destroy();
58
-
59
- removeFloatable(this.element);
60
- removeFocusTrap(this.element);
68
+ state.anchor = undefined as never;
69
+ state.content = undefined as never;
70
+ state.floatable = undefined as never;
71
+ state.focusTrap = undefined as never;
72
+ state.listeners = undefined as never;
73
+ state.popover = undefined as never;
74
+ }
61
75
 
62
- this.element.removeAttribute(ATTRIBUTE_CONTENT);
76
+ /**
77
+ * Get the _OuiPopover_ for an element
78
+ *
79
+ * @param element Element to get _OuiPopover_ for
80
+ * @returns _OuiPopover_ instance
81
+ */
82
+ function getPopover(element: HTMLElement): OuiPopover | undefined {
83
+ return states.get(element)?.popover;
84
+ }
63
85
 
64
- this.anchor = null;
65
- this.element = null as never;
66
- }
86
+ function getState(anchor: HTMLElement, content: HTMLElement): OuiPopoverState {
87
+ const state: OuiPopoverState = {
88
+ anchor,
89
+ content,
90
+ floatable: createFloatable(anchor, content, options, false),
91
+ focusTrap:
92
+ content.getAttribute(ARIA_MODAL) === TRUE
93
+ ? createFocusTrap(content, {
94
+ noescape: true,
95
+ })
96
+ : undefined,
97
+ listeners: [
98
+ getOnBeforeToggleListener(content),
99
+ on(content, EVENT_TOGGLE, event => {
100
+ if (event.newState === STATE_OPEN) {
101
+ ignoreFocus = false;
102
+
103
+ state.floatable?.update();
104
+
105
+ content.setAttribute(ATTRIBUTE_OPEN, '');
106
+
107
+ content.focus();
108
+ } else {
109
+ content.removeAttribute(ATTRIBUTE_OPEN);
110
+
111
+ if (!ignoreFocus) {
112
+ anchor?.focus();
113
+ }
114
+ }
115
+ }),
116
+ ],
117
+ popover: undefined as never,
118
+ };
119
+
120
+ state.popover = new OuiPopover(state);
121
+
122
+ return state;
67
123
  }
68
124
 
69
125
  function onAdd(element: HTMLElement): void {
70
- if (
71
- !instances.has(element) &&
72
- element.getAttribute(ATTRIBUTE) !== POPOVER_HINT &&
73
- !element.hasAttribute(ATTRIBUTE_FLOATABLE)
74
- ) {
75
- instances.set(element, new Popover(element));
126
+ if (states.has(element) || element.hasAttribute(ATTRIBUTE_FLOATABLE)) {
127
+ return;
128
+ }
129
+
130
+ const anchor = element.ownerDocument.querySelector(`[popovertarget="${element.id}"]`);
131
+
132
+ if (isHTMLOrSVGElement(anchor)) {
133
+ states.set(element, getState(anchor, element));
76
134
  }
77
135
  }
78
136
 
@@ -81,32 +139,56 @@ function onPointerdown(): void {
81
139
  }
82
140
 
83
141
  function onRemove(element: HTMLElement): void {
84
- instances.get(element)?.destroy();
142
+ const state = states.get(element);
85
143
 
86
- instances.delete(element);
144
+ if (state == null) {
145
+ return;
146
+ }
147
+
148
+ destroyPopover(state);
149
+
150
+ states.delete(element);
87
151
  }
88
152
 
89
- const ATTRIBUTE = 'popover';
153
+ // #endregion
90
154
 
91
- const ATTRIBUTE_CONTENT = 'oui-popover-content';
155
+ // #region Variables
92
156
 
93
- const EVENT_TOGGLE = 'toggle';
157
+ const ATTRIBUTE = 'oui-popover';
94
158
 
95
- const POPOVER_HINT = 'hint';
159
+ const ARIA_MODAL = 'aria-modal';
160
+
161
+ const ATTRIBUTE_OPEN = `${ATTRIBUTE}-open`;
162
+
163
+ const EVENT_TOGGLE = 'toggle';
96
164
 
97
165
  const STATE_OPEN = 'open';
98
166
 
99
- const instances = new WeakMap<HTMLElement, Popover>();
167
+ const TRUE = 'true';
100
168
 
101
- const options: FloatableOptions = {
169
+ const options: OuiFloatableOptions = {
102
170
  attribute: `${ATTRIBUTE}position`,
103
171
  position: 'below-start',
104
172
  };
105
173
 
174
+ const states = new WeakMap<HTMLElement, OuiPopoverState>();
175
+
106
176
  let ignoreFocus = false;
107
177
 
178
+ // #endregion
179
+
180
+ // #region Initialization
181
+
108
182
  on(document, 'pointerdown', onPointerdown, {
109
183
  capture: true,
110
184
  });
111
185
 
112
186
  attributable(ATTRIBUTE, onAdd, onRemove);
187
+
188
+ // #endregion
189
+
190
+ // #region Exports
191
+
192
+ export {getPopover, type OuiPopover};
193
+
194
+ // #endregion
package/src/js/tooltip.ts CHANGED
@@ -3,97 +3,135 @@ import {createElement} from '@oscarpalmer/toretto/create';
3
3
  import {on} from '@oscarpalmer/toretto/event';
4
4
  import {findAncestor} from '@oscarpalmer/toretto/find';
5
5
  import {isHTMLOrSVGElement} from '@oscarpalmer/toretto/is';
6
+ import {createFloatable, type OuiFloatable, type OuiFloatableOptions} from './floatable/embedded';
6
7
  import {attributable} from './internal/attributable';
7
- import {Floatable, type FloatableOptions} from './floatable';
8
8
 
9
- class Tooltip {
10
- floatable: Floatable;
9
+ // #region Types
11
10
 
12
- timer: number | undefined;
11
+ class OuiTooltip {
12
+ readonly #state: OuiTooltipState;
13
13
 
14
- constructor(
15
- public anchor: HTMLElement,
16
- public content: HTMLElement,
17
- ) {
18
- instances.set(content, this);
19
-
20
- anchor.insertAdjacentElement('afterend', content);
14
+ /**
15
+ * Is the tooltip open?
16
+ */
17
+ get open(): boolean {
18
+ return this.#state.content.checkVisibility();
19
+ }
21
20
 
22
- this.floatable = new Floatable(anchor, content, options, false);
21
+ constructor(state: OuiTooltipState) {
22
+ this.#state = state;
23
23
 
24
- this.floatable.update();
24
+ state.floatable.update();
25
25
  }
26
26
 
27
- destroy(): void {
28
- instances.delete(this.content);
27
+ /**
28
+ * Closes the tooltip
29
+ */
30
+ hide(): void {
31
+ this.#state.floatable.hide();
32
+ }
29
33
 
30
- this.anchor = null as never;
31
- this.content = null as never;
34
+ /**
35
+ * Opens the tooltip
36
+ */
37
+ show(): void {
38
+ this.#state.floatable.show();
32
39
  }
33
40
  }
34
41
 
42
+ type OuiTooltipState = {
43
+ anchor: HTMLElement;
44
+ content: HTMLElement;
45
+ floatable: OuiFloatable;
46
+ timer?: number;
47
+ tooltip: OuiTooltip;
48
+ };
49
+
50
+ // #endregion
51
+
52
+ // #region Functions
53
+
35
54
  function activate(event: Event, focus: boolean): void {
36
55
  const anchor = findAncestor(event, SELECTOR) as HTMLElement | null;
37
56
  const content = findAncestor(event, SELECTOR_CONTENT) as HTMLElement | null;
38
57
 
39
58
  if (anchor == null && content == null) {
40
59
  if (!focused) {
41
- closeTooltip(active);
60
+ closeTooltip(activeState);
42
61
 
43
- active = undefined;
62
+ activeState = undefined;
63
+ activeTooltip = undefined;
44
64
  }
45
65
 
46
66
  return;
47
67
  }
48
68
 
49
- const instance = instances.get((anchor ?? content)!);
69
+ const state = states.get((anchor ?? content)!);
50
70
 
51
- if (instance == null || instance === active) {
71
+ if (state == null || state.tooltip === activeTooltip) {
52
72
  return;
53
73
  }
54
74
 
55
- const shouldDelay = active == null;
75
+ const shouldDelay = activeTooltip == null;
56
76
 
57
- closeTooltip(active);
77
+ closeTooltip(activeState);
78
+
79
+ activeState = state;
80
+ activeTooltip = state.tooltip;
58
81
 
59
- active = instance;
60
82
  focused = focus;
61
83
 
62
- instance.timer = setTimeout(
84
+ state.timer = setTimeout(
63
85
  () => {
64
- instance.content.showPopover({
65
- source: instance.anchor as HTMLElement,
86
+ state.content.showPopover({
87
+ source: state.anchor as HTMLElement,
66
88
  });
67
89
 
68
90
  setTimeout(() => {
69
- instance.floatable.update();
91
+ state.floatable.update();
70
92
 
71
- instance.content.setAttribute(ATTRIBUTE_CONTENT_ACTIVE, '');
93
+ state.content.setAttribute(ATTRIBUTE_CONTENT_OPEN, '');
72
94
  });
73
95
  },
74
96
  shouldDelay ? delay : 0,
75
97
  );
76
98
  }
77
99
 
78
- function closeTooltip(instance?: Tooltip): void {
79
- if (instance == null) {
100
+ function closeTooltip(state?: OuiTooltipState): void {
101
+ if (state == null) {
80
102
  return;
81
103
  }
82
104
 
83
- if (instance.timer != null) {
84
- clearTimeout(instance.timer);
105
+ if (state.timer != null) {
106
+ clearTimeout(state.timer);
85
107
  }
86
108
 
87
- instance.content?.hidePopover();
88
- instance.content?.removeAttribute(ATTRIBUTE_CONTENT_ACTIVE);
109
+ state.content?.hidePopover();
110
+ state.content?.removeAttribute(ATTRIBUTE_CONTENT_OPEN);
89
111
  }
90
112
 
91
113
  function createTooltip(anchor: HTMLElement): void {
92
114
  const content = getContent(anchor);
93
115
 
94
- if (content != null) {
95
- instances.set(anchor, new Tooltip(anchor, content));
116
+ if (content == null) {
117
+ return;
96
118
  }
119
+
120
+ anchor.insertAdjacentElement('afterend', content);
121
+
122
+ states.set(anchor, getState(anchor, content));
123
+ }
124
+
125
+ function destroyTooltip(state: OuiTooltipState): void {
126
+ clearTimeout(state.timer);
127
+
128
+ state.content.remove();
129
+ state.floatable.destroy();
130
+
131
+ state.anchor = undefined as never;
132
+ state.content = undefined as never;
133
+ state.floatable = undefined as never;
134
+ state.tooltip = undefined as never;
97
135
  }
98
136
 
99
137
  function findElements(attribute: string | null): HTMLElement[] {
@@ -169,8 +207,25 @@ function getElements(anchor: HTMLElement): HTMLElement[] | undefined {
169
207
  return [paragraph];
170
208
  }
171
209
 
210
+ function getState(anchor: HTMLElement, content: HTMLElement): OuiTooltipState {
211
+ const state: OuiTooltipState = {
212
+ anchor,
213
+ content,
214
+ floatable: createFloatable(anchor, content, options, false),
215
+ tooltip: null as never,
216
+ };
217
+
218
+ state.tooltip = new OuiTooltip(state);
219
+
220
+ return state;
221
+ }
222
+
223
+ function getTooltip(element: HTMLElement): OuiTooltip | undefined {
224
+ return states.get(element)?.tooltip;
225
+ }
226
+
172
227
  function onAdd(element: HTMLElement): void {
173
- if (!instances.has(element)) {
228
+ if (!states.has(element)) {
174
229
  createTooltip(element);
175
230
  }
176
231
  }
@@ -180,7 +235,7 @@ function onFocusin(event: Event): void {
180
235
  }
181
236
 
182
237
  function onFocusout(): void {
183
- if (active == null) {
238
+ if (activeTooltip == null) {
184
239
  return;
185
240
  }
186
241
 
@@ -190,16 +245,17 @@ function onFocusout(): void {
190
245
 
191
246
  focusTimer = setTimeout(() => {
192
247
  if (
193
- active == null ||
248
+ activeTooltip == null ||
194
249
  document.activeElement == null ||
195
250
  findAncestor(document.activeElement, SELECTOR) != null
196
251
  ) {
197
252
  return;
198
253
  }
199
254
 
200
- closeTooltip(active);
255
+ closeTooltip(activeState);
201
256
 
202
- active = undefined;
257
+ activeState = undefined;
258
+ activeTooltip = undefined;
203
259
  });
204
260
  }
205
261
 
@@ -208,9 +264,15 @@ function onPointermove(event: Event): void {
208
264
  }
209
265
 
210
266
  function onRemove(element: HTMLElement): void {
211
- instances.get(element)?.destroy();
267
+ const state = states.get(element);
212
268
 
213
- instances.delete(element);
269
+ if (state == null) {
270
+ return;
271
+ }
272
+
273
+ destroyTooltip(state);
274
+
275
+ states.delete(element);
214
276
  }
215
277
 
216
278
  /**
@@ -219,12 +281,16 @@ function onRemove(element: HTMLElement): void {
219
281
  * _(Default delay is half a second, or 500 milliseconds)_
220
282
  * @param value Delay in milliseconds
221
283
  */
222
- export function setTooltipDelay(value: number): void {
284
+ function setTooltipDelay(value: number): void {
223
285
  if (typeof value === 'number' && !Number.isNaN(value) && value >= 0) {
224
286
  delay = value;
225
287
  }
226
288
  }
227
289
 
290
+ // #endregion
291
+
292
+ // #region Variables
293
+
228
294
  const ARIA_DESCRIBEDBY = 'aria-describedby';
229
295
 
230
296
  const ARIA_DESCRIPTION = 'aria-description';
@@ -241,15 +307,15 @@ const ATTRIBUTE = 'oui-tooltip';
241
307
 
242
308
  const ATTRIBUTE_CONTENT = `${ATTRIBUTE}-content`;
243
309
 
244
- const ATTRIBUTE_CONTENT_ACTIVE = `${ATTRIBUTE_CONTENT}-active`;
310
+ const ATTRIBUTE_CONTENT_OPEN = `${ATTRIBUTE_CONTENT}-open`;
245
311
 
246
312
  const SELECTOR = `[${ATTRIBUTE}]`;
247
313
 
248
314
  const SELECTOR_CONTENT = `[${ATTRIBUTE_CONTENT}]`;
249
315
 
250
- const instances = new WeakMap<HTMLElement, Tooltip>();
316
+ const states = new WeakMap<HTMLElement, OuiTooltipState>();
251
317
 
252
- const options: FloatableOptions = {
318
+ const options: OuiFloatableOptions = {
253
319
  attribute: `${ATTRIBUTE}-position`,
254
320
  position: 'above',
255
321
  };
@@ -258,12 +324,26 @@ let delay = 500;
258
324
 
259
325
  let focused = false;
260
326
 
261
- let active: Tooltip | undefined;
327
+ let activeState: OuiTooltipState | undefined;
328
+
329
+ let activeTooltip: OuiTooltip | undefined;
262
330
 
263
331
  let focusTimer: number | undefined;
264
332
 
333
+ // #endregion
334
+
335
+ // #region Initialization
336
+
265
337
  on(document, 'focusin', onFocusin);
266
338
  on(document, 'focusout', onFocusout);
267
339
  on(document, 'pointermove', onPointermove);
268
340
 
269
341
  attributable(ATTRIBUTE, onAdd, onRemove);
342
+
343
+ // #endregion
344
+
345
+ // #region Exports
346
+
347
+ export {getTooltip, setTooltipDelay, type OuiTooltip};
348
+
349
+ // #endregion
@@ -1,30 +0,0 @@
1
-
2
- :where([oui-popover-content]) {
3
- box-shadow: inset 0 1px 0 0 transparent, 0 0 0 1px rgba(0, 0, 0, 0.05), 0 1px 2px -0.5px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.05), 0 4px 8px -2px rgba(0, 0, 0, 0.05), 0 8px 16px -4px rgba(0, 0, 0, 0.05), 0 16px 32px -8px rgba(0, 0, 0, 0.05), 0 32px 64px -16px rgba(0, 0, 0, 0.05), 0 64px 128px -32px rgba(0, 0, 0, 0.05);
4
- --margin-bottom: 0.5em;
5
- --margin-left: 0.5em;
6
- --margin-right: 0.5em;
7
- --margin-top: 0.5em;
8
- inset-block: var(--margin-top) var(--margin-bottom);
9
- inset-inline: var(--margin-left) var(--margin-right);
10
- }
11
- :where([oui-popover-content])[oui-position$=bottom] {
12
- --margin-bottom: 0;
13
- }
14
- :where([oui-popover-content])[oui-position$=end] {
15
- --margin-right: 0;
16
- }
17
- :where([oui-popover-content])[oui-position$=start] {
18
- --margin-left: 0;
19
- }
20
- :where([oui-popover-content])[oui-position$=top] {
21
- --margin-top: 0;
22
- }
23
- :where([oui-popover-content]) {
24
- padding: 1rem;
25
- background-color: var(--oui-absolute);
26
- border: none;
27
- border-radius: 0.25rem;
28
- }
29
-
30
-
@@ -1,59 +0,0 @@
1
- //#region src/js/floatable.d.ts
2
- declare class Floatable {
3
- #private;
4
- anchor: HTMLElement;
5
- content: HTMLElement;
6
- options: FloatableOptions;
7
- /**
8
- * Is the _Floatable_ open?
9
- */
10
- get open(): boolean;
11
- constructor(anchor: HTMLElement, content: HTMLElement, options: FloatableOptions, standalone: boolean);
12
- /**
13
- * Destroy the floatable, removing it from the DOM and cleaning up references
14
- */
15
- destroy(): void;
16
- /**
17
- * Hide the floatable
18
- */
19
- hide(): void;
20
- /**
21
- * Show the floatable
22
- */
23
- show(): void;
24
- /**
25
- * Update the position of the floatable, with an optional new position
26
- *
27
- * @param position Optional new position
28
- */
29
- update(position?: FloatablePosition): void;
30
- }
31
- type FloatableOptions = {
32
- attribute?: string;
33
- position: FloatablePosition;
34
- };
35
- type FloatablePosition = 'above' | 'above-end' | 'above-start' | 'below' | 'below-end' | 'below-start' | 'end' | 'end-bottom' | 'end-top' | 'start' | 'start-bottom' | 'start-top';
36
- type GetFloatableOptions = {
37
- /**
38
- * Open the _Floatable_ when created? _(defaults to `true`)_
39
- */
40
- open?: boolean;
41
- /**
42
- * Position of the _Floatable_ _(defaults to `below`)_
43
- */
44
- position?: FloatablePosition;
45
- };
46
- declare function createFloatable(anchor: HTMLElement, content: HTMLElement, options: FloatableOptions, standalone: boolean): Floatable;
47
- /**
48
- * Create a _Floatable_ for an anchor and content element
49
- *
50
- * @param anchor Anchor element
51
- * @param content Content element
52
- * @param options _Floatable_ options
53
- * @returns _Floatable_ instance
54
- */
55
- declare function getFloatable(anchor: HTMLElement | SVGElement, content: HTMLElement | SVGElement, options?: GetFloatableOptions): Floatable;
56
- declare function removeFloatable(content: HTMLElement): void;
57
- declare const ATTRIBUTE_FLOATABLE = "oui-floatable";
58
- //#endregion
59
- export { ATTRIBUTE_FLOATABLE, Floatable, FloatableOptions, FloatablePosition, createFloatable, getFloatable, removeFloatable };
@@ -1,10 +0,0 @@
1
- @use 'mixins.scss' as mixins;
2
-
3
- :where([oui-popover-content]) {
4
- @include mixins.floatable;
5
-
6
- padding: 1rem;
7
- background-color: var(--oui-absolute);
8
- border: none;
9
- border-radius: 0.25rem;
10
- }