@oscarpalmer/oui 0.22.1 → 0.23.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.
@@ -1,110 +1,31 @@
1
- import {on} from '@oscarpalmer/toretto/event';
2
- import {findAncestor} from '@oscarpalmer/toretto/find';
3
-
4
1
  export class Floatable {
5
- active = false;
6
-
7
- disabled = false;
8
-
9
- frame: DOMHighResTimeStamp | undefined;
10
-
11
- ignoreFocus = false;
2
+ constructor(
3
+ public anchor: HTMLElement,
4
+ public content: HTMLElement,
5
+ public options: FloatableOptions,
6
+ ) {
7
+ const anchorName = getAnchorName(anchor);
12
8
 
13
- constructor(readonly options: Options) {
14
- initialize(this);
9
+ anchor.style.anchorName = anchorName;
10
+ content.style.positionAnchor = anchorName;
15
11
  }
16
12
 
17
13
  destroy(): void {
18
- stop(this);
19
-
20
- const state = getState();
21
-
22
- state?.active.delete(this);
23
- state?.mapped.delete(this.options.anchor);
24
- state?.mapped.delete(this.options.content);
25
-
26
- this.options.anchor = undefined as never;
27
- this.options.content = undefined as never;
28
- }
29
-
30
- disable(): void {
31
- if (this.disabled) {
32
- return;
33
- }
34
-
35
- this.disabled = true;
36
-
37
- stop(this);
38
-
39
- getState()?.active.delete(this);
40
- }
41
-
42
- enable(): void {
43
- if (!this.disabled) {
44
- return;
45
- }
46
-
47
- this.disabled = false;
48
-
49
- initialize(this);
14
+ this.anchor = null as never;
15
+ this.content = null as never;
50
16
  }
51
17
 
52
- toggle(active: boolean, ignoreFocus?: boolean): void {
53
- if (this.disabled || this.active === active) {
54
- return;
55
- }
56
-
57
- const state = getState();
58
-
59
- if (state == null) {
60
- return;
61
- }
62
-
63
- this.ignoreFocus = !active && (ignoreFocus ?? false);
64
-
65
- stop(this);
66
-
67
- if (active) {
68
- state.active.add(this);
69
- } else {
70
- state.active.delete(this);
71
- }
72
-
73
- if (this.options.interactive) {
74
- if (active) {
75
- state.order.push(this);
76
- } else {
77
- const index = state.order.indexOf(this);
78
-
79
- if (index > -1) {
80
- state.order.splice(index, 1);
81
- }
82
- }
83
- }
84
-
85
- if (active) {
86
- activate(this);
87
- } else {
88
- deactivate(this);
89
- }
18
+ update(): void {
19
+ setPosition(this);
90
20
  }
91
21
  }
92
22
 
93
- type FloatableWindow = Window & {
94
- _oscarpalmer_oui_floatable: State;
23
+ export type FloatableOptions = {
24
+ attribute: string;
25
+ position: FloatablePosition;
95
26
  };
96
27
 
97
- type Options = {
98
- anchor: HTMLElement;
99
- content: HTMLElement;
100
- defaultPosition: Position;
101
- interactive: boolean;
102
- positionAttribute: string;
103
- reusable: boolean;
104
- onAfter?: (active: boolean) => void;
105
- };
106
-
107
- type Position =
28
+ export type FloatablePosition =
108
29
  | 'above'
109
30
  | 'above-end'
110
31
  | 'above-start'
@@ -118,210 +39,60 @@ type Position =
118
39
  | 'start-bottom'
119
40
  | 'start-top';
120
41
 
121
- type State = {
122
- active: Set<Floatable>;
123
- mapped: Map<HTMLElement, Floatable>;
124
- order: Floatable[];
125
- };
126
-
127
- //
128
-
129
- function activate(floatable: Floatable): void {
130
- floatable.active = true;
131
-
132
- document.body.append(floatable.options.content);
133
-
134
- floatable.options.content.hidden = false;
135
-
136
- setPosition(floatable);
137
- }
138
-
139
- function closeAbove(element: HTMLElement): void {
140
- const state = getState();
141
-
142
- if (state == null) {
42
+ export function createFloatable(
43
+ anchor: HTMLElement | null,
44
+ content: HTMLElement,
45
+ options: FloatableOptions,
46
+ ): Floatable | undefined {
47
+ if (anchor == null) {
143
48
  return;
144
49
  }
145
50
 
146
- const index = state.order.findIndex(
147
- floatable => floatable.options.anchor === element || floatable.options.content === element,
148
- );
149
-
150
- if (index === -1 || index === state.order.length - 1) {
151
- return;
152
- }
153
-
154
- while (index < state.order.length - 1) {
155
- state.order.pop()?.toggle(false, true);
156
- }
157
- }
158
-
159
- function closeAll(): void {
160
- const state = getState();
51
+ let floatable = instances.get(content);
161
52
 
162
- const floatables = [...(state?.active ?? [])];
163
-
164
- state?.active.clear();
53
+ if (floatable == null) {
54
+ floatable = new Floatable(anchor, content, options);
165
55
 
166
- for (const floatable of floatables) {
167
- floatable.toggle(false, true);
56
+ instances.set(content, floatable);
168
57
  }
169
- }
170
58
 
171
- function closeNonInteractive(state: State): void {
172
- for (const floatable of state.active) {
173
- if (!floatable.options.interactive) {
174
- floatable.toggle(false);
175
- }
176
- }
59
+ return floatable;
177
60
  }
178
61
 
179
- export function deactivate(floatable: Floatable): void {
180
- floatable.active = false;
62
+ function getAnchorName(anchor: HTMLElement): string {
63
+ let {anchorName} = anchor.style;
181
64
 
182
- if (!floatable.options.reusable) {
183
- floatable.options.content.hidden = true;
184
-
185
- floatable.options.anchor.insertAdjacentElement(INSERT_AFTEREND, floatable.options.content);
65
+ if (anchorName.length > 0) {
66
+ return anchorName;
186
67
  }
187
68
 
188
- floatable.options.onAfter?.(false);
189
- }
190
-
191
- function getAnchorName(): string {
192
69
  index += 1;
193
70
 
194
71
  return `--oui-floatable-anchor-${index}`;
195
72
  }
196
73
 
197
- function getState(): State {
198
- return (window as unknown as FloatableWindow)._oscarpalmer_oui_floatable;
199
- }
200
-
201
- function initialize(floatable: Floatable): void {
202
- const {anchor, content, interactive} = floatable.options;
203
-
204
- if (interactive) {
205
- anchor.setAttribute(ATTRIBUTE_ANCHOR, '');
206
- content.setAttribute(ATTRIBUTE_CONTENT, '');
207
- }
208
-
209
- let anchorName = anchor.style.anchorName;
210
-
211
- if (anchorName.length === 0) {
212
- anchorName = getAnchorName();
213
- }
214
-
215
- anchor.style.anchorName = anchorName;
216
-
217
- content.style.position = 'absolute';
218
- content.style.positionAnchor = anchorName;
219
- content.style.positionTry = 'normal flip-start';
220
-
221
- const state = getState();
222
-
223
- state?.mapped.set(anchor, floatable);
224
- state?.mapped.set(content, floatable);
225
- }
226
-
227
- function isDisabled(element: HTMLElement): boolean {
228
- return (
229
- (element as HTMLInputElement).disabled ||
230
- (element as HTMLInputElement).readOnly ||
231
- element.getAttribute('aria-disabled') === 'true'
232
- );
233
- }
234
-
235
- function onClick(event: PointerEvent): void {
236
- const related = findAncestor(event, SELECTOR_ALL);
237
-
238
- if (!(related instanceof HTMLElement)) {
239
- closeAll();
240
- } else if (related.hasAttribute(ATTRIBUTE_CONTENT)) {
241
- closeAbove(related);
242
- } else if (!isDisabled(related)) {
243
- toggle(related);
244
- }
245
- }
246
-
247
- function onKeyDown(event: KeyboardEvent): void {
248
- if (event.key !== 'Escape') {
249
- return;
250
- }
251
-
252
- const state = getState();
253
-
254
- if (state == null || state.active.size === 0) {
255
- return;
256
- }
257
-
258
- closeNonInteractive(state);
259
-
260
- const related = findAncestor(event, SELECTOR_CONTENT);
261
-
262
- if (!(related instanceof HTMLElement)) {
263
- return;
264
- }
265
-
266
- closeAbove(related);
267
-
268
- const floatable = state.mapped.get(related);
269
-
270
- if (floatable != null) {
271
- floatable.toggle(false);
272
- }
74
+ export function removeFloatable(content: HTMLElement): void {
75
+ instances.get(content)?.destroy();
76
+ instances.delete(content);
273
77
  }
274
78
 
275
79
  function setPosition(floatable: Floatable): void {
276
- let position: Position =
277
- (floatable.options.anchor.getAttribute(floatable.options.positionAttribute) as Position) ??
278
- floatable.options.defaultPosition;
80
+ let position: FloatablePosition =
81
+ (floatable.anchor.getAttribute(floatable.options.attribute) as FloatablePosition) ??
82
+ floatable.options.position;
279
83
 
280
84
  if (AREAS[position] == null) {
281
- position = floatable.options.defaultPosition;
85
+ position = floatable.options.position;
282
86
  }
283
87
 
284
88
  const area = AREAS[position];
285
89
 
286
- floatable.options.content.style.positionArea = area;
287
-
288
- floatable.options.content.setAttribute(ATTRIBUTE_POSITION, POSITIONS[area]);
90
+ floatable.content.style.positionArea = area;
289
91
 
290
- floatable.options.onAfter?.(true);
92
+ floatable.content.setAttribute(ATTRIBUTE, POSITIONS[area]);
291
93
  }
292
94
 
293
- function stop(floatable: Floatable): void {
294
- if (floatable.frame != null) {
295
- cancelAnimationFrame(floatable.frame);
296
- }
297
-
298
- floatable.frame = undefined;
299
- }
300
-
301
- function toggle(anchor: HTMLElement): void {
302
- const floatable = getState()?.mapped.get(anchor);
303
-
304
- if (floatable == null) {
305
- closeAll();
306
-
307
- return;
308
- }
309
-
310
- const active = Boolean(floatable.active);
311
- const content = findAncestor(floatable.options.anchor, SELECTOR_CONTENT);
312
-
313
- if (content == null) {
314
- closeAll();
315
- } else {
316
- closeAbove(content as HTMLElement);
317
- }
318
-
319
- floatable.toggle(!active);
320
- }
321
-
322
- //
323
-
324
- const AREAS: Record<Position, string> = {
95
+ const AREAS: Record<FloatablePosition, string> = {
325
96
  above: 'block-start',
326
97
  'above-end': 'start span-start',
327
98
  'above-start': 'start span-end',
@@ -336,42 +107,12 @@ const AREAS: Record<Position, string> = {
336
107
  'start-top': 'span-end start',
337
108
  };
338
109
 
339
- const ATTRIBUTE_ANCHOR = 'oui-floatable-anchor';
340
-
341
- const ATTRIBUTE_CONTENT = 'oui-floatable-content';
342
-
343
- const ATTRIBUTE_POSITION = 'oui-position';
344
-
345
- const INSERT_AFTEREND = 'afterend';
110
+ const ATTRIBUTE = 'oui-position';
346
111
 
347
112
  const POSITIONS = Object.fromEntries(
348
113
  Object.entries(AREAS).map(([position, area]) => [area, position]),
349
- ) as Record<string, Position>;
114
+ ) as Record<string, FloatablePosition>;
350
115
 
351
- const SELECTOR_ALL = `[${ATTRIBUTE_ANCHOR}], [${ATTRIBUTE_CONTENT}]`;
352
-
353
- const SELECTOR_CONTENT = `[${ATTRIBUTE_CONTENT}]`;
116
+ const instances = new WeakMap<HTMLElement, Floatable>();
354
117
 
355
118
  let index = 0;
356
-
357
- //
358
-
359
- if ((window as unknown as FloatableWindow)._oscarpalmer_oui_floatable == null) {
360
- Object.defineProperty(window, '_oscarpalmer_oui_floatable', {
361
- enumerable: false,
362
- value: {
363
- active: new Set(),
364
- mapped: new Map(),
365
- order: [],
366
- },
367
- writable: false,
368
- });
369
-
370
- on(document, 'keydown', event => {
371
- onKeyDown(event);
372
- });
373
-
374
- on(document, 'click', event => {
375
- onClick(event);
376
- });
377
- }
package/src/js/popover.ts CHANGED
@@ -1,158 +1,96 @@
1
- import {isNullableOrWhitespace} from '@oscarpalmer/atoms/is';
2
- import {getFocusable} from '@oscarpalmer/toretto/focusable';
3
- import {Floatable} from './internal/floatable';
4
- import {createFocusTrap, type FocusTrap} from './focus-trap/embedded';
5
-
6
- declare global {
7
- // biome-ignore lint/nursery/useConsistentTypeDefinitions: Extending builtins to allow custom element type help
8
- interface HTMLElementTagNameMap {
9
- 'oui-popover': OuiPopoverElement;
10
- }
11
- }
12
-
13
- let index = 0;
14
-
15
- export class OuiPopoverElement extends HTMLElement {
16
- readonly #floatable: Floatable;
17
- readonly #focusTrap: FocusTrap;
18
-
19
- constructor() {
20
- super();
21
-
22
- const content = this.querySelector(SELECTOR_CONTENT);
23
- const toggle = this.querySelector(SELECTOR_TOGGLE);
24
-
25
- if (!(content instanceof HTMLElement)) {
26
- throw new Error(
27
- 'A oui-popover must have a direct child element with the [oui-popover-content]-attribute',
28
- );
29
- }
30
-
31
- if (!(toggle instanceof HTMLButtonElement)) {
32
- throw new Error(
33
- 'A oui-popover must have a direct child element with the [oui-popover-toggle]-attribute and be a button',
34
- );
35
- }
36
-
37
- content.hidden = true;
38
-
39
- content.setAttribute('role', 'dialog');
40
- content.setAttribute('aria-modal', 'true');
41
-
42
- if (isNullableOrWhitespace(content.id)) {
43
- content.setAttribute('id', `${ATTRIBUTE_ID_PREFIX}_${++index}`);
44
- }
45
-
46
- toggle.setAttribute('aria-controls', content.id);
47
- toggle.setAttribute('aria-haspopup', 'dialog');
48
-
49
- this.#floatable = new Floatable({
50
- content,
51
- anchor: toggle,
52
- defaultPosition: 'below-start',
53
- interactive: true,
54
- positionAttribute: 'position',
55
- reusable: false,
56
- onAfter: (active: boolean): void => {
57
- if (active) {
58
- focus(content);
59
- } else if (!this.#floatable.ignoreFocus) {
60
- toggle.focus();
61
- }
62
- },
63
- });
64
-
65
- this.#focusTrap = createFocusTrap(content, {
1
+ import {on} from '@oscarpalmer/toretto/event';
2
+ import type {RemovableEventListener} from '@oscarpalmer/toretto/models';
3
+ import {createFocusTrap, removeFocusTrap, type FocusTrap} from './focus-trap/embedded';
4
+ import {attributable} from './internal/attributable';
5
+ import {
6
+ createFloatable,
7
+ removeFloatable,
8
+ type Floatable,
9
+ type FloatableOptions,
10
+ } from './internal/floatable';
11
+
12
+ class Popover {
13
+ anchor: HTMLElement | null;
14
+ floatable?: Floatable;
15
+ focusTrap?: FocusTrap;
16
+ listener: RemovableEventListener;
17
+
18
+ constructor(public element: HTMLElement) {
19
+ this.anchor = element.ownerDocument.querySelector(`[popovertarget="${element.id}"]`);
20
+
21
+ this.floatable = createFloatable(this.anchor as HTMLElement, element, options);
22
+
23
+ this.focusTrap = createFocusTrap(element, {
66
24
  noescape: true,
67
25
  });
68
26
 
69
- POPOVERS.set(toggle, this.#floatable);
70
- POPOVERS.set(content, this.#floatable);
71
- }
27
+ this.listener = on(element, EVENT_TOGGLE, event => {
28
+ if (event.newState === STATE_OPEN) {
29
+ ignoreFocus = false;
72
30
 
73
- close(): void {
74
- this.hidePopover();
75
- }
76
-
77
- connectedCallback(): void {
78
- this.#floatable.enable();
79
- this.#focusTrap.connect();
80
- }
81
-
82
- disconnectedCallback(): void {
83
- this.#floatable.disable();
84
- this.#focusTrap.disconnect();
85
- }
86
-
87
- override hidePopover(): void {
88
- if (this.#floatable.active && this.isConnected) {
89
- this.#floatable.toggle(false);
90
- }
91
- }
31
+ this.floatable?.update();
92
32
 
93
- open(): void {
94
- this.showPopover();
95
- }
33
+ element.focus();
34
+ } else if (!ignoreFocus) {
35
+ this.anchor?.focus();
36
+ }
37
+ });
96
38
 
97
- override showPopover(): void {
98
- if (!this.#floatable.active && this.isConnected) {
99
- this.#floatable.toggle(true);
100
- }
101
- }
39
+ this.element.setAttribute(ATTRIBUTE_CONTENT, '');
102
40
 
103
- toggle(): void {
104
- this.togglePopover();
41
+ this.floatable?.update();
105
42
  }
106
43
 
107
- override togglePopover(options?: boolean): boolean {
108
- if (typeof options === 'boolean') {
109
- if (options === true) {
110
- this.showPopover();
111
- } else {
112
- this.hidePopover();
113
- }
114
-
115
- return options;
116
- }
117
-
118
- if (this.#floatable.active) {
119
- this.hidePopover();
44
+ destroy(): void {
45
+ this.focusTrap?.destroy();
120
46
 
121
- return false;
122
- }
47
+ removeFloatable(this.element);
48
+ removeFocusTrap(this.element);
123
49
 
124
- this.showPopover();
50
+ this.element.removeAttribute(ATTRIBUTE_CONTENT);
125
51
 
126
- return true;
52
+ this.anchor = null;
53
+ this.element = null as never;
127
54
  }
128
55
  }
129
56
 
130
- //
57
+ function onAdd(element: HTMLElement): void {
58
+ if (!instances.has(element) && element.getAttribute(ATTRIBUTE) !== POPOVER_HINT) {
59
+ instances.set(element, new Popover(element));
60
+ }
61
+ }
131
62
 
132
- function focus(content: HTMLElement): void {
133
- let target = getFocusable(content)[0];
63
+ function onPointerdown(): void {
64
+ ignoreFocus = true;
65
+ }
134
66
 
135
- if (!(target instanceof HTMLElement)) {
136
- target = content;
137
- }
67
+ function onRemove(element: HTMLElement): void {
68
+ instances.get(element)?.destroy();
138
69
 
139
- requestAnimationFrame(() => {
140
- (target as HTMLElement).focus();
141
- });
70
+ instances.delete(element);
142
71
  }
143
72
 
144
- //
73
+ const ATTRIBUTE = 'popover';
74
+
75
+ const ATTRIBUTE_CONTENT = 'oui-popover-content';
76
+
77
+ const EVENT_TOGGLE = 'toggle';
145
78
 
146
- const ATTRIBUTE_ID_PREFIX = 'oui_popover_content_';
79
+ const POPOVER_HINT = 'hint';
147
80
 
148
- const POPOVERS: Map<HTMLElement, Floatable> = new Map();
81
+ const STATE_OPEN = 'open';
149
82
 
150
- const SELECTOR = 'oui-popover';
83
+ const instances = new WeakMap<HTMLElement, Popover>();
151
84
 
152
- const SELECTOR_CONTENT = `:scope > [${SELECTOR}-content]`;
85
+ const options: FloatableOptions = {
86
+ attribute: 'position',
87
+ position: 'below-start',
88
+ };
153
89
 
154
- const SELECTOR_TOGGLE = `:scope > [${SELECTOR}-toggle]`;
90
+ let ignoreFocus = false;
155
91
 
156
- //
92
+ on(document, 'pointerdown', onPointerdown, {
93
+ capture: true,
94
+ });
157
95
 
158
- customElements.define(SELECTOR, OuiPopoverElement);
96
+ attributable(ATTRIBUTE, onAdd, onRemove);