@oscarpalmer/oui 0.16.0 → 0.18.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.
Files changed (62) hide show
  1. package/dist/css/badge.css +3 -3
  2. package/dist/css/content.css +9 -11
  3. package/dist/css/form.css +36 -22
  4. package/dist/css/message.css +4 -0
  5. package/dist/css/popover.css +3 -0
  6. package/dist/css/reset.css +176 -56
  7. package/dist/css/splitter.css +3 -3
  8. package/dist/css/tooltip.css +8 -0
  9. package/dist/js/attributable.full.js +48 -0
  10. package/dist/js/attributable.js +30 -45
  11. package/dist/js/floatable.full.js +413 -0
  12. package/dist/js/floatable.js +259 -132
  13. package/dist/js/focus-trap/embedded.full.js +351 -0
  14. package/dist/js/focus-trap/embedded.js +131 -131
  15. package/dist/js/focus-trap/standalone.full.js +402 -0
  16. package/dist/js/focus-trap/standalone.js +4 -4
  17. package/dist/js/index.js +4 -5
  18. package/dist/js/oui.full.js +1596 -0
  19. package/dist/js/popover.full.js +822 -0
  20. package/dist/js/popover.js +83 -156
  21. package/dist/js/splitter.full.js +605 -0
  22. package/dist/js/splitter.js +279 -200
  23. package/dist/js/tooltip.full.js +679 -0
  24. package/dist/js/tooltip.js +159 -144
  25. package/package.json +31 -59
  26. package/src/css/content.scss +1 -2
  27. package/src/css/form.scss +12 -4
  28. package/src/css/message.scss +3 -0
  29. package/src/css/mixins.scss +7 -0
  30. package/src/css/popover.scss +4 -0
  31. package/src/css/reset.scss +180 -54
  32. package/src/css/splitter.scss +3 -3
  33. package/src/css/tooltip.scss +8 -0
  34. package/src/js/floatable.ts +458 -124
  35. package/src/js/focus-trap/embedded.ts +149 -64
  36. package/src/js/focus-trap/standalone.ts +4 -4
  37. package/src/js/index.ts +1 -0
  38. package/src/js/popover.ts +51 -114
  39. package/src/js/splitter.ts +346 -109
  40. package/src/js/tooltip.ts +157 -63
  41. package/types/floatable.d.ts +19 -7
  42. package/types/focus-trap/embedded.d.ts +18 -5
  43. package/types/index.d.ts +1 -0
  44. package/types/popover.d.ts +2 -0
  45. package/types/splitter.d.ts +13 -1
  46. package/types/tooltip.d.ts +2 -1
  47. package/dist/js/attributable.cjs +0 -51
  48. package/dist/js/floatable.cjs +0 -134
  49. package/dist/js/focus-trap/embedded.cjs +0 -150
  50. package/dist/js/focus-trap/standalone.cjs +0 -8
  51. package/dist/js/index.cjs +0 -6
  52. package/dist/js/popover.cjs +0 -160
  53. package/dist/js/splitter.cjs +0 -211
  54. package/dist/js/tooltip.cjs +0 -160
  55. package/types/attributable.d.cts +0 -5
  56. package/types/floatable.d.cts +0 -16
  57. package/types/focus-trap/embedded.d.cts +0 -15
  58. package/types/focus-trap/standalone.d.cts +0 -3
  59. package/types/index.d.cts +0 -14
  60. package/types/popover.d.cts +0 -14
  61. package/types/splitter.d.cts +0 -10
  62. package/types/tooltip.d.cts +0 -3
@@ -1,175 +1,350 @@
1
+ import {on} from '@oscarpalmer/toretto/event';
2
+ import {findAncestor} from '@oscarpalmer/toretto/find';
3
+
1
4
  export class Floatable {
2
- activate: number | undefined;
3
- active = false;
4
- deactivate: number | undefined;
5
- frame: DOMHighResTimeStamp | undefined;
6
-
7
- constructor(
8
- public anchor: HTMLElement,
9
- public content: HTMLElement,
10
- ) {
11
- setStyles(content);
12
- }
5
+ active = false;
6
+
7
+ disabled = false;
13
8
 
14
- destroy(): void {
15
- this.activate = undefined;
16
- this.deactivate = undefined;
17
- this.frame = undefined;
9
+ frame: DOMHighResTimeStamp | undefined;
10
+
11
+ ignoreFocus = false;
12
+
13
+ constructor(readonly options: Options) {
14
+ initialize(this);
15
+ }
16
+
17
+ 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
+ }
18
29
 
19
- this.anchor = undefined as never;
20
- this.content = undefined as never;
30
+ disable(): void {
31
+ if (this.disabled) {
32
+ return;
21
33
  }
34
+
35
+ this.disabled = true;
36
+
37
+ stop(this);
38
+
39
+ getState()?.active.delete(this);
22
40
  }
23
41
 
24
- export function activate(
25
- floatable: Floatable,
26
- active: Set<Floatable>,
27
- order?: Floatable[],
28
- time?: number,
29
- ): void {
30
- stop(floatable);
42
+ enable(): void {
43
+ if (!this.disabled) {
44
+ return;
45
+ }
31
46
 
32
- if (floatable.active) {
33
- return;
47
+ this.disabled = false;
48
+
49
+ initialize(this);
34
50
  }
35
51
 
36
- if (time == null || time === 0) {
37
- onActivate(floatable, active, order);
38
- } else {
39
- floatable.activate = +setTimeout(() => {
40
- onActivate(floatable, active, order);
41
- }, time);
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
+ }
42
90
  }
43
91
  }
44
92
 
45
- export function deactivate(
46
- floatable: Floatable,
47
- active: Set<Floatable>,
48
- order?: Floatable[],
49
- time?: number,
50
- ): void {
51
- stop(floatable);
93
+ type FloatableWindow = Window & {
94
+ // biome-ignore lint/style/useNamingConvention: Non-standard name to avoid conflicts
95
+ _oscarpalmer_oui_floatable: State;
96
+ };
97
+
98
+ type Options = {
99
+ anchor: HTMLElement;
100
+ content: HTMLElement;
101
+ defaultPosition: Position;
102
+ interactive: boolean;
103
+ positionAttribute: string;
104
+ preferAbove: boolean;
105
+ onAfter?: (active: boolean) => void;
106
+ };
107
+
108
+ type Position =
109
+ | 'above'
110
+ | 'above-end'
111
+ | 'above-start'
112
+ | 'below'
113
+ | 'below-end'
114
+ | 'below-start'
115
+ | 'end'
116
+ | 'end-bottom'
117
+ | 'end-top'
118
+ | 'horizontal'
119
+ | 'horizontal-bottom'
120
+ | 'horizontal-top'
121
+ | 'start'
122
+ | 'start-bottom'
123
+ | 'start-top'
124
+ | 'vertical'
125
+ | 'vertical-end'
126
+ | 'vertical-start';
127
+
128
+ type State = {
129
+ active: Set<Floatable>;
130
+ mapped: Map<HTMLElement, Floatable>;
131
+ order: Floatable[];
132
+ };
52
133
 
53
- if (!floatable.active) {
134
+ //
135
+
136
+ function activate(floatable: Floatable): void {
137
+ floatable.active = true;
138
+
139
+ document.body.append(floatable.options.content);
140
+
141
+ floatable.options.content.hidden = false;
142
+
143
+ updatePosition(floatable);
144
+ }
145
+
146
+ function closeAbove(element: HTMLElement): void {
147
+ const state = getState();
148
+
149
+ if (state == null) {
54
150
  return;
55
151
  }
56
152
 
57
- if (time == null || time === 0) {
58
- onDeactivate(floatable, active, order);
59
- } else {
60
- floatable.deactivate = +setTimeout(() => {
61
- onDeactivate(floatable, active, order);
62
- }, time);
153
+ const index = state.order.findIndex(
154
+ floatable =>
155
+ floatable.options.anchor === element ||
156
+ floatable.options.content === element,
157
+ );
158
+
159
+ if (index === -1 || index === state.order.length - 1) {
160
+ return;
63
161
  }
64
- }
65
162
 
66
- function getX(
67
- anchor: DOMRect,
68
- content: DOMRect,
69
- ): {left?: number; right?: number} {
70
- if (content.width + 2 * margin >= window.innerWidth) {
71
- return {
72
- left: margin,
73
- right: margin,
74
- };
163
+ while (index < state.order.length - 1) {
164
+ void state.order.pop()?.toggle(false, true);
75
165
  }
166
+ }
76
167
 
77
- const left = anchor.left + (anchor.width - content.width) / 2;
78
- const right = window.innerWidth - margin;
168
+ function closeAll(): void {
169
+ const state = getState();
170
+
171
+ const floatables = [...(state?.active ?? [])];
172
+
173
+ state?.active.clear();
79
174
 
80
- if (left < margin) {
81
- return {
82
- left: margin,
83
- };
175
+ for (const floatable of floatables) {
176
+ void floatable.toggle(false, true);
84
177
  }
178
+ }
85
179
 
86
- if (left + content.width > right) {
87
- return {
88
- right: margin,
89
- };
180
+ function closeNonInteractive(state: State): void {
181
+ for (const floatable of state.active) {
182
+ if (!floatable.options.interactive) {
183
+ void floatable.toggle(false);
184
+ }
90
185
  }
186
+ }
187
+
188
+ export function deactivate(floatable: Floatable): void {
189
+ floatable.active = false;
190
+
191
+ floatable.options.content.hidden = true;
192
+
193
+ floatable.options.anchor.insertAdjacentElement(
194
+ INSERT_AFTEREND,
195
+ floatable.options.content,
196
+ );
91
197
 
92
- return {
93
- left,
94
- };
198
+ floatable.options.onAfter?.(false);
95
199
  }
96
200
 
97
- function getY(anchor: DOMRect, content: DOMRect): number {
98
- let top = anchor.top - content.height - margin;
201
+ function getState(): State {
202
+ return (window as unknown as FloatableWindow)._oscarpalmer_oui_floatable;
203
+ }
204
+
205
+ function getX(anchor: DOMRect, content: DOMRect, position: Position): number {
206
+ if (position.endsWith(POSITION_SUFFIX_END)) {
207
+ const end = anchor.right - content.width;
99
208
 
100
- if (top - margin >= 0) {
101
- return top;
209
+ return end < 0 ? 0 : end;
102
210
  }
103
211
 
104
- top = anchor.bottom + margin;
212
+ if (position.endsWith(POSITION_SUFFIX_START)) {
213
+ const start = anchor.left;
105
214
 
106
- if (top + content.height + margin <= window.innerHeight) {
107
- return top;
215
+ return start + content.width > window.innerWidth
216
+ ? window.innerWidth - content.width
217
+ : start;
108
218
  }
109
219
 
110
- return anchor.top + (anchor.height - content.height) / 2;
111
- }
220
+ if (position.startsWith(POSITION_END)) {
221
+ return anchor.right;
222
+ }
112
223
 
113
- function onActivate(
114
- floatable: Floatable,
115
- active: Set<Floatable>,
116
- order?: Floatable[],
117
- ): void {
118
- floatable.active = true;
224
+ if (position.startsWith(POSITION_START)) {
225
+ return anchor.left - content.width;
226
+ }
119
227
 
120
- active.add(floatable);
228
+ if (position.startsWith(POSITION_HORIZONTAL)) {
229
+ if (anchor.right + content.width <= window.innerWidth) {
230
+ return anchor.right;
231
+ }
121
232
 
122
- if (order != null) {
123
- order.push(floatable);
233
+ return anchor.left - content.width;
124
234
  }
125
235
 
126
- document.body.append(floatable.content);
127
-
128
- floatable.content.hidden = false;
236
+ const left = anchor.left + (anchor.width - content.width) / 2;
129
237
 
130
- update(floatable);
238
+ return left < 0 ? 0 : left;
131
239
  }
132
240
 
133
- function onDeactivate(
134
- floatable: Floatable,
135
- active: Set<Floatable>,
136
- order?: Floatable[],
137
- ): void {
138
- floatable.active = false;
241
+ function getY(
242
+ anchor: DOMRect,
243
+ content: DOMRect,
244
+ position: Position,
245
+ preferAbove: boolean,
246
+ ): number {
247
+ if (position.startsWith(POSITION_ABOVE)) {
248
+ return anchor.top - content.height;
249
+ }
139
250
 
140
- active.delete(floatable);
251
+ if (position.startsWith(POSITION_BELOW)) {
252
+ return anchor.bottom;
253
+ }
141
254
 
142
- const index = order?.indexOf(floatable) ?? -1;
255
+ if (position.endsWith(POSITION_BOTTOM)) {
256
+ const bottom = anchor.bottom - content.height;
143
257
 
144
- if (index > -1) {
145
- order?.splice(index, 1);
258
+ return bottom < 0 ? 0 : bottom;
146
259
  }
147
260
 
148
- floatable.content.hidden = true;
261
+ if (position.endsWith(POSITION_TOP)) {
262
+ const top = anchor.top;
149
263
 
150
- floatable.anchor.insertAdjacentElement('afterend', floatable.content);
151
- }
152
-
153
- function update(floatable: Floatable): void {
154
- function run(): void {
155
- const anchor = floatable.anchor.getBoundingClientRect();
156
- const content = floatable.content.getBoundingClientRect();
264
+ return top + content.height > window.innerHeight
265
+ ? window.innerHeight - content.height
266
+ : top;
267
+ }
157
268
 
158
- const {left, right} = getX(anchor, content);
159
- const top = getY(anchor, content);
269
+ if (position.startsWith(POSITION_VERTICAL)) {
270
+ if (preferAbove && anchor.top - content.height >= 0) {
271
+ return anchor.top - content.height;
272
+ }
160
273
 
161
- floatable.content.style.inset = `${top}px ${right == null ? 'auto' : `${right}px`} auto ${left == null ? 'auto' : `${left}px`}`;
274
+ if (anchor.bottom + content.height <= document.body.clientHeight) {
275
+ return anchor.bottom;
276
+ }
162
277
 
163
- floatable.frame = requestAnimationFrame(run);
278
+ return anchor.top - content.height;
164
279
  }
165
280
 
166
- floatable.frame = requestAnimationFrame(run);
281
+ return anchor.top + (anchor.height - content.height) / 2;
167
282
  }
168
283
 
169
- function setStyles(content: HTMLElement): void {
284
+ function initialize(floatable: Floatable): void {
285
+ const {anchor, content, interactive} = floatable.options;
286
+
287
+ if (interactive) {
288
+ anchor.setAttribute(ATTRIBUTE_ANCHOR, '');
289
+ content.setAttribute(ATTRIBUTE_CONTENT, '');
290
+ }
291
+
170
292
  content.style.position = 'fixed';
171
293
  content.style.inset = '0 auto auto 0';
172
294
  content.style.zIndex = '10';
295
+
296
+ const state = getState();
297
+
298
+ state?.mapped.set(anchor, floatable);
299
+ state?.mapped.set(content, floatable);
300
+ }
301
+
302
+ function isDisabled(element: HTMLElement): boolean {
303
+ return (
304
+ (element as HTMLInputElement).disabled ||
305
+ (element as HTMLInputElement).readOnly ||
306
+ element.getAttribute('aria-disabled') === 'true'
307
+ );
308
+ }
309
+
310
+ function onClick(event: PointerEvent): void {
311
+ const related = findAncestor(event.target as never, SELECTOR_ALL);
312
+
313
+ if (!(related instanceof HTMLElement)) {
314
+ closeAll();
315
+ } else if (related.hasAttribute(ATTRIBUTE_CONTENT)) {
316
+ closeAbove(related);
317
+ } else if (!isDisabled(related)) {
318
+ toggle(related);
319
+ }
320
+ }
321
+
322
+ function onKeyDown(event: KeyboardEvent): void {
323
+ if (event.key !== 'Escape') {
324
+ return;
325
+ }
326
+
327
+ const state = getState();
328
+
329
+ if (state == null || state.active.size === 0) {
330
+ return;
331
+ }
332
+
333
+ closeNonInteractive(state);
334
+
335
+ const related = findAncestor(event.target as never, SELECTOR_CONTENT);
336
+
337
+ if (!(related instanceof HTMLElement)) {
338
+ return;
339
+ }
340
+
341
+ closeAbove(related);
342
+
343
+ const floatable = state.mapped.get(related);
344
+
345
+ if (floatable != null) {
346
+ void floatable.toggle(false);
347
+ }
173
348
  }
174
349
 
175
350
  function stop(floatable: Floatable): void {
@@ -177,20 +352,179 @@ function stop(floatable: Floatable): void {
177
352
  cancelAnimationFrame(floatable.frame);
178
353
  }
179
354
 
180
- if (floatable.activate != null) {
181
- clearTimeout(floatable.activate);
355
+ floatable.frame = undefined;
356
+ }
357
+
358
+ function toggle(anchor: HTMLElement): void {
359
+ const floatable = getState()?.mapped.get(anchor);
360
+
361
+ if (floatable == null) {
362
+ closeAll();
363
+
364
+ return;
365
+ }
366
+
367
+ const active = Boolean(floatable.active);
368
+ const content = findAncestor(floatable.options.anchor, SELECTOR_CONTENT);
369
+
370
+ if (content == null) {
371
+ closeAll();
372
+ } else {
373
+ closeAbove(content as HTMLElement);
182
374
  }
183
375
 
184
- if (floatable.deactivate != null) {
185
- clearTimeout(floatable.deactivate);
376
+ void floatable.toggle(!active);
377
+ }
378
+
379
+ function updatePosition(floatable: Floatable): void {
380
+ let position: Position =
381
+ (floatable.options.anchor
382
+ .getAttribute(floatable.options.positionAttribute)
383
+ ?.trim() as Position) ?? floatable.options.defaultPosition;
384
+
385
+ if (!POSITIONS.has(position as Position)) {
386
+ position = floatable.options.defaultPosition;
186
387
  }
187
388
 
188
- floatable.activate = undefined;
189
- floatable.deactivate = undefined;
190
- floatable.frame = undefined;
389
+ let previousAnchor: DOMRect;
390
+ let previousContent: DOMRect;
391
+
392
+ let calculated = false;
393
+ let runOnAfter = false;
394
+
395
+ function run(): void {
396
+ const anchor = floatable.options.anchor.getBoundingClientRect();
397
+ const content = floatable.options.content.getBoundingClientRect();
398
+
399
+ if (calculated) {
400
+ if (previousAnchor != null && previousContent != null) {
401
+ if (
402
+ PROPERTIES.every(
403
+ property => anchor[property] === previousAnchor[property],
404
+ ) &&
405
+ PROPERTIES.every(
406
+ property => content[property] === previousContent[property],
407
+ )
408
+ ) {
409
+ floatable.frame = requestAnimationFrame(run);
410
+
411
+ return;
412
+ }
413
+ }
414
+ } else {
415
+ calculated = true;
416
+ }
417
+
418
+ update(anchor, content);
419
+ }
420
+
421
+ function update(anchor: DOMRect, content: DOMRect): void {
422
+ previousAnchor = anchor;
423
+ previousContent = content;
424
+
425
+ const left = getX(anchor, content, position);
426
+ const top = getY(anchor, content, position, floatable.options.preferAbove);
427
+
428
+ if (top + content.height > window.innerHeight) {
429
+ floatable.options.content.style.height = `${window.innerHeight - top}px`;
430
+ } else {
431
+ floatable.options.content.style.height = '';
432
+ }
433
+
434
+ if (left + content.width > window.innerWidth) {
435
+ floatable.options.content.style.width = `${window.innerWidth - left}px`;
436
+ } else {
437
+ floatable.options.content.style.width = '';
438
+ }
439
+
440
+ // floatable.options.content.style.inset = `${top}px auto auto ${left}px`;
441
+ floatable.options.content.style.transform = `translate3d(${left}px, ${top}px, 0)`;
442
+
443
+ floatable.frame = requestAnimationFrame(run);
444
+
445
+ if (!runOnAfter) {
446
+ runOnAfter = true;
447
+
448
+ floatable.options.onAfter?.(true);
449
+ }
450
+ }
451
+
452
+ floatable.frame = requestAnimationFrame(run);
191
453
  }
192
454
 
193
455
  //
194
456
 
195
- const margin =
196
- Number.parseInt(getComputedStyle(document.documentElement).fontSize, 10) / 2;
457
+ const ATTRIBUTE_ANCHOR = 'oui-floatable-anchor';
458
+
459
+ const ATTRIBUTE_CONTENT = 'oui-floatable-content';
460
+
461
+ const INSERT_AFTEREND = 'afterend';
462
+
463
+ const POSITION_ABOVE = 'above';
464
+
465
+ const POSITION_BELOW = 'below';
466
+
467
+ const POSITION_BOTTOM = 'bottom';
468
+
469
+ const POSITION_END = 'end';
470
+
471
+ const POSITION_HORIZONTAL = 'horizontal';
472
+
473
+ const POSITION_START = 'start';
474
+
475
+ const POSITION_SUFFIX_END = '-end';
476
+
477
+ const POSITION_SUFFIX_START = '-start';
478
+
479
+ const POSITION_TOP = 'top';
480
+
481
+ const POSITION_VERTICAL = 'vertical';
482
+
483
+ const POSITIONS: Set<Position> = new Set([
484
+ 'above',
485
+ 'above-end',
486
+ 'above-start',
487
+ 'below',
488
+ 'below-end',
489
+ 'below-start',
490
+ 'end',
491
+ 'end-bottom',
492
+ 'end-top',
493
+ 'horizontal',
494
+ 'horizontal-bottom',
495
+ 'horizontal-top',
496
+ 'start',
497
+ 'start-bottom',
498
+ 'start-top',
499
+ 'vertical',
500
+ 'vertical-end',
501
+ 'vertical-start',
502
+ ]);
503
+
504
+ const PROPERTIES: (keyof DOMRect)[] = ['bottom', 'left', 'right', 'top'];
505
+
506
+ const SELECTOR_ALL = `[${ATTRIBUTE_ANCHOR}], [${ATTRIBUTE_CONTENT}]`;
507
+
508
+ const SELECTOR_CONTENT = `[${ATTRIBUTE_CONTENT}]`;
509
+
510
+ //
511
+
512
+ if ((window as unknown as FloatableWindow)._oscarpalmer_oui_floatable == null) {
513
+ Object.defineProperty(window, '_oscarpalmer_oui_floatable', {
514
+ enumerable: false,
515
+ value: {
516
+ active: new Set(),
517
+ mapped: new Map(),
518
+ order: [],
519
+ },
520
+ writable: false,
521
+ });
522
+
523
+ on(document, 'keydown', event => {
524
+ onKeyDown(event);
525
+ });
526
+
527
+ on(document, 'click', event => {
528
+ onClick(event);
529
+ });
530
+ }