feat(modal): enhance modal behavior and viewport handling for iOS devices

This commit is contained in:
ncoronel 2026-09-02 15:17:32 -03:00
parent 71ef09b413
commit 6f87aae939
6 changed files with 120 additions and 11 deletions

View File

@ -51,6 +51,10 @@ describe('ModalHostComponent', () => {
afterEach(() => { afterEach(() => {
doc.body.style.overflow = ''; doc.body.style.overflow = '';
doc.body.style.paddingRight = ''; doc.body.style.paddingRight = '';
doc.body.style.position = '';
doc.body.style.top = '';
doc.body.style.left = '';
doc.body.style.width = '';
TestBed.resetTestingModule(); TestBed.resetTestingModule();
}); });
@ -99,6 +103,10 @@ describe('ModalHostComponent', () => {
expect(service.activeModal()).toBeNull(); expect(service.activeModal()).toBeNull();
expect(doc.body.style.overflow).toBe(''); expect(doc.body.style.overflow).toBe('');
expect(doc.body.style.paddingRight).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', () => { it('closes on backdrop click when enabled', () => {

View File

@ -55,11 +55,23 @@ export class ModalHostComponent {
const body = this.document.body; const body = this.document.body;
const previousOverflow = body.style.overflow; const previousOverflow = body.style.overflow;
const previousPaddingRight = body.style.paddingRight; 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 view = this.document.defaultView;
const scrollX = view?.scrollX ?? 0;
const scrollY = view?.scrollY ?? 0;
const scrollbarWidth = view const scrollbarWidth = view
? Math.max(0, view.innerWidth - this.document.documentElement.clientWidth) ? Math.max(0, view.innerWidth - this.document.documentElement.clientWidth)
: 0; : 0;
const activeElement = this.document.activeElement;
if (activeElement instanceof HTMLElement && activeElement !== body) {
activeElement.blur();
}
if (scrollbarWidth > 0 && view) { if (scrollbarWidth > 0 && view) {
const currentPaddingRight = const currentPaddingRight =
Number.parseFloat(view.getComputedStyle(body).paddingRight) || 0; Number.parseFloat(view.getComputedStyle(body).paddingRight) || 0;
@ -68,6 +80,47 @@ export class ModalHostComponent {
body.style.overflow = 'hidden'; 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) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !this.activeModal()?.config.closeOnEscape) { if (event.key !== 'Escape' || !this.activeModal()?.config.closeOnEscape) {
return; return;
@ -82,8 +135,26 @@ export class ModalHostComponent {
onCleanup(() => { onCleanup(() => {
this.document.removeEventListener('keydown', onKeyDown); 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.overflow = previousOverflow;
body.style.paddingRight = previousPaddingRight; 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);
}
}); });
}); });
} }

View File

@ -1,4 +1,5 @@
<div <div
#shell
class="modal-shell" class="modal-shell"
[class.modal-shell--fullscreen-media]="presentation() === 'fullscreen-media'" [class.modal-shell--fullscreen-media]="presentation() === 'fullscreen-media'"
(click)="backdropClick.emit()" (click)="backdropClick.emit()"

View File

@ -4,7 +4,10 @@
.modal-shell { .modal-shell {
position: fixed; 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; z-index: 2000;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -19,6 +19,7 @@ import { ModalPresentation, ModalSize } from '../../../core/services/modal.servi
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ModalShellComponent { export class ModalShellComponent {
private readonly shell = viewChild.required<ElementRef<HTMLElement>>('shell');
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel'); private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
readonly title = input<string | undefined>(); readonly title = input<string | undefined>();
@ -47,6 +48,28 @@ export class ModalShellComponent {
return this.title() ? this.titleId : null; 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 { focusInitialElement(): void {
const panel = this.panel().nativeElement; const panel = this.panel().nativeElement;
const focusTarget = panel.querySelector<HTMLElement>( const focusTarget = panel.querySelector<HTMLElement>(

View File

@ -1,13 +1,16 @@
<!doctype html> <!doctype html>
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<title>ShopitFront</title> <title>ShopitFront</title>
<base href="/"> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"> name="viewport"
</head> content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
<body> />
<app-root></app-root> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
</body> </head>
<body>
<app-root></app-root>
</body>
</html> </html>