@oscarpalmer/oui 0.17.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 (58) 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/reset.css +176 -56
  6. package/dist/css/tooltip.css +8 -0
  7. package/dist/js/attributable.full.js +48 -0
  8. package/dist/js/attributable.js +30 -45
  9. package/dist/js/floatable.full.js +413 -0
  10. package/dist/js/floatable.js +254 -169
  11. package/dist/js/focus-trap/embedded.full.js +351 -0
  12. package/dist/js/focus-trap/embedded.js +131 -131
  13. package/dist/js/focus-trap/standalone.full.js +402 -0
  14. package/dist/js/focus-trap/standalone.js +4 -4
  15. package/dist/js/index.js +4 -5
  16. package/dist/js/oui.full.js +1596 -0
  17. package/dist/js/popover.full.js +822 -0
  18. package/dist/js/popover.js +83 -162
  19. package/dist/js/splitter.full.js +605 -0
  20. package/dist/js/splitter.js +263 -245
  21. package/dist/js/tooltip.full.js +679 -0
  22. package/dist/js/tooltip.js +159 -150
  23. package/package.json +31 -59
  24. package/src/css/content.scss +1 -2
  25. package/src/css/form.scss +12 -4
  26. package/src/css/message.scss +3 -0
  27. package/src/css/mixins.scss +7 -0
  28. package/src/css/reset.scss +180 -54
  29. package/src/css/tooltip.scss +8 -0
  30. package/src/js/floatable.ts +380 -133
  31. package/src/js/focus-trap/embedded.ts +149 -64
  32. package/src/js/focus-trap/standalone.ts +4 -4
  33. package/src/js/index.ts +1 -0
  34. package/src/js/popover.ts +50 -120
  35. package/src/js/splitter.ts +250 -103
  36. package/src/js/tooltip.ts +155 -68
  37. package/types/floatable.d.ts +17 -10
  38. package/types/focus-trap/embedded.d.ts +18 -5
  39. package/types/index.d.ts +1 -0
  40. package/types/popover.d.ts +2 -0
  41. package/types/splitter.d.ts +13 -1
  42. package/types/tooltip.d.ts +2 -1
  43. package/dist/js/attributable.cjs +0 -51
  44. package/dist/js/floatable.cjs +0 -176
  45. package/dist/js/focus-trap/embedded.cjs +0 -150
  46. package/dist/js/focus-trap/standalone.cjs +0 -8
  47. package/dist/js/index.cjs +0 -6
  48. package/dist/js/popover.cjs +0 -166
  49. package/dist/js/splitter.cjs +0 -272
  50. package/dist/js/tooltip.cjs +0 -166
  51. package/types/attributable.d.cts +0 -5
  52. package/types/floatable.d.cts +0 -20
  53. package/types/focus-trap/embedded.d.cts +0 -15
  54. package/types/focus-trap/standalone.d.cts +0 -3
  55. package/types/index.d.cts +0 -14
  56. package/types/popover.d.cts +0 -14
  57. package/types/splitter.d.cts +0 -10
  58. package/types/tooltip.d.cts +0 -3
@@ -1,31 +1,109 @@
1
+ import {on} from '@oscarpalmer/toretto/event';
2
+ import {findAncestor} from '@oscarpalmer/toretto/find';
3
+
1
4
  export class Floatable {
2
- activate: number | undefined;
5
+ active = false;
6
+
7
+ disabled = false;
8
+
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
+ }
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
+ }
3
41
 
4
- active = false;
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
+ }
5
62
 
6
- deactivate: number | undefined;
63
+ this.ignoreFocus = !active && (ignoreFocus ?? false);
7
64
 
8
- frame: DOMHighResTimeStamp | undefined;
65
+ stop(this);
9
66
 
10
- constructor(
11
- public anchor: HTMLElement,
12
- public content: HTMLElement,
13
- readonly attribute: string,
14
- readonly position: Position,
15
- readonly preferAbove: boolean,
16
- ) {
17
- setStyles(content);
67
+ if (active) {
68
+ state.active.add(this);
69
+ } else {
70
+ state.active.delete(this);
18
71
  }
19
72
 
20
- destroy(): void {
21
- this.activate = undefined;
22
- this.deactivate = undefined;
23
- this.frame = undefined;
73
+ if (this.options.interactive) {
74
+ if (active) {
75
+ state.order.push(this);
76
+ } else {
77
+ const index = state.order.indexOf(this);
24
78
 
25
- this.anchor = undefined as never;
26
- this.content = undefined as never;
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);
27
89
  }
28
90
  }
91
+ }
92
+
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
+ };
29
107
 
30
108
  type Position =
31
109
  | 'above'
@@ -47,82 +125,117 @@ type Position =
47
125
  | 'vertical-end'
48
126
  | 'vertical-start';
49
127
 
128
+ type State = {
129
+ active: Set<Floatable>;
130
+ mapped: Map<HTMLElement, Floatable>;
131
+ order: Floatable[];
132
+ };
133
+
50
134
  //
51
135
 
52
- export function activate(
53
- floatable: Floatable,
54
- active: Set<Floatable>,
55
- order?: Floatable[],
56
- time?: number,
57
- ): void {
58
- stop(floatable);
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;
59
142
 
60
- if (floatable.active) {
143
+ updatePosition(floatable);
144
+ }
145
+
146
+ function closeAbove(element: HTMLElement): void {
147
+ const state = getState();
148
+
149
+ if (state == null) {
61
150
  return;
62
151
  }
63
152
 
64
- if (time == null || time === 0) {
65
- onActivate(floatable, active, order);
66
- } else {
67
- floatable.activate = +setTimeout(() => {
68
- onActivate(floatable, active, order);
69
- }, 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;
161
+ }
162
+
163
+ while (index < state.order.length - 1) {
164
+ void state.order.pop()?.toggle(false, true);
70
165
  }
71
166
  }
72
167
 
73
- export function deactivate(
74
- floatable: Floatable,
75
- active: Set<Floatable>,
76
- order?: Floatable[],
77
- time?: number,
78
- ): void {
79
- stop(floatable);
168
+ function closeAll(): void {
169
+ const state = getState();
80
170
 
81
- if (!floatable.active) {
82
- return;
171
+ const floatables = [...(state?.active ?? [])];
172
+
173
+ state?.active.clear();
174
+
175
+ for (const floatable of floatables) {
176
+ void floatable.toggle(false, true);
83
177
  }
178
+ }
84
179
 
85
- if (time == null || time === 0) {
86
- onDeactivate(floatable, active, order);
87
- } else {
88
- floatable.deactivate = +setTimeout(() => {
89
- onDeactivate(floatable, active, order);
90
- }, time);
180
+ function closeNonInteractive(state: State): void {
181
+ for (const floatable of state.active) {
182
+ if (!floatable.options.interactive) {
183
+ void floatable.toggle(false);
184
+ }
91
185
  }
92
186
  }
93
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
+ );
197
+
198
+ floatable.options.onAfter?.(false);
199
+ }
200
+
201
+ function getState(): State {
202
+ return (window as unknown as FloatableWindow)._oscarpalmer_oui_floatable;
203
+ }
204
+
94
205
  function getX(anchor: DOMRect, content: DOMRect, position: Position): number {
95
- if (position.endsWith('-end')) {
206
+ if (position.endsWith(POSITION_SUFFIX_END)) {
96
207
  const end = anchor.right - content.width;
97
208
 
98
- return end - margin < 0 ? margin : end;
209
+ return end < 0 ? 0 : end;
99
210
  }
100
211
 
101
- if (position.endsWith('-start')) {
212
+ if (position.endsWith(POSITION_SUFFIX_START)) {
102
213
  const start = anchor.left;
103
214
 
104
- return start + content.width + margin > window.innerWidth
105
- ? window.innerWidth - content.width - margin
215
+ return start + content.width > window.innerWidth
216
+ ? window.innerWidth - content.width
106
217
  : start;
107
218
  }
108
219
 
109
- if (position.startsWith('end')) {
110
- return anchor.right + margin;
220
+ if (position.startsWith(POSITION_END)) {
221
+ return anchor.right;
111
222
  }
112
223
 
113
- if (position.startsWith('start')) {
114
- return anchor.left - content.width - margin;
224
+ if (position.startsWith(POSITION_START)) {
225
+ return anchor.left - content.width;
115
226
  }
116
227
 
117
- if (position.startsWith('horizontal')) {
118
- if (anchor.right + content.width + margin <= window.innerWidth) {
119
- return anchor.right + margin;
228
+ if (position.startsWith(POSITION_HORIZONTAL)) {
229
+ if (anchor.right + content.width <= window.innerWidth) {
230
+ return anchor.right;
120
231
  }
121
232
 
122
- return anchor.left - content.width - margin;
233
+ return anchor.left - content.width;
123
234
  }
124
235
 
125
- return anchor.left + (anchor.width - content.width) / 2;
236
+ const left = anchor.left + (anchor.width - content.width) / 2;
237
+
238
+ return left < 0 ? 0 : left;
126
239
  }
127
240
 
128
241
  function getY(
@@ -131,137 +244,243 @@ function getY(
131
244
  position: Position,
132
245
  preferAbove: boolean,
133
246
  ): number {
134
- if (position.startsWith('above')) {
135
- return anchor.top - content.height - margin;
247
+ if (position.startsWith(POSITION_ABOVE)) {
248
+ return anchor.top - content.height;
136
249
  }
137
250
 
138
- if (position.startsWith('below')) {
139
- return anchor.bottom + margin;
251
+ if (position.startsWith(POSITION_BELOW)) {
252
+ return anchor.bottom;
140
253
  }
141
254
 
142
- if (position.endsWith('bottom')) {
255
+ if (position.endsWith(POSITION_BOTTOM)) {
143
256
  const bottom = anchor.bottom - content.height;
144
257
 
145
- return bottom + margin < 0 ? margin : bottom;
258
+ return bottom < 0 ? 0 : bottom;
146
259
  }
147
260
 
148
- if (position.endsWith('top')) {
261
+ if (position.endsWith(POSITION_TOP)) {
149
262
  const top = anchor.top;
150
263
 
151
- return top + content.height + margin > window.innerHeight
152
- ? window.innerHeight - content.height - margin
264
+ return top + content.height > window.innerHeight
265
+ ? window.innerHeight - content.height
153
266
  : top;
154
267
  }
155
268
 
156
- if (position.startsWith('vertical')) {
157
- if (preferAbove && anchor.top - content.height - margin >= 0) {
158
- return anchor.top - content.height - margin;
269
+ if (position.startsWith(POSITION_VERTICAL)) {
270
+ if (preferAbove && anchor.top - content.height >= 0) {
271
+ return anchor.top - content.height;
159
272
  }
160
273
 
161
- if (anchor.bottom + content.height + margin <= document.body.clientHeight) {
162
- return anchor.bottom + margin;
274
+ if (anchor.bottom + content.height <= document.body.clientHeight) {
275
+ return anchor.bottom;
163
276
  }
164
277
 
165
- return anchor.top - content.height - margin;
278
+ return anchor.top - content.height;
166
279
  }
167
280
 
168
281
  return anchor.top + (anchor.height - content.height) / 2;
169
282
  }
170
283
 
171
- function onActivate(
172
- floatable: Floatable,
173
- active: Set<Floatable>,
174
- order?: Floatable[],
175
- ): void {
176
- floatable.active = true;
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
+
292
+ content.style.position = 'fixed';
293
+ content.style.inset = '0 auto auto 0';
294
+ content.style.zIndex = '10';
295
+
296
+ const state = getState();
297
+
298
+ state?.mapped.set(anchor, floatable);
299
+ state?.mapped.set(content, floatable);
300
+ }
177
301
 
178
- active.add(floatable);
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);
179
312
 
180
- if (order != null) {
181
- order.push(floatable);
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);
182
319
  }
320
+ }
183
321
 
184
- document.body.append(floatable.content);
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
+ }
185
332
 
186
- floatable.content.hidden = false;
333
+ closeNonInteractive(state);
187
334
 
188
- update(floatable);
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
+ }
189
348
  }
190
349
 
191
- function onDeactivate(
192
- floatable: Floatable,
193
- active: Set<Floatable>,
194
- order?: Floatable[],
195
- ): void {
196
- floatable.active = false;
350
+ function stop(floatable: Floatable): void {
351
+ if (floatable.frame != null) {
352
+ cancelAnimationFrame(floatable.frame);
353
+ }
197
354
 
198
- active.delete(floatable);
355
+ floatable.frame = undefined;
356
+ }
357
+
358
+ function toggle(anchor: HTMLElement): void {
359
+ const floatable = getState()?.mapped.get(anchor);
199
360
 
200
- const index = order?.indexOf(floatable) ?? -1;
361
+ if (floatable == null) {
362
+ closeAll();
201
363
 
202
- if (index > -1) {
203
- order?.splice(index, 1);
364
+ return;
204
365
  }
205
366
 
206
- floatable.content.hidden = true;
367
+ const active = Boolean(floatable.active);
368
+ const content = findAncestor(floatable.options.anchor, SELECTOR_CONTENT);
207
369
 
208
- floatable.anchor.insertAdjacentElement('afterend', floatable.content);
370
+ if (content == null) {
371
+ closeAll();
372
+ } else {
373
+ closeAbove(content as HTMLElement);
374
+ }
375
+
376
+ void floatable.toggle(!active);
209
377
  }
210
378
 
211
- function update(floatable: Floatable): void {
379
+ function updatePosition(floatable: Floatable): void {
212
380
  let position: Position =
213
- (floatable.anchor.getAttribute(floatable.attribute)?.trim() as Position) ??
214
- floatable.position;
381
+ (floatable.options.anchor
382
+ .getAttribute(floatable.options.positionAttribute)
383
+ ?.trim() as Position) ?? floatable.options.defaultPosition;
215
384
 
216
- if (!positions.has(position as Position)) {
217
- position = floatable.position;
385
+ if (!POSITIONS.has(position as Position)) {
386
+ position = floatable.options.defaultPosition;
218
387
  }
219
388
 
389
+ let previousAnchor: DOMRect;
390
+ let previousContent: DOMRect;
391
+
392
+ let calculated = false;
393
+ let runOnAfter = false;
394
+
220
395
  function run(): void {
221
- const anchor = floatable.anchor.getBoundingClientRect();
222
- const content = floatable.content.getBoundingClientRect();
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
+ }
223
420
 
224
- const left = getX(anchor, content, position);
225
- const top = getY(anchor, content, position, floatable.preferAbove);
421
+ function update(anchor: DOMRect, content: DOMRect): void {
422
+ previousAnchor = anchor;
423
+ previousContent = content;
226
424
 
227
- floatable.content.style.inset = `${top}px auto auto ${left}px`;
425
+ const left = getX(anchor, content, position);
426
+ const top = getY(anchor, content, position, floatable.options.preferAbove);
228
427
 
229
- floatable.frame = requestAnimationFrame(run);
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 = '';
230
432
  }
231
433
 
232
- floatable.frame = requestAnimationFrame(run);
233
- }
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
+ }
234
439
 
235
- function setStyles(content: HTMLElement): void {
236
- content.style.position = 'fixed';
237
- content.style.inset = '0 auto auto 0';
238
- content.style.zIndex = '10';
239
- }
440
+ // floatable.options.content.style.inset = `${top}px auto auto ${left}px`;
441
+ floatable.options.content.style.transform = `translate3d(${left}px, ${top}px, 0)`;
240
442
 
241
- function stop(floatable: Floatable): void {
242
- if (floatable.frame != null) {
243
- cancelAnimationFrame(floatable.frame);
244
- }
443
+ floatable.frame = requestAnimationFrame(run);
245
444
 
246
- if (floatable.activate != null) {
247
- clearTimeout(floatable.activate);
248
- }
445
+ if (!runOnAfter) {
446
+ runOnAfter = true;
249
447
 
250
- if (floatable.deactivate != null) {
251
- clearTimeout(floatable.deactivate);
448
+ floatable.options.onAfter?.(true);
449
+ }
252
450
  }
253
451
 
254
- floatable.activate = undefined;
255
- floatable.deactivate = undefined;
256
- floatable.frame = undefined;
452
+ floatable.frame = requestAnimationFrame(run);
257
453
  }
258
454
 
259
455
  //
260
456
 
261
- const margin =
262
- 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';
263
464
 
264
- const positions = new Set<Position>([
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([
265
484
  'above',
266
485
  'above-end',
267
486
  'above-start',
@@ -281,3 +500,31 @@ const positions = new Set<Position>([
281
500
  'vertical-end',
282
501
  'vertical-start',
283
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
+ }