@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
@@ -3,15 +3,56 @@ import {round} from '@oscarpalmer/atoms/math';
3
3
  import {clamp} from '@oscarpalmer/atoms/number';
4
4
  import {getPosition, on} from '@oscarpalmer/toretto/event';
5
5
  import {findAncestor} from '@oscarpalmer/toretto/find';
6
+ import {type StyleToggler, toggleStyles} from '@oscarpalmer/toretto/style';
7
+ import supportsTouch from '@oscarpalmer/toretto/touch';
6
8
 
7
9
  declare global {
10
+ // biome-ignore lint/nursery/useConsistentTypeDefinitions: Extending builtins to allow custom element type help
8
11
  interface HTMLElementTagNameMap {
9
- 'oui-splitter': OuiSplitterElement;
12
+ [SELECTOR]: OuiSplitterElement;
10
13
  }
11
14
  }
12
15
 
13
16
  export class OuiSplitterElement extends HTMLElement {
14
- #splitter: Splitter;
17
+ static readonly observedAttributes = ['max', 'min', 'size', 'type'];
18
+
19
+ readonly #splitter: Splitter;
20
+
21
+ // Getters and setters
22
+
23
+ get max(): number {
24
+ return this.#splitter.values.max;
25
+ }
26
+
27
+ set max(value: number) {
28
+ this.setAttribute('max', String(value));
29
+ }
30
+
31
+ get min(): number {
32
+ return this.#splitter.values.min;
33
+ }
34
+
35
+ set min(value: number) {
36
+ this.setAttribute('min', String(value));
37
+ }
38
+
39
+ get size(): number {
40
+ return this.#splitter.values.now.current;
41
+ }
42
+
43
+ set size(value: number) {
44
+ this.setAttribute('size', String(value));
45
+ }
46
+
47
+ get type(): string {
48
+ return this.getAttribute('type') ?? 'vertical';
49
+ }
50
+
51
+ set type(value: string) {
52
+ this.setAttribute('type', value);
53
+ }
54
+
55
+ // Constructor
15
56
 
16
57
  constructor() {
17
58
  super();
@@ -21,21 +62,36 @@ export class OuiSplitterElement extends HTMLElement {
21
62
  );
22
63
 
23
64
  if (panels.length !== 2) {
24
- throw new Error('oui-splitter must have exactly two panels.');
65
+ throw new Error(`${SELECTOR} must have exactly two panels.`);
25
66
  }
26
67
 
27
68
  this.#splitter = new Splitter(this, panels);
28
69
  }
29
70
 
71
+ // Methods
72
+
30
73
  connectedCallback(): void {
31
- if (this.#splitter.types.auto) {
32
- observer.observe(this);
33
- }
74
+ this.#splitter.updateConnections(1);
34
75
  }
35
76
 
36
77
  disconnectedCallback(): void {
37
- if (this.#splitter.types.auto) {
38
- observer.unobserve(this);
78
+ this.#splitter.updateConnections(0);
79
+ }
80
+
81
+ attributeChangedCallback(name: string): void {
82
+ switch (name) {
83
+ case 'max':
84
+ case 'min':
85
+ case 'size':
86
+ this.#splitter.updateValues();
87
+ break;
88
+
89
+ case 'type':
90
+ this.#splitter.updateOrientation();
91
+ break;
92
+
93
+ default:
94
+ break;
39
95
  }
40
96
  }
41
97
  }
@@ -43,20 +99,24 @@ export class OuiSplitterElement extends HTMLElement {
43
99
  let index = 0;
44
100
 
45
101
  class Splitter {
102
+ element: OuiSplitterElement;
103
+
46
104
  handle: HTMLSpanElement;
47
105
 
106
+ panels: HTMLElement[];
107
+
48
108
  rectangle: DOMRect;
49
109
 
50
110
  separator: HTMLDivElement;
51
111
 
52
- types: Types;
112
+ types!: Types;
53
113
 
54
114
  values: Values;
55
115
 
56
- constructor(
57
- public element: OuiSplitterElement,
58
- public panels: HTMLElement[],
59
- ) {
116
+ constructor(element: OuiSplitterElement, panels: HTMLElement[]) {
117
+ this.element = element;
118
+ this.panels = panels;
119
+
60
120
  if (isNullableOrWhitespace(element.id)) {
61
121
  element.id = `oui_splitter_${++index}`;
62
122
  }
@@ -71,19 +131,47 @@ class Splitter {
71
131
 
72
132
  panels[0].insertAdjacentElement('afterend', this.separator);
73
133
 
74
- mapped.set(element, this);
75
- mapped.set(this.handle, this);
134
+ this.updateOrientation();
135
+ this.updateValues();
136
+ }
137
+
138
+ updateConnections(add: 0 | 1): void {
139
+ const method = METHODS_CONNECT[add];
76
140
 
77
- setSize(this, this.values.now.current);
141
+ MAPPED_ELEMENTS[method](this.element, this);
142
+ MAPPED_ELEMENTS[method](this.handle, this);
143
+ MAPPED_ELEMENTS[method](this.separator, this);
78
144
 
145
+ if (this.types.auto) {
146
+ RESIZE_OBSERVER[METHODS_OBSERVE[add]](this.element);
147
+ }
148
+ }
149
+
150
+ updateOrientation(): void {
79
151
  this.types = getTypes(this);
80
152
 
153
+ if (this.types.auto) {
154
+ RESIZE_OBSERVER.observe(this.element);
155
+ } else {
156
+ RESIZE_OBSERVER.unobserve(this.element);
157
+ }
158
+
81
159
  if (this.types.horizontal) {
82
- this.element.setAttribute('oui-splitter-horizontal', '');
160
+ this.element.setAttribute(ATTRIBUTE_HORIZONTAL, '');
161
+ } else {
162
+ this.element.removeAttribute(ATTRIBUTE_HORIZONTAL);
83
163
  }
84
164
 
85
165
  setAriaOrientation(this);
86
- setAriaValue(this);
166
+ }
167
+
168
+ updateValues(): void {
169
+ this.values = getValues(this.element);
170
+
171
+ requestAnimationFrame(() => {
172
+ setSize(this, this.values.now.current);
173
+ setAriaValue(this);
174
+ });
87
175
  }
88
176
  }
89
177
 
@@ -109,7 +197,7 @@ function createHandle(): HTMLSpanElement {
109
197
  const handle = document.createElement('span');
110
198
 
111
199
  handle.setAttribute('aria-hidden', 'true');
112
- handle.setAttribute('oui-splitter-handle', '');
200
+ handle.setAttribute(ATTRIBUTE_HANDLE, '');
113
201
 
114
202
  return handle;
115
203
  }
@@ -122,13 +210,13 @@ function createSeparator(splitter: Splitter): HTMLDivElement {
122
210
 
123
211
  separator.setAttribute('aria-controls', splitter.element.id);
124
212
  separator.setAttribute('aria-label', 'Resize panels');
125
- separator.setAttribute('oui-splitter-separator', '');
213
+ separator.setAttribute(ATTRIBUTE_SEPARATOR, '');
126
214
 
127
215
  return separator;
128
216
  }
129
217
 
130
218
  function getContained(min: number, value: number, max: number): number {
131
- return round(clamp(value, min, max), 6);
219
+ return round(clamp(value, min, max), VALUE_DECIMALS);
132
220
  }
133
221
 
134
222
  function getSize(splitter: Splitter, value: number): number {
@@ -183,13 +271,17 @@ function getValue(
183
271
  return defaultValue;
184
272
  }
185
273
 
186
- return parsed < 0 ? defaultValue : parsed > 1 ? parsed / 100 : parsed;
274
+ if (parsed < 0) {
275
+ return defaultValue;
276
+ }
277
+
278
+ return parsed > 1 ? parsed / NAVIGATION_MAXIMUM_TOTAL : parsed;
187
279
  }
188
280
 
189
281
  function getValues(element: OuiSplitterElement): Values {
190
282
  const values: Values = {
191
- max: getValue(element, 'max', 0.9),
192
- min: getValue(element, 'min', 0.1),
283
+ max: getValue(element, 'max', NAVIGATION_MAXIMUM_PERCENTAGE),
284
+ min: getValue(element, 'min', NAVIGATION_MINIMUM_PERCENTAGE),
193
285
  now: {
194
286
  current: 0.5,
195
287
  previous: 0.5,
@@ -209,23 +301,27 @@ function getValues(element: OuiSplitterElement): Values {
209
301
  }
210
302
 
211
303
  function onEnd(reset: boolean): void {
212
- if (splitter != null) {
213
- splitter.handle.removeAttribute('oui-splitter-active');
214
- splitter.separator.removeAttribute('oui-splitter-active');
304
+ if (splitter == null) {
305
+ return;
306
+ }
215
307
 
216
- if (reset) {
217
- setSize(splitter, splitter.values.now.previous);
218
- } else {
219
- splitter.values.now.previous = splitter.values.now.current;
220
- }
308
+ splitter.handle.removeAttribute(ATTRIBUTE_ACTIVE);
309
+ splitter.separator.removeAttribute(ATTRIBUTE_ACTIVE);
310
+
311
+ if (reset) {
312
+ setSize(splitter, splitter.values.now.previous);
313
+ } else {
314
+ splitter.values.now.previous = splitter.values.now.current;
221
315
  }
222
316
 
223
317
  splitter = undefined;
318
+
319
+ STYLING_TOGGLER.remove();
224
320
  }
225
321
 
226
322
  function onKeydown(event: KeyboardEvent): void {
227
323
  switch (true) {
228
- case allKeys.has(event.key):
324
+ case KEYS_ALL.has(event.key):
229
325
  onNavigate(event);
230
326
  break;
231
327
 
@@ -238,52 +334,11 @@ function onKeydown(event: KeyboardEvent): void {
238
334
  }
239
335
  }
240
336
 
241
- function onMousedown(event: Event): void {
242
- splitter = mapped.get(
243
- findAncestor(
244
- event.target as never,
245
- '[oui-splitter-handle]',
246
- ) as HTMLSpanElement,
247
- );
248
-
249
- if (splitter == null) {
250
- return;
251
- }
252
-
253
- splitter.handle.setAttribute('oui-splitter-active', '');
254
- splitter.separator.setAttribute('oui-splitter-active', '');
255
-
256
- splitter.rectangle = splitter.element.getBoundingClientRect();
257
- }
258
-
259
- function onMousemove(event: MouseEvent): void {
260
- if (splitter == null) {
261
- return;
262
- }
263
-
264
- const position = getPosition(event);
265
-
266
- if (position != null) {
267
- splitter.values.now.current = getSize(
268
- splitter,
269
- splitter.types.horizontal ? position.y : position.x,
270
- );
271
-
272
- setSize(splitter, splitter.values.now.current);
273
- }
274
- }
275
-
276
- function onMouseup(): void {
277
- if (splitter != null) {
278
- onEnd(false);
279
- }
280
- }
281
-
282
337
  function onNavigate(event: KeyboardEvent): void {
283
- const splitter = mapped.get(
338
+ const splitter = MAPPED_ELEMENTS.get(
284
339
  findAncestor(
285
340
  event.target as never,
286
- '[oui-splitter-handle]',
341
+ '[oui-splitter-separator]',
287
342
  ) as HTMLSpanElement,
288
343
  );
289
344
 
@@ -291,26 +346,32 @@ function onNavigate(event: KeyboardEvent): void {
291
346
  return;
292
347
  }
293
348
 
294
- if (absoluteKeys.has(event.key)) {
295
- setSize(
296
- splitter,
297
- event.key === 'End' ? splitter.values.max : splitter.values.min,
298
- true,
299
- );
349
+ const {types, values} = splitter;
350
+
351
+ if (KEYS_ABSOLUTE.has(event.key)) {
352
+ setSize(splitter, event.key === 'End' ? values.max : values.min, true);
300
353
 
301
354
  return;
302
355
  }
303
356
 
304
357
  if (
305
- (splitter.types.horizontal && !horizontalKeys.has(event.key)) ||
306
- (!splitter.types.horizontal && !verticalKeys.has(event.key))
358
+ (types.horizontal && !KEYS_HORIZONTAL.has(event.key)) ||
359
+ !(types.horizontal || KEYS_VERTICAL.has(event.key))
307
360
  ) {
308
361
  return;
309
362
  }
310
363
 
311
- const modifier = negativeKeys.has(event.key) ? -1 : 1;
364
+ const offset = KEYS_NEGATIVE.has(event.key) ? -1 : 1;
312
365
 
313
- setSize(splitter, splitter.values.now.current + modifier * 0.05, true);
366
+ setSize(
367
+ splitter,
368
+ getContained(
369
+ values.min,
370
+ values.now.current + offset * NAVIGATION_MODIFIER,
371
+ values.max,
372
+ ),
373
+ true,
374
+ );
314
375
  }
315
376
 
316
377
  function onObservation(entries: ResizeObserverEntry[]): void {
@@ -320,7 +381,7 @@ function onObservation(entries: ResizeObserverEntry[]): void {
320
381
 
321
382
  frame = requestAnimationFrame(() => {
322
383
  for (const entry of entries) {
323
- const splitter = mapped.get(entry.target as HTMLElement);
384
+ const splitter = MAPPED_ELEMENTS.get(entry.target as HTMLElement);
324
385
 
325
386
  if (splitter == null) {
326
387
  return;
@@ -333,9 +394,9 @@ function onObservation(entries: ResizeObserverEntry[]): void {
333
394
  );
334
395
 
335
396
  if (splitter.types.horizontal) {
336
- splitter.element.setAttribute('oui-splitter-horizontal', '');
397
+ splitter.element.setAttribute(ATTRIBUTE_HORIZONTAL, '');
337
398
  } else {
338
- splitter.element.removeAttribute('oui-splitter-horizontal');
399
+ splitter.element.removeAttribute(ATTRIBUTE_HORIZONTAL);
339
400
  }
340
401
 
341
402
  setAriaOrientation(splitter);
@@ -345,6 +406,57 @@ function onObservation(entries: ResizeObserverEntry[]): void {
345
406
  });
346
407
  }
347
408
 
409
+ function onPointerdown(event: Event): void {
410
+ if (
411
+ (event.type === 'mousedown' && supportsTouch.value) ||
412
+ (event.type === 'touchstart' && !supportsTouch.value)
413
+ ) {
414
+ return;
415
+ }
416
+
417
+ splitter = MAPPED_ELEMENTS.get(
418
+ findAncestor(event.target as never, SELECTOR_HANDLE) as HTMLSpanElement,
419
+ );
420
+
421
+ if (splitter == null) {
422
+ return;
423
+ }
424
+
425
+ if (supportsTouch.value) {
426
+ event.preventDefault();
427
+ }
428
+
429
+ splitter.handle.setAttribute(ATTRIBUTE_ACTIVE, '');
430
+ splitter.separator.setAttribute(ATTRIBUTE_ACTIVE, '');
431
+
432
+ splitter.rectangle = splitter.element.getBoundingClientRect();
433
+
434
+ STYLING_TOGGLER.set();
435
+ }
436
+
437
+ function onPointermove(event: MouseEvent): void {
438
+ if (splitter == null) {
439
+ return;
440
+ }
441
+
442
+ const position = getPosition(event);
443
+
444
+ if (position != null) {
445
+ splitter.values.now.current = getSize(
446
+ splitter,
447
+ splitter.types.horizontal ? position.y : position.x,
448
+ );
449
+
450
+ setSize(splitter, splitter.values.now.current);
451
+ }
452
+ }
453
+
454
+ function onPointerup(): void {
455
+ if (splitter != null) {
456
+ onEnd(false);
457
+ }
458
+ }
459
+
348
460
  function setAriaOrientation(splitter: Splitter): void {
349
461
  splitter.separator.setAttribute(
350
462
  'aria-orientation',
@@ -376,19 +488,55 @@ function setSize(splitter: Splitter, size: number, previous?: boolean): void {
376
488
 
377
489
  //
378
490
 
379
- const absoluteKeys = new Set(['End', 'Home']);
491
+ const SELECTOR = 'oui-splitter';
492
+
493
+ const ATTRIBUTE_ACTIVE = `${SELECTOR}-active`;
494
+
495
+ const ATTRIBUTE_HANDLE = `${SELECTOR}-handle`;
496
+
497
+ const ATTRIBUTE_HORIZONTAL = `${SELECTOR}-horizontal`;
498
+
499
+ const ATTRIBUTE_SEPARATOR = `${SELECTOR}-separator`;
500
+
501
+ const KEYS_ABSOLUTE: Set<string> = new Set(['End', 'Home']);
502
+
503
+ const KEYS_HORIZONTAL: Set<string> = new Set(['ArrowDown', 'ArrowUp']);
504
+
505
+ const KEYS_NEGATIVE: Set<string> = new Set(['ArrowLeft', 'ArrowUp']);
506
+
507
+ const KEYS_VERTICAL: Set<string> = new Set(['ArrowLeft', 'ArrowRight']);
380
508
 
381
- const horizontalKeys = new Set(['ArrowDown', 'ArrowUp']);
509
+ const KEYS_ALL: Set<string> = new Set([
510
+ ...KEYS_ABSOLUTE,
511
+ ...KEYS_HORIZONTAL,
512
+ ...KEYS_VERTICAL,
513
+ ]);
382
514
 
383
- const mapped = new WeakMap<HTMLElement, Splitter>();
515
+ const MAPPED_ELEMENTS: WeakMap<HTMLElement, Splitter> = new WeakMap();
384
516
 
385
- const observer = new ResizeObserver(onObservation);
517
+ const METHODS_CONNECT = ['delete', 'set'] as const;
386
518
 
387
- const negativeKeys = new Set(['ArrowLeft', 'ArrowUp']);
519
+ const METHODS_OBSERVE = ['unobserve', 'observe'] as const;
388
520
 
389
- const verticalKeys = new Set(['ArrowLeft', 'ArrowRight']);
521
+ const NAVIGATION_MAXIMUM_PERCENTAGE = 0.9;
390
522
 
391
- const allKeys = new Set([...absoluteKeys, ...horizontalKeys, ...verticalKeys]);
523
+ const NAVIGATION_MAXIMUM_TOTAL = 100;
524
+
525
+ const NAVIGATION_MINIMUM_PERCENTAGE = 0.1;
526
+
527
+ const NAVIGATION_MODIFIER = 0.05;
528
+
529
+ const RESIZE_OBSERVER: ResizeObserver = new ResizeObserver(onObservation);
530
+
531
+ const SELECTOR_HANDLE = `[${ATTRIBUTE_HANDLE}]`;
532
+
533
+ const STYLING_TOGGLER: StyleToggler = toggleStyles(document.body, {
534
+ touchAction: 'none',
535
+ userSelect: 'none',
536
+ webkitUserSelect: 'none',
537
+ });
538
+
539
+ const VALUE_DECIMALS = 6;
392
540
 
393
541
  let frame: DOMHighResTimeStamp | undefined;
394
542
 
@@ -396,12 +544,11 @@ let splitter: Splitter | undefined;
396
544
 
397
545
  //
398
546
 
399
- customElements.define('oui-splitter', OuiSplitterElement);
547
+ customElements.define(SELECTOR, OuiSplitterElement);
400
548
 
401
549
  on(document, 'keydown', onKeydown);
402
-
403
- on(document, 'mousedown', onMousedown);
404
-
405
- on(document, 'mousemove', onMousemove);
406
-
407
- on(document, 'mouseup', onMouseup);
550
+ on(document, 'mousedown', onPointerdown);
551
+ on(document, 'pointermove', onPointermove);
552
+ on(document, 'pointerup', onPointerup);
553
+ on(document, 'touchcancel', onPointerup, {passive: false});
554
+ on(document, 'touchstart', onPointerdown, {passive: false});