@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.
- package/README.md +35 -1
- package/dist/css/components/ion-alert.css +1 -1
- package/dist/css/components/ion-popover.css +1 -1
- package/dist/css/ionic-theme-ios26.css +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/popover/animations/ios.enter.d.ts +4 -0
- package/dist/popover/animations/ios.enter.d.ts.map +1 -0
- package/dist/popover/animations/ios.enter.js +111 -0
- package/dist/popover/animations/ios.leave.d.ts +3 -0
- package/dist/popover/animations/ios.leave.d.ts.map +1 -0
- package/dist/popover/animations/ios.leave.js +60 -0
- package/dist/popover/popover-interface.d.ts +38 -0
- package/dist/popover/popover-interface.d.ts.map +1 -0
- package/dist/popover/popover-interface.js +1 -0
- package/dist/popover/utils.d.ts +48 -0
- package/dist/popover/utils.d.ts.map +1 -0
- package/dist/popover/utils.js +479 -0
- package/dist/sheets-of-glass/animations.d.ts +8 -0
- package/dist/sheets-of-glass/animations.d.ts.map +1 -0
- package/dist/sheets-of-glass/animations.js +97 -0
- package/dist/sheets-of-glass/index.d.ts +3 -0
- package/dist/sheets-of-glass/index.d.ts.map +1 -0
- package/dist/sheets-of-glass/index.js +160 -0
- package/dist/sheets-of-glass/interfaces.d.ts +16 -0
- package/dist/sheets-of-glass/interfaces.d.ts.map +1 -0
- package/dist/sheets-of-glass/interfaces.js +1 -0
- package/dist/utils.d.ts +5 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +26 -11
- package/package.json +1 -1
- package/src/index.ts +5 -3
- package/src/popover/animations/ios.enter.ts +176 -0
- package/src/popover/animations/ios.leave.ts +76 -0
- package/src/popover/popover-interface.ts +44 -0
- package/src/popover/utils.ts +912 -0
- package/src/{gestures → sheets-of-glass}/animations.ts +3 -3
- package/src/{gestures → sheets-of-glass}/index.ts +1 -1
- package/src/styles/components/ion-alert.scss +1 -0
- package/src/styles/components/ion-popover.scss +6 -0
- package/src/{gestures/utils.ts → utils.ts} +18 -1
- /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
|
+
};
|