feat(modal): enhance modal behavior and viewport handling for iOS devices
This commit is contained in:
@@ -51,6 +51,10 @@ describe('ModalHostComponent', () => {
|
||||
afterEach(() => {
|
||||
doc.body.style.overflow = '';
|
||||
doc.body.style.paddingRight = '';
|
||||
doc.body.style.position = '';
|
||||
doc.body.style.top = '';
|
||||
doc.body.style.left = '';
|
||||
doc.body.style.width = '';
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
@@ -99,6 +103,10 @@ describe('ModalHostComponent', () => {
|
||||
expect(service.activeModal()).toBeNull();
|
||||
expect(doc.body.style.overflow).toBe('');
|
||||
expect(doc.body.style.paddingRight).toBe('');
|
||||
expect(doc.body.style.position).toBe('');
|
||||
expect(doc.body.style.top).toBe('');
|
||||
expect(doc.body.style.left).toBe('');
|
||||
expect(doc.body.style.width).toBe('');
|
||||
});
|
||||
|
||||
it('closes on backdrop click when enabled', () => {
|
||||
|
||||
@@ -55,11 +55,23 @@ export class ModalHostComponent {
|
||||
const body = this.document.body;
|
||||
const previousOverflow = body.style.overflow;
|
||||
const previousPaddingRight = body.style.paddingRight;
|
||||
const previousPosition = body.style.position;
|
||||
const previousTop = body.style.top;
|
||||
const previousLeft = body.style.left;
|
||||
const previousWidth = body.style.width;
|
||||
const view = this.document.defaultView;
|
||||
const scrollX = view?.scrollX ?? 0;
|
||||
const scrollY = view?.scrollY ?? 0;
|
||||
const scrollbarWidth = view
|
||||
? Math.max(0, view.innerWidth - this.document.documentElement.clientWidth)
|
||||
: 0;
|
||||
|
||||
const activeElement = this.document.activeElement;
|
||||
|
||||
if (activeElement instanceof HTMLElement && activeElement !== body) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
if (scrollbarWidth > 0 && view) {
|
||||
const currentPaddingRight =
|
||||
Number.parseFloat(view.getComputedStyle(body).paddingRight) || 0;
|
||||
@@ -68,6 +80,47 @@ export class ModalHostComponent {
|
||||
|
||||
body.style.overflow = 'hidden';
|
||||
|
||||
const isIos = view
|
||||
? /iPad|iPhone|iPod/.test(view.navigator.userAgent) ||
|
||||
(view.navigator.platform === 'MacIntel' && view.navigator.maxTouchPoints > 1)
|
||||
: false;
|
||||
|
||||
if (isIos) {
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `${-scrollY}px`;
|
||||
body.style.left = `${-scrollX}px`;
|
||||
body.style.width = '100%';
|
||||
}
|
||||
|
||||
let viewportFrame: number | undefined;
|
||||
const viewportSyncTimers: number[] = [];
|
||||
const syncVisualViewport = () => {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewportFrame !== undefined) {
|
||||
view.cancelAnimationFrame(viewportFrame);
|
||||
}
|
||||
|
||||
viewportFrame = view.requestAnimationFrame(() => {
|
||||
viewportFrame = undefined;
|
||||
this.modalShell()?.setVisualViewport(view.visualViewport, view.scrollX, view.scrollY);
|
||||
});
|
||||
};
|
||||
|
||||
view?.visualViewport?.addEventListener('resize', syncVisualViewport);
|
||||
view?.visualViewport?.addEventListener('scroll', syncVisualViewport);
|
||||
view?.addEventListener('orientationchange', syncVisualViewport);
|
||||
syncVisualViewport();
|
||||
|
||||
if (view) {
|
||||
viewportSyncTimers.push(
|
||||
view.setTimeout(syncVisualViewport, 100),
|
||||
view.setTimeout(syncVisualViewport, 300),
|
||||
);
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || !this.activeModal()?.config.closeOnEscape) {
|
||||
return;
|
||||
@@ -82,8 +135,26 @@ export class ModalHostComponent {
|
||||
|
||||
onCleanup(() => {
|
||||
this.document.removeEventListener('keydown', onKeyDown);
|
||||
view?.visualViewport?.removeEventListener('resize', syncVisualViewport);
|
||||
view?.visualViewport?.removeEventListener('scroll', syncVisualViewport);
|
||||
view?.removeEventListener('orientationchange', syncVisualViewport);
|
||||
|
||||
if (viewportFrame !== undefined) {
|
||||
view?.cancelAnimationFrame(viewportFrame);
|
||||
}
|
||||
|
||||
viewportSyncTimers.forEach((timer) => view?.clearTimeout(timer));
|
||||
|
||||
body.style.overflow = previousOverflow;
|
||||
body.style.paddingRight = previousPaddingRight;
|
||||
body.style.position = previousPosition;
|
||||
body.style.top = previousTop;
|
||||
body.style.left = previousLeft;
|
||||
body.style.width = previousWidth;
|
||||
|
||||
if (isIos) {
|
||||
view?.scrollTo(scrollX, scrollY);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div
|
||||
#shell
|
||||
class="modal-shell"
|
||||
[class.modal-shell--fullscreen-media]="presentation() === 'fullscreen-media'"
|
||||
(click)="backdropClick.emit()"
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
.modal-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: var(--modal-viewport-top, 0);
|
||||
left: var(--modal-viewport-left, 0);
|
||||
width: var(--modal-viewport-width, 100vw);
|
||||
height: var(--modal-viewport-height, 100dvh);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ModalPresentation, ModalSize } from '../../../core/services/modal.servi
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ModalShellComponent {
|
||||
private readonly shell = viewChild.required<ElementRef<HTMLElement>>('shell');
|
||||
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
|
||||
|
||||
readonly title = input<string | undefined>();
|
||||
@@ -47,6 +48,28 @@ export class ModalShellComponent {
|
||||
return this.title() ? this.titleId : null;
|
||||
}
|
||||
|
||||
setVisualViewport(viewport: VisualViewport | null, layoutScrollX = 0, layoutScrollY = 0): void {
|
||||
const shell = this.shell().nativeElement;
|
||||
|
||||
if (!viewport) {
|
||||
shell.style.removeProperty('--modal-viewport-top');
|
||||
shell.style.removeProperty('--modal-viewport-left');
|
||||
shell.style.removeProperty('--modal-viewport-width');
|
||||
shell.style.removeProperty('--modal-viewport-height');
|
||||
return;
|
||||
}
|
||||
|
||||
// pageTop/pageLeft are a useful fallback for WebKit versions that update
|
||||
// offsetTop/offsetLeft one frame late after dismissing a native control.
|
||||
const top = Math.max(viewport.offsetTop, viewport.pageTop - layoutScrollY);
|
||||
const left = Math.max(viewport.offsetLeft, viewport.pageLeft - layoutScrollX);
|
||||
|
||||
shell.style.setProperty('--modal-viewport-top', `${top}px`);
|
||||
shell.style.setProperty('--modal-viewport-left', `${left}px`);
|
||||
shell.style.setProperty('--modal-viewport-width', `${viewport.width}px`);
|
||||
shell.style.setProperty('--modal-viewport-height', `${viewport.height}px`);
|
||||
}
|
||||
|
||||
focusInitialElement(): void {
|
||||
const panel = this.panel().nativeElement;
|
||||
const focusTarget = panel.querySelector<HTMLElement>(
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>ShopitFront</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>ShopitFront</title>
|
||||
<base href="/" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user