@rdlabo/ionic-theme-ios26 1.0.6 → 1.1.1

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 (43) hide show
  1. package/README.md +35 -1
  2. package/dist/css/components/ion-alert.css +1 -1
  3. package/dist/css/components/ion-popover.css +1 -1
  4. package/dist/css/ionic-theme-ios26.css +1 -1
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -2
  8. package/dist/popover/animations/ios.enter.d.ts +4 -0
  9. package/dist/popover/animations/ios.enter.d.ts.map +1 -0
  10. package/dist/popover/animations/ios.enter.js +111 -0
  11. package/dist/popover/animations/ios.leave.d.ts +3 -0
  12. package/dist/popover/animations/ios.leave.d.ts.map +1 -0
  13. package/dist/popover/animations/ios.leave.js +60 -0
  14. package/dist/popover/popover-interface.d.ts +38 -0
  15. package/dist/popover/popover-interface.d.ts.map +1 -0
  16. package/dist/popover/popover-interface.js +1 -0
  17. package/dist/popover/utils.d.ts +48 -0
  18. package/dist/popover/utils.d.ts.map +1 -0
  19. package/dist/popover/utils.js +479 -0
  20. package/dist/sheets-of-glass/animations.d.ts +8 -0
  21. package/dist/sheets-of-glass/animations.d.ts.map +1 -0
  22. package/dist/sheets-of-glass/animations.js +97 -0
  23. package/dist/sheets-of-glass/index.d.ts +3 -0
  24. package/dist/sheets-of-glass/index.d.ts.map +1 -0
  25. package/dist/sheets-of-glass/index.js +160 -0
  26. package/dist/sheets-of-glass/interfaces.d.ts +16 -0
  27. package/dist/sheets-of-glass/interfaces.d.ts.map +1 -0
  28. package/dist/sheets-of-glass/interfaces.js +1 -0
  29. package/dist/utils.d.ts +5 -1
  30. package/dist/utils.d.ts.map +1 -1
  31. package/dist/utils.js +26 -11
  32. package/package.json +1 -1
  33. package/src/index.ts +5 -3
  34. package/src/popover/animations/ios.enter.ts +176 -0
  35. package/src/popover/animations/ios.leave.ts +76 -0
  36. package/src/popover/popover-interface.ts +44 -0
  37. package/src/popover/utils.ts +912 -0
  38. package/src/{gestures → sheets-of-glass}/animations.ts +3 -3
  39. package/src/{gestures → sheets-of-glass}/index.ts +1 -1
  40. package/src/styles/components/ion-alert.scss +1 -0
  41. package/src/styles/components/ion-popover.scss +6 -0
  42. package/src/{gestures/utils.ts → utils.ts} +18 -1
  43. /package/src/{gestures → sheets-of-glass}/interfaces.ts +0 -0
@@ -0,0 +1,912 @@
1
+ import { getElementRoot, raf } from '../utils';
2
+
3
+ import type { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from './popover-interface';
4
+ import { POPOVER_IOS_BODY_MARGIN } from './animations/ios.enter';
5
+
6
+ interface InteractionCallback {
7
+ eventName: string;
8
+ callback: (ev: any) => void; // TODO(FW-2832): type
9
+ }
10
+
11
+ export interface ReferenceCoordinates {
12
+ top: number;
13
+ left: number;
14
+ width: number;
15
+ height: number;
16
+ }
17
+
18
+ interface PopoverPosition {
19
+ top: number;
20
+ left: number;
21
+ referenceCoordinates?: ReferenceCoordinates;
22
+ arrowTop?: number;
23
+ arrowLeft?: number;
24
+ originX: string;
25
+ originY: string;
26
+ }
27
+
28
+ export interface PopoverStyles {
29
+ top: number;
30
+ left: number;
31
+ bottom?: number;
32
+ originX: string;
33
+ originY: string;
34
+ checkSafeAreaLeft: boolean;
35
+ checkSafeAreaRight: boolean;
36
+ arrowTop: number;
37
+ arrowLeft: number;
38
+ addPopoverBottomClass: boolean;
39
+ }
40
+
41
+ /**
42
+ * Returns the dimensions of the popover
43
+ * arrow on `ios` mode. If arrow is disabled
44
+ * returns (0, 0).
45
+ */
46
+ export const getArrowDimensions = (arrowEl: HTMLElement | null) => {
47
+ if (!arrowEl) {
48
+ return { arrowWidth: 0, arrowHeight: 0 };
49
+ }
50
+ const { width, height } = arrowEl.getBoundingClientRect();
51
+
52
+ return { arrowWidth: width, arrowHeight: height };
53
+ };
54
+
55
+ /**
56
+ * Returns the recommended dimensions of the popover
57
+ * that takes into account whether or not the width
58
+ * should match the trigger width.
59
+ */
60
+ export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement) => {
61
+ const contentDimentions = contentEl.getBoundingClientRect();
62
+ const contentHeight = contentDimentions.height;
63
+ let contentWidth = contentDimentions.width;
64
+
65
+ if (size === 'cover' && triggerEl) {
66
+ const triggerDimensions = triggerEl.getBoundingClientRect();
67
+ contentWidth = triggerDimensions.width;
68
+ }
69
+
70
+ return {
71
+ contentWidth,
72
+ contentHeight,
73
+ };
74
+ };
75
+
76
+ export const configureDismissInteraction = (
77
+ triggerEl: HTMLElement,
78
+ triggerAction: TriggerAction,
79
+ popoverEl: HTMLIonPopoverElement,
80
+ parentPopoverEl: HTMLIonPopoverElement,
81
+ ) => {
82
+ let dismissCallbacks: InteractionCallback[] = [];
83
+ const root = getElementRoot(parentPopoverEl);
84
+ const parentContentEl = root.querySelector('.popover-content') as HTMLElement;
85
+
86
+ switch (triggerAction) {
87
+ case 'hover':
88
+ dismissCallbacks = [
89
+ {
90
+ /**
91
+ * Do not use mouseover here
92
+ * as this will causes the event to
93
+ * be dispatched on each underlying
94
+ * element rather than on the popover
95
+ * content as a whole.
96
+ */
97
+ eventName: 'mouseenter',
98
+ callback: (ev: MouseEvent) => {
99
+ /**
100
+ * Do not dismiss the popover is we
101
+ * are hovering over its trigger.
102
+ * This would be easier if we used mouseover
103
+ * but this would cause the event to be dispatched
104
+ * more often than we would like, potentially
105
+ * causing performance issues.
106
+ */
107
+ const element = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null;
108
+ if (element === triggerEl) {
109
+ return;
110
+ }
111
+
112
+ popoverEl.dismiss(undefined, undefined, false);
113
+ },
114
+ },
115
+ ];
116
+ break;
117
+ case 'context-menu':
118
+ case 'click':
119
+ default:
120
+ dismissCallbacks = [
121
+ {
122
+ eventName: 'click',
123
+ callback: (ev: MouseEvent) => {
124
+ /**
125
+ * Do not dismiss the popover is we
126
+ * are hovering over its trigger.
127
+ */
128
+ const target = ev.target as HTMLElement;
129
+ const closestTrigger = target.closest('[data-ion-popover-trigger]');
130
+ if (closestTrigger === triggerEl) {
131
+ /**
132
+ * stopPropagation here so if the
133
+ * popover has dismissOnSelect="true"
134
+ * the popover does not dismiss since
135
+ * we just clicked a trigger element.
136
+ */
137
+ ev.stopPropagation();
138
+ return;
139
+ }
140
+
141
+ popoverEl.dismiss(undefined, undefined, false);
142
+ },
143
+ },
144
+ ];
145
+ break;
146
+ }
147
+
148
+ dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.addEventListener(eventName, callback));
149
+
150
+ return () => {
151
+ dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.removeEventListener(eventName, callback));
152
+ };
153
+ };
154
+
155
+ /**
156
+ * Configures the triggerEl to respond
157
+ * to user interaction based upon the triggerAction
158
+ * prop that devs have defined.
159
+ */
160
+ export const configureTriggerInteraction = (triggerEl: HTMLElement, triggerAction: TriggerAction, popoverEl: HTMLIonPopoverElement) => {
161
+ let triggerCallbacks: InteractionCallback[] = [];
162
+
163
+ /**
164
+ * Based upon the kind of trigger interaction
165
+ * the user wants, we setup the correct event
166
+ * listeners.
167
+ */
168
+ switch (triggerAction) {
169
+ case 'hover':
170
+ let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
171
+
172
+ triggerCallbacks = [
173
+ {
174
+ eventName: 'mouseenter',
175
+ callback: async (ev: Event) => {
176
+ ev.stopPropagation();
177
+
178
+ if (hoverTimeout) {
179
+ clearTimeout(hoverTimeout);
180
+ }
181
+
182
+ /**
183
+ * Hovering over a trigger should not
184
+ * immediately open the next popover.
185
+ */
186
+ hoverTimeout = setTimeout(() => {
187
+ raf(() => {
188
+ popoverEl.presentFromTrigger(ev);
189
+ hoverTimeout = undefined;
190
+ });
191
+ }, 100);
192
+ },
193
+ },
194
+ {
195
+ eventName: 'mouseleave',
196
+ callback: (ev: MouseEvent) => {
197
+ if (hoverTimeout) {
198
+ clearTimeout(hoverTimeout);
199
+ }
200
+
201
+ /**
202
+ * If mouse is over another popover
203
+ * that is not this popover then we should
204
+ * close this popover.
205
+ */
206
+ const target = ev.relatedTarget as HTMLElement | null;
207
+ if (!target) {
208
+ return;
209
+ }
210
+
211
+ if (target.closest('ion-popover') !== popoverEl) {
212
+ popoverEl.dismiss(undefined, undefined, false);
213
+ }
214
+ },
215
+ },
216
+ {
217
+ /**
218
+ * stopPropagation here prevents the popover
219
+ * from dismissing when dismiss-on-select="true".
220
+ */
221
+ eventName: 'click',
222
+ callback: (ev: Event) => ev.stopPropagation(),
223
+ },
224
+ {
225
+ eventName: 'ionPopoverActivateTrigger',
226
+ callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
227
+ },
228
+ ];
229
+
230
+ break;
231
+ case 'context-menu':
232
+ triggerCallbacks = [
233
+ {
234
+ eventName: 'contextmenu',
235
+ callback: (ev: Event) => {
236
+ /**
237
+ * Prevents the platform context
238
+ * menu from appearing.
239
+ */
240
+ ev.preventDefault();
241
+ popoverEl.presentFromTrigger(ev);
242
+ },
243
+ },
244
+ {
245
+ eventName: 'click',
246
+ callback: (ev: Event) => ev.stopPropagation(),
247
+ },
248
+ {
249
+ eventName: 'ionPopoverActivateTrigger',
250
+ callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
251
+ },
252
+ ];
253
+
254
+ break;
255
+ case 'click':
256
+ default:
257
+ triggerCallbacks = [
258
+ {
259
+ /**
260
+ * Do not do a stopPropagation() here
261
+ * because if you had two click triggers
262
+ * then clicking the first trigger and then
263
+ * clicking the second trigger would not cause
264
+ * the first popover to dismiss.
265
+ */
266
+ eventName: 'click',
267
+ callback: (ev: Event) => popoverEl.presentFromTrigger(ev),
268
+ },
269
+ {
270
+ eventName: 'ionPopoverActivateTrigger',
271
+ callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
272
+ },
273
+ ];
274
+ break;
275
+ }
276
+
277
+ triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.addEventListener(eventName, callback));
278
+ triggerEl.setAttribute('data-ion-popover-trigger', 'true');
279
+
280
+ return () => {
281
+ triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.removeEventListener(eventName, callback));
282
+ triggerEl.removeAttribute('data-ion-popover-trigger');
283
+ };
284
+ };
285
+
286
+ /**
287
+ * Returns the index of an ion-item in an array of ion-items.
288
+ */
289
+ export const getIndexOfItem = (items: HTMLIonItemElement[], item: HTMLElement | null) => {
290
+ if (!item || item.tagName !== 'ION-ITEM') {
291
+ return -1;
292
+ }
293
+
294
+ return items.findIndex((el) => el === item);
295
+ };
296
+
297
+ /**
298
+ * Given an array of elements and a currently focused ion-item
299
+ * returns the next ion-item relative to the focused one or
300
+ * undefined.
301
+ */
302
+ export const getNextItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => {
303
+ const currentItemIndex = getIndexOfItem(items, currentItem);
304
+ return items[currentItemIndex + 1];
305
+ };
306
+
307
+ /**
308
+ * Given an array of elements and a currently focused ion-item
309
+ * returns the previous ion-item relative to the focused one or
310
+ * undefined.
311
+ */
312
+ export const getPrevItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => {
313
+ const currentItemIndex = getIndexOfItem(items, currentItem);
314
+ return items[currentItemIndex - 1];
315
+ };
316
+
317
+ /** Focus the internal button of the ion-item */
318
+ const focusItem = (item: HTMLIonItemElement) => {
319
+ const root = getElementRoot(item);
320
+ const button = root.querySelector('button');
321
+
322
+ if (button) {
323
+ raf(() => button.focus());
324
+ }
325
+ };
326
+
327
+ /**
328
+ * Returns `true` if `el` has been designated
329
+ * as a trigger element for an ion-popover.
330
+ */
331
+ export const isTriggerElement = (el: HTMLElement) => el.hasAttribute('data-ion-popover-trigger');
332
+
333
+ export const configureKeyboardInteraction = (popoverEl: HTMLIonPopoverElement) => {
334
+ const callback = async (ev: KeyboardEvent) => {
335
+ const activeElement = document.activeElement as HTMLElement | null;
336
+ let items: HTMLIonItemElement[] = [];
337
+
338
+ const targetTagName = (ev.target as HTMLElement)?.tagName;
339
+ /**
340
+ * Only handle custom keyboard interactions for the host popover element
341
+ * and children ion-item elements.
342
+ */
343
+ if (targetTagName !== 'ION-POPOVER' && targetTagName !== 'ION-ITEM') {
344
+ return;
345
+ }
346
+ /**
347
+ * Complex selectors with :not() are :not supported
348
+ * in older versions of Chromium so we need to do a
349
+ * try/catch here so errors are not thrown.
350
+ */
351
+ try {
352
+ /**
353
+ * Select all ion-items that are not children of child popovers.
354
+ * i.e. only select ion-item elements that are part of this popover
355
+ */
356
+ items = Array.from(
357
+ popoverEl.querySelectorAll('ion-item:not(ion-popover ion-popover *):not([disabled])') as NodeListOf<HTMLIonItemElement>,
358
+ );
359
+ /* eslint-disable-next-line */
360
+ } catch {}
361
+
362
+ switch (ev.key) {
363
+ /**
364
+ * If we are in a child popover
365
+ * then pressing the left arrow key
366
+ * should close this popover and move
367
+ * focus to the popover that presented
368
+ * this one.
369
+ */
370
+ case 'ArrowLeft':
371
+ const parentPopover = await popoverEl.getParentPopover();
372
+ if (parentPopover) {
373
+ popoverEl.dismiss(undefined, undefined, false);
374
+ }
375
+ break;
376
+ /**
377
+ * ArrowDown should move focus to the next focusable ion-item.
378
+ */
379
+ case 'ArrowDown':
380
+ // Disable movement/scroll with keyboard
381
+ ev.preventDefault();
382
+ const nextItem = getNextItem(items, activeElement);
383
+ if (nextItem !== undefined) {
384
+ focusItem(nextItem);
385
+ }
386
+ break;
387
+ /**
388
+ * ArrowUp should move focus to the previous focusable ion-item.
389
+ */
390
+ case 'ArrowUp':
391
+ // Disable movement/scroll with keyboard
392
+ ev.preventDefault();
393
+ const prevItem = getPrevItem(items, activeElement);
394
+ if (prevItem !== undefined) {
395
+ focusItem(prevItem);
396
+ }
397
+ break;
398
+ /**
399
+ * Home should move focus to the first focusable ion-item.
400
+ */
401
+ case 'Home':
402
+ ev.preventDefault();
403
+ const firstItem = items[0];
404
+ if (firstItem !== undefined) {
405
+ focusItem(firstItem);
406
+ }
407
+ break;
408
+ /**
409
+ * End should move focus to the last focusable ion-item.
410
+ */
411
+ case 'End':
412
+ ev.preventDefault();
413
+ const lastItem = items[items.length - 1];
414
+ if (lastItem !== undefined) {
415
+ focusItem(lastItem);
416
+ }
417
+ break;
418
+ /**
419
+ * ArrowRight, Spacebar, or Enter should activate
420
+ * the currently focused trigger item to open a
421
+ * popover if the element is a trigger item.
422
+ */
423
+ case 'ArrowRight':
424
+ case ' ':
425
+ case 'Enter':
426
+ if (activeElement && isTriggerElement(activeElement)) {
427
+ const rightEvent = new CustomEvent('ionPopoverActivateTrigger');
428
+ activeElement.dispatchEvent(rightEvent);
429
+ }
430
+ break;
431
+ default:
432
+ break;
433
+ }
434
+ };
435
+
436
+ popoverEl.addEventListener('keydown', callback);
437
+ return () => popoverEl.removeEventListener('keydown', callback);
438
+ };
439
+
440
+ /**
441
+ * Positions a popover by taking into account
442
+ * the reference point, preferred side, alignment
443
+ * and viewport dimensions.
444
+ */
445
+ export const getPopoverPosition = (
446
+ isRTL: boolean,
447
+ contentWidth: number,
448
+ contentHeight: number,
449
+ arrowWidth: number,
450
+ arrowHeight: number,
451
+ reference: PositionReference,
452
+ side: PositionSide,
453
+ align: PositionAlign,
454
+ defaultPosition: PopoverPosition,
455
+ triggerEl?: HTMLElement,
456
+ event?: MouseEvent | CustomEvent,
457
+ ): PopoverPosition => {
458
+ let referenceCoordinates = {
459
+ top: 0,
460
+ left: 0,
461
+ width: 0,
462
+ height: 0,
463
+ };
464
+
465
+ /**
466
+ * Calculate position relative to the
467
+ * x-y coordinates in the event that
468
+ * was passed in
469
+ */
470
+ switch (reference) {
471
+ case 'event':
472
+ if (!event) {
473
+ return defaultPosition;
474
+ }
475
+
476
+ const mouseEv = event as MouseEvent;
477
+
478
+ referenceCoordinates = {
479
+ top: mouseEv.clientY,
480
+ left: mouseEv.clientX,
481
+ width: 1,
482
+ height: 1,
483
+ };
484
+
485
+ break;
486
+
487
+ /**
488
+ * Calculate position relative to the bounding
489
+ * box on either the trigger element
490
+ * specified via the `trigger` prop or
491
+ * the target specified on the event
492
+ * that was passed in.
493
+ */
494
+ case 'trigger':
495
+ default:
496
+ const customEv = event as CustomEvent;
497
+
498
+ /**
499
+ * ionShadowTarget is used when we need to align the
500
+ * popover with an element inside of the shadow root
501
+ * of an Ionic component. Ex: Presenting a popover
502
+ * by clicking on the collapsed indicator inside
503
+ * of `ion-breadcrumb` and centering it relative
504
+ * to the indicator rather than `ion-breadcrumb`
505
+ * as a whole.
506
+ */
507
+ const actualTriggerEl = (triggerEl || customEv?.detail?.ionShadowTarget || customEv?.target) as HTMLElement | null;
508
+ if (!actualTriggerEl) {
509
+ return defaultPosition;
510
+ }
511
+ const triggerBoundingBox = actualTriggerEl.getBoundingClientRect();
512
+ referenceCoordinates = {
513
+ top: triggerBoundingBox.top,
514
+ left: triggerBoundingBox.left,
515
+ width: triggerBoundingBox.width,
516
+ height: triggerBoundingBox.height,
517
+ };
518
+
519
+ break;
520
+ }
521
+
522
+ /**
523
+ * Get top/left offset that would allow
524
+ * popover to be positioned on the
525
+ * preferred side of the reference.
526
+ */
527
+ const coordinates = calculatePopoverSide(side, referenceCoordinates, contentWidth, contentHeight, arrowWidth, arrowHeight, isRTL);
528
+
529
+ /**
530
+ * Get the top/left adjustments that
531
+ * would allow the popover content
532
+ * to have the correct alignment.
533
+ */
534
+ const alignedCoordinates = calculatePopoverAlign(align, side, referenceCoordinates, contentWidth, contentHeight);
535
+
536
+ const top = coordinates.top + alignedCoordinates.top;
537
+ const left = coordinates.left + alignedCoordinates.left;
538
+
539
+ const { arrowTop, arrowLeft } = calculateArrowPosition(side, arrowWidth, arrowHeight, top, left, contentWidth, contentHeight, isRTL);
540
+
541
+ const { originX, originY } = calculatePopoverOrigin(side, align, isRTL);
542
+
543
+ return { top, left, referenceCoordinates, arrowTop, arrowLeft, originX, originY };
544
+ };
545
+
546
+ /**
547
+ * Determines the transform-origin
548
+ * of the popover animation so that it
549
+ * is in line with what the side and alignment
550
+ * prop values are. Currently only used
551
+ * with the MD animation.
552
+ */
553
+ const calculatePopoverOrigin = (side: PositionSide, align: PositionAlign, isRTL: boolean) => {
554
+ switch (side) {
555
+ case 'top':
556
+ return { originX: getOriginXAlignment(align), originY: 'bottom' };
557
+ case 'bottom':
558
+ return { originX: getOriginXAlignment(align), originY: 'top' };
559
+ case 'left':
560
+ return { originX: 'right', originY: getOriginYAlignment(align) };
561
+ case 'right':
562
+ return { originX: 'left', originY: getOriginYAlignment(align) };
563
+ case 'start':
564
+ return { originX: isRTL ? 'left' : 'right', originY: getOriginYAlignment(align) };
565
+ case 'end':
566
+ return { originX: isRTL ? 'right' : 'left', originY: getOriginYAlignment(align) };
567
+ }
568
+ };
569
+
570
+ const getOriginXAlignment = (align: PositionAlign) => {
571
+ switch (align) {
572
+ case 'start':
573
+ return 'left';
574
+ case 'center':
575
+ return 'center';
576
+ case 'end':
577
+ return 'right';
578
+ }
579
+ };
580
+
581
+ const getOriginYAlignment = (align: PositionAlign) => {
582
+ switch (align) {
583
+ case 'start':
584
+ return 'top';
585
+ case 'center':
586
+ return 'center';
587
+ case 'end':
588
+ return 'bottom';
589
+ }
590
+ };
591
+
592
+ /**
593
+ * Calculates where the arrow positioning
594
+ * should be relative to the popover content.
595
+ */
596
+ const calculateArrowPosition = (
597
+ side: PositionSide,
598
+ arrowWidth: number,
599
+ arrowHeight: number,
600
+ top: number,
601
+ left: number,
602
+ contentWidth: number,
603
+ contentHeight: number,
604
+ isRTL: boolean,
605
+ ) => {
606
+ /**
607
+ * Note: When side is left, right, start, or end, the arrow is
608
+ * been rotated using a `transform`, so to move the arrow up or down
609
+ * by its dimension, you need to use `arrowWidth`.
610
+ */
611
+ const leftPosition = {
612
+ arrowTop: top + contentHeight / 2 - arrowWidth / 2,
613
+ arrowLeft: left + contentWidth - arrowWidth / 2,
614
+ };
615
+
616
+ /**
617
+ * Move the arrow to the left by arrowWidth and then
618
+ * again by half of its width because we have rotated
619
+ * the arrow using a transform.
620
+ */
621
+ const rightPosition = { arrowTop: top + contentHeight / 2 - arrowWidth / 2, arrowLeft: left - arrowWidth * 1.5 };
622
+
623
+ switch (side) {
624
+ case 'top':
625
+ return { arrowTop: top + contentHeight, arrowLeft: left + contentWidth / 2 - arrowWidth / 2 };
626
+ case 'bottom':
627
+ return { arrowTop: top - arrowHeight, arrowLeft: left + contentWidth / 2 - arrowWidth / 2 };
628
+ case 'left':
629
+ return leftPosition;
630
+ case 'right':
631
+ return rightPosition;
632
+ case 'start':
633
+ return isRTL ? rightPosition : leftPosition;
634
+ case 'end':
635
+ return isRTL ? leftPosition : rightPosition;
636
+ default:
637
+ return { arrowTop: 0, arrowLeft: 0 };
638
+ }
639
+ };
640
+
641
+ /**
642
+ * Calculates the required top/left
643
+ * values needed to position the popover
644
+ * content on the side specified in the
645
+ * `side` prop.
646
+ */
647
+ const calculatePopoverSide = (
648
+ side: PositionSide,
649
+ triggerBoundingBox: ReferenceCoordinates,
650
+ contentWidth: number,
651
+ contentHeight: number,
652
+ arrowWidth: number,
653
+ arrowHeight: number,
654
+ isRTL: boolean,
655
+ ) => {
656
+ const sideLeft = {
657
+ top: triggerBoundingBox.top,
658
+ left: triggerBoundingBox.left - contentWidth - arrowWidth,
659
+ };
660
+ const sideRight = {
661
+ top: triggerBoundingBox.top,
662
+ left: triggerBoundingBox.left + triggerBoundingBox.width + arrowWidth,
663
+ };
664
+
665
+ switch (side) {
666
+ case 'top':
667
+ return {
668
+ top: triggerBoundingBox.top - contentHeight - arrowHeight,
669
+ left: triggerBoundingBox.left,
670
+ };
671
+ case 'right':
672
+ return sideRight;
673
+ case 'bottom':
674
+ return {
675
+ top: triggerBoundingBox.top + triggerBoundingBox.height + arrowHeight,
676
+ left: triggerBoundingBox.left,
677
+ };
678
+ case 'left':
679
+ return sideLeft;
680
+ case 'start':
681
+ return isRTL ? sideRight : sideLeft;
682
+ case 'end':
683
+ return isRTL ? sideLeft : sideRight;
684
+ }
685
+ };
686
+
687
+ /**
688
+ * Calculates the required top/left
689
+ * offset values needed to provide the
690
+ * correct alignment regardless while taking
691
+ * into account the side the popover is on.
692
+ */
693
+ const calculatePopoverAlign = (
694
+ align: PositionAlign,
695
+ side: PositionSide,
696
+ triggerBoundingBox: ReferenceCoordinates,
697
+ contentWidth: number,
698
+ contentHeight: number,
699
+ ) => {
700
+ switch (align) {
701
+ case 'center':
702
+ return calculatePopoverCenterAlign(side, triggerBoundingBox, contentWidth, contentHeight);
703
+ case 'end':
704
+ return calculatePopoverEndAlign(side, triggerBoundingBox, contentWidth, contentHeight);
705
+ case 'start':
706
+ default:
707
+ return { top: 0, left: 0 };
708
+ }
709
+ };
710
+
711
+ /**
712
+ * Calculate the end alignment for
713
+ * the popover. If side is on the x-axis
714
+ * then the align values refer to the top
715
+ * and bottom margins of the content.
716
+ * If side is on the y-axis then the
717
+ * align values refer to the left and right
718
+ * margins of the content.
719
+ */
720
+ const calculatePopoverEndAlign = (
721
+ side: PositionSide,
722
+ triggerBoundingBox: ReferenceCoordinates,
723
+ contentWidth: number,
724
+ contentHeight: number,
725
+ ) => {
726
+ switch (side) {
727
+ case 'start':
728
+ case 'end':
729
+ case 'left':
730
+ case 'right':
731
+ return {
732
+ top: -(contentHeight - triggerBoundingBox.height),
733
+ left: 0,
734
+ };
735
+ case 'top':
736
+ case 'bottom':
737
+ default:
738
+ return {
739
+ top: 0,
740
+ left: -(contentWidth - triggerBoundingBox.width),
741
+ };
742
+ }
743
+ };
744
+
745
+ /**
746
+ * Calculate the center alignment for
747
+ * the popover. If side is on the x-axis
748
+ * then the align values refer to the top
749
+ * and bottom margins of the content.
750
+ * If side is on the y-axis then the
751
+ * align values refer to the left and right
752
+ * margins of the content.
753
+ */
754
+ const calculatePopoverCenterAlign = (
755
+ side: PositionSide,
756
+ triggerBoundingBox: ReferenceCoordinates,
757
+ contentWidth: number,
758
+ contentHeight: number,
759
+ ) => {
760
+ switch (side) {
761
+ case 'start':
762
+ case 'end':
763
+ case 'left':
764
+ case 'right':
765
+ return {
766
+ top: -(contentHeight / 2 - triggerBoundingBox.height / 2),
767
+ left: 0,
768
+ };
769
+ case 'top':
770
+ case 'bottom':
771
+ default:
772
+ return {
773
+ top: 0,
774
+ left: -(contentWidth / 2 - triggerBoundingBox.width / 2),
775
+ };
776
+ }
777
+ };
778
+
779
+ /**
780
+ * Adjusts popover positioning coordinates
781
+ * such that popover does not appear offscreen
782
+ * or overlapping safe area bounds.
783
+ */
784
+ export const calculateWindowAdjustment = (
785
+ side: PositionSide,
786
+ coordTop: number,
787
+ coordLeft: number,
788
+ bodyPadding: number,
789
+ bodyWidth: number,
790
+ bodyHeight: number,
791
+ contentWidth: number,
792
+ contentHeight: number,
793
+ safeAreaMargin: number,
794
+ contentOriginX: string,
795
+ contentOriginY: string,
796
+ triggerCoordinates?: ReferenceCoordinates,
797
+ coordArrowTop = 0,
798
+ coordArrowLeft = 0,
799
+ arrowHeight = 0,
800
+ eventElementRect?: DOMRect,
801
+ isReplace: boolean = false,
802
+ ): PopoverStyles => {
803
+ let arrowTop = coordArrowTop;
804
+ const arrowLeft = coordArrowLeft;
805
+ const triggerTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height : bodyHeight / 2 - contentHeight / 2;
806
+ const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0;
807
+ let left = coordLeft;
808
+ let top = !isReplace ? coordTop + POPOVER_IOS_BODY_MARGIN : coordTop - triggerHeight;
809
+ let bottom;
810
+ let originX = contentOriginX;
811
+ let originY = contentOriginY;
812
+ let checkSafeAreaLeft = false;
813
+ let checkSafeAreaRight = false;
814
+ let addPopoverBottomClass = false;
815
+
816
+ /**
817
+ * Adjust popover so it does not
818
+ * go off the left of the screen.
819
+ */
820
+ if (left < bodyPadding + safeAreaMargin) {
821
+ left = !eventElementRect ? bodyPadding : eventElementRect.left;
822
+ checkSafeAreaLeft = true;
823
+ originX = 'left';
824
+ /**
825
+ * Adjust popover so it does not
826
+ * go off the right of the screen.
827
+ */
828
+ } else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
829
+ checkSafeAreaRight = true;
830
+ left = !eventElementRect ? bodyWidth - contentWidth - bodyPadding : eventElementRect.right - contentWidth;
831
+ originX = 'right';
832
+ }
833
+
834
+ /**
835
+ * Adjust popover so it does not
836
+ * go off the top of the screen.
837
+ * If popover is on the left or the right of
838
+ * the trigger, then we should not adjust top
839
+ * margins.
840
+ */
841
+ const compareTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height / 2 : bodyHeight / 2 - contentHeight / 2;
842
+ if (compareTop > bodyHeight / 2 && (side === 'top' || side === 'bottom')) {
843
+ if (triggerTop - contentHeight > 0) {
844
+ /**
845
+ * While we strive to align the popover with the trigger
846
+ * on smaller screens this is not always possible. As a result,
847
+ * we adjust the popover up so that it does not hang
848
+ * off the bottom of the screen. However, we do not want to move
849
+ * the popover up so much that it goes off the top of the screen.
850
+ *
851
+ * We chose 12 here so that the popover position looks a bit nicer as
852
+ * it is not right up against the edge of the screen.
853
+ */
854
+ if (!isReplace) {
855
+ top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1)) - POPOVER_IOS_BODY_MARGIN;
856
+ } else {
857
+ top = Math.max(12, triggerTop - contentHeight - (arrowHeight - 1));
858
+ }
859
+ arrowTop = top + contentHeight;
860
+ originY = 'bottom';
861
+ addPopoverBottomClass = true;
862
+
863
+ /**
864
+ * If not enough room for popover to appear
865
+ * above trigger, then cut it off.
866
+ */
867
+ } else {
868
+ bottom = bodyPadding;
869
+ }
870
+ }
871
+
872
+ return {
873
+ top,
874
+ left,
875
+ bottom,
876
+ originX,
877
+ originY,
878
+ checkSafeAreaLeft,
879
+ checkSafeAreaRight,
880
+ arrowTop,
881
+ arrowLeft,
882
+ addPopoverBottomClass,
883
+ };
884
+ };
885
+
886
+ export const shouldShowArrow = (side: PositionSide, didAdjustBounds = false, ev?: Event, trigger?: HTMLElement) => {
887
+ /**
888
+ * If no event provided and
889
+ * we do not have a trigger,
890
+ * then this popover was likely
891
+ * presented via the popoverController
892
+ * or users called `present` manually.
893
+ * In this case, the arrow should not be
894
+ * shown as we do not have a reference.
895
+ */
896
+ if (!ev && !trigger) {
897
+ return false;
898
+ }
899
+
900
+ /**
901
+ * If popover is on the left or the right
902
+ * of a trigger, but we needed to adjust the
903
+ * popover due to screen bounds, then we should
904
+ * hide the arrow as it will never be pointing
905
+ * at the trigger.
906
+ */
907
+ if (side !== 'top' && side !== 'bottom' && didAdjustBounds) {
908
+ return false;
909
+ }
910
+
911
+ return true;
912
+ };