@oscarpalmer/oui 0.22.0 → 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;
14
+ this.anchor = null as never;
15
+ this.content = null as never;
28
16
  }
29
17
 
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);
50
- }
51
-
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,206 +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
- }
51
+ let floatable = instances.get(content);
158
52
 
159
- function closeAll(): void {
160
- const state = getState();
161
-
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;
181
-
182
- if (!floatable.options.reusable) {
183
- floatable.options.content.hidden = true;
62
+ function getAnchorName(anchor: HTMLElement): string {
63
+ let {anchorName} = anchor.style;
184
64
 
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
- const anchorName = getAnchorName();
210
-
211
- anchor.style.anchorName = anchorName;
212
-
213
- content.style.position = 'absolute';
214
- content.style.positionAnchor = anchorName;
215
- content.style.positionTry = 'normal flip-start';
216
-
217
- const state = getState();
218
-
219
- state?.mapped.set(anchor, floatable);
220
- state?.mapped.set(content, floatable);
221
- }
222
-
223
- function isDisabled(element: HTMLElement): boolean {
224
- return (
225
- (element as HTMLInputElement).disabled ||
226
- (element as HTMLInputElement).readOnly ||
227
- element.getAttribute('aria-disabled') === 'true'
228
- );
229
- }
230
-
231
- function onClick(event: PointerEvent): void {
232
- const related = findAncestor(event, SELECTOR_ALL);
233
-
234
- if (!(related instanceof HTMLElement)) {
235
- closeAll();
236
- } else if (related.hasAttribute(ATTRIBUTE_CONTENT)) {
237
- closeAbove(related);
238
- } else if (!isDisabled(related)) {
239
- toggle(related);
240
- }
241
- }
242
-
243
- function onKeyDown(event: KeyboardEvent): void {
244
- if (event.key !== 'Escape') {
245
- return;
246
- }
247
-
248
- const state = getState();
249
-
250
- if (state == null || state.active.size === 0) {
251
- return;
252
- }
253
-
254
- closeNonInteractive(state);
255
-
256
- const related = findAncestor(event, SELECTOR_CONTENT);
257
-
258
- if (!(related instanceof HTMLElement)) {
259
- return;
260
- }
261
-
262
- closeAbove(related);
263
-
264
- const floatable = state.mapped.get(related);
265
-
266
- if (floatable != null) {
267
- floatable.toggle(false);
268
- }
74
+ export function removeFloatable(content: HTMLElement): void {
75
+ instances.get(content)?.destroy();
76
+ instances.delete(content);
269
77
  }
270
78
 
271
79
  function setPosition(floatable: Floatable): void {
272
- let position: Position =
273
- (floatable.options.anchor.getAttribute(floatable.options.positionAttribute) as Position) ??
274
- floatable.options.defaultPosition;
80
+ let position: FloatablePosition =
81
+ (floatable.anchor.getAttribute(floatable.options.attribute) as FloatablePosition) ??
82
+ floatable.options.position;
275
83
 
276
84
  if (AREAS[position] == null) {
277
- position = floatable.options.defaultPosition;
85
+ position = floatable.options.position;
278
86
  }
279
87
 
280
88
  const area = AREAS[position];
281
89
 
282
- floatable.options.content.style.positionArea = area;
90
+ floatable.content.style.positionArea = area;
283
91
 
284
- floatable.options.content.setAttribute(ATTRIBUTE_POSITION, POSITIONS[area]);
285
-
286
- floatable.options.onAfter?.(true);
287
- }
288
-
289
- function stop(floatable: Floatable): void {
290
- if (floatable.frame != null) {
291
- cancelAnimationFrame(floatable.frame);
292
- }
293
-
294
- floatable.frame = undefined;
295
- }
296
-
297
- function toggle(anchor: HTMLElement): void {
298
- const floatable = getState()?.mapped.get(anchor);
299
-
300
- if (floatable == null) {
301
- closeAll();
302
-
303
- return;
304
- }
305
-
306
- const active = Boolean(floatable.active);
307
- const content = findAncestor(floatable.options.anchor, SELECTOR_CONTENT);
308
-
309
- if (content == null) {
310
- closeAll();
311
- } else {
312
- closeAbove(content as HTMLElement);
313
- }
314
-
315
- floatable.toggle(!active);
92
+ floatable.content.setAttribute(ATTRIBUTE, POSITIONS[area]);
316
93
  }
317
94
 
318
- //
319
-
320
- const AREAS: Record<Position, string> = {
95
+ const AREAS: Record<FloatablePosition, string> = {
321
96
  above: 'block-start',
322
97
  'above-end': 'start span-start',
323
98
  'above-start': 'start span-end',
@@ -332,42 +107,12 @@ const AREAS: Record<Position, string> = {
332
107
  'start-top': 'span-end start',
333
108
  };
334
109
 
335
- const ATTRIBUTE_ANCHOR = 'oui-floatable-anchor';
336
-
337
- const ATTRIBUTE_CONTENT = 'oui-floatable-content';
338
-
339
- const ATTRIBUTE_POSITION = 'oui-position';
340
-
341
- const INSERT_AFTEREND = 'afterend';
110
+ const ATTRIBUTE = 'oui-position';
342
111
 
343
112
  const POSITIONS = Object.fromEntries(
344
113
  Object.entries(AREAS).map(([position, area]) => [area, position]),
345
- ) as Record<string, Position>;
114
+ ) as Record<string, FloatablePosition>;
346
115
 
347
- const SELECTOR_ALL = `[${ATTRIBUTE_ANCHOR}], [${ATTRIBUTE_CONTENT}]`;
348
-
349
- const SELECTOR_CONTENT = `[${ATTRIBUTE_CONTENT}]`;
116
+ const instances = new WeakMap<HTMLElement, Floatable>();
350
117
 
351
118
  let index = 0;
352
-
353
- //
354
-
355
- if ((window as unknown as FloatableWindow)._oscarpalmer_oui_floatable == null) {
356
- Object.defineProperty(window, '_oscarpalmer_oui_floatable', {
357
- enumerable: false,
358
- value: {
359
- active: new Set(),
360
- mapped: new Map(),
361
- order: [],
362
- },
363
- writable: false,
364
- });
365
-
366
- on(document, 'keydown', event => {
367
- onKeyDown(event);
368
- });
369
-
370
- on(document, 'click', event => {
371
- onClick(event);
372
- });
373
- }
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);