Modal
oge-modal is the centered dialog primitive: backdrop, focus trap, body scroll lock, Escape/backdrop closing and focus restore. Content renders lazily behind the opened model and joins the shared overlay Escape stack, so popups opened inside the modal close before the modal itself. Declare it near the component root — transformed ancestors break position: fixed.
Basics
Open declaratively via the two-way opened model or imperatively with open()/close()/toggle(). The footer slot's let close function closes the modal; Escape, backdrop clicks and the ✕ button work out of the box and focus returns to the opener.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeButton } from '@oge-ui/buttons';
import { OgeModal, OgeModalFooter } from '@oge-ui/overlay';
@Component({
selector: 'demo-root',
imports: [OgeButton, OgeModal, OgeModalFooter],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-button text="Open" (clicked)="modal.open()" />
<oge-modal #modal title="Team settings" [(opened)]="opened">
<p>Centered dialog with backdrop, focus trap and scroll lock.</p>
<div *ogeModalFooter="let close">
<oge-button text="Cancel" stylingMode="text" (clicked)="close()" />
<oge-button text="Save" (clicked)="close()" />
</div>
</oge-modal>
<!-- Escape and backdrop clicks close it (closeOnEscape /
closeOnBackdropClick); focus returns to the opener. -->
`,
})
export class Demo {
protected readonly opened = signal(false);
}Form content & stacked popups
Any content projects into the body — including dropdown editors. Their popups render above the modal, and the shared Escape stack closes the topmost surface first: one Escape for the open select popup, a second for the modal.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeSelectBox } from '@oge-ui/inputs';
import { OgeModal } from '@oge-ui/overlay';
@Component({
selector: 'demo-root',
imports: [OgeSelectBox, OgeModal],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-modal title="Edit record" [(opened)]="opened" [width]="420">
<oge-select-box label="Status" [items]="statuses" [(value)]="status" />
<!-- the select popup renders above the modal; the first Escape
closes the popup, the second closes the modal -->
</oge-modal>
`,
})
export class Demo {
protected readonly opened = signal(false);
protected readonly statuses = ['Draft', 'In review', 'Published'];
protected readonly status = signal<unknown>('Draft');
}Full screen, placement & sizing
showMaximizeButton puts a maximize/restore toggle in the title bar, driving the two-way fullScreen model (size inputs are ignored while full screen). placement="top" pins the dialog near the top edge — command-palette style — and [shading]="false" keeps the backdrop transparent while staying fully modal. Sizing accepts width/height plus min/max variants, as numbers (px) or CSS strings.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeModal } from '@oge-ui/overlay';
@Component({
selector: 'demo-root',
imports: [OgeModal],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- maximize/restore toggle in the title bar drives [(fullScreen)] -->
<oge-modal title="Report" [(opened)]="opened" [(fullScreen)]="max"
[showMaximizeButton]="true"
[width]="480" [minHeight]="240" [maxWidth]="'90vw'" />
<!-- pinned near the top edge, command-palette style -->
<oge-modal title="Search" [(opened)]="search" placement="top" />
<!-- transparent backdrop — still modal (focus trap + scroll lock) -->
<oge-modal title="Quiet" [(opened)]="quiet" [shading]="false" />
`,
})
export class Demo {
protected readonly opened = signal(false);
protected readonly search = signal(false);
protected readonly quiet = signal(false);
protected readonly max = signal(false);
}Window mode & modal service
dragEnabled makes the title bar a drag handle (viewport-clamped unless dragOutsideBoundary), resizeEnabled adds a corner handle with resizeStarted/resized events, and restorePosition resets both on reopen. For imperative flows — or transformed ancestors — OgeModalService.open(component, config) renders a body-appended modal; the content injects OGE_MODAL_DATA and closes itself via OgeModalRef, whose closed promise carries the typed result. inertBackground additionally marks the page behind the modal inert.
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { OgeModal, OGE_MODAL_DATA, OgeModalRef, OgeModalService } from '@oge-ui/overlay';
import { Injectable } from '@angular/core';
import type { OgeModalResizeEvent } from '@oge-ui/overlay';
@Component({
selector: 'demo-root',
imports: [OgeModal],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- drag by the title bar, resize by the corner handle -->
<oge-modal title="Window" [(opened)]="opened"
[dragEnabled]="true" [resizeEnabled]="true"
(resized)="onResized($event)" />
`,
})
export class Demo {
protected readonly opened = signal(false);
protected onResized(event: OgeModalResizeEvent): void {
console.log(event.width, event.height);
}
// imperative, body-appended — for transformed ancestors & prompt flows
private readonly modals = inject(OgeModalService);
protected async openPrompt(): Promise<void> {
const ref = this.modals.open<string>(RenameDialog, {
title: 'Rename file',
width: 380,
data: { name: 'report.xlsx' },
});
const { result } = await ref.closed;
if (result) this.rename(result);
}
private rename(name: string): void {
console.log('renamed to', name);
}
}
// content component: inject its data + the ref to close with a result
@Component({
selector: 'demo-rename-dialog',
template: `<p>Renaming {{ data.name }}</p>`,
})
export class RenameDialog {
readonly data = inject<{ name: string }>(OGE_MODAL_DATA);
readonly ref = inject<OgeModalRef<string>>(OgeModalRef);
}Async close guard
closeGuard runs before every close — Escape, backdrop, ✕ and close() alike — and may return a Promise<boolean>: the modal stays open until it resolves, single-flight guarded. No other library covers the async unsaved-changes veto without hand-rolled plumbing. A direct opened model write bypasses the guard (the app already decided).
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeModal } from '@oge-ui/overlay';
@Component({
selector: 'demo-root',
imports: [OgeModal],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-modal title="Draft" [(opened)]="opened" [closeGuard]="confirmDiscard">
<p>Unsaved work lives here.</p>
</oge-modal>
`,
})
export class Demo {
protected readonly opened = signal(false);
protected readonly dirty = signal(true);
// runs for every close reason (Escape, backdrop, ✕, close());
// may be async: the modal stays open until the promise resolves
protected readonly confirmDiscard = (): boolean =>
!this.dirty() || confirm('Discard unsaved changes?');
}Busy state
While busy is true the modal shows a spinner veil, sets aria-busy and blocks user-initiated closes — programmatic close() still works, so finish your async work and close. Pairs naturally with the button family's async action.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeModal } from '@oge-ui/overlay';
@Component({
selector: 'demo-root',
imports: [OgeModal],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-modal title="Publishing…" [(opened)]="opened" [busy]="saving()">
<!-- spinner veil + aria-busy; Escape/backdrop/✕ are blocked
while busy — programmatic close() still works -->
</oge-modal>
`,
})
export class Demo {
protected readonly opened = signal(false);
protected readonly saving = signal(true);
}Typed result
close(result) — from code or the footer slot — carries a typed value into closed, alongside the close reason. Declarative confirm/prompt flows no longer need side-channel component state.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeButton } from '@oge-ui/buttons';
import { OgeModal, OgeModalFooter } from '@oge-ui/overlay';
import type { OgeModalClosedEvent } from '@oge-ui/overlay';
@Component({
selector: 'demo-root',
imports: [OgeButton, OgeModal, OgeModalFooter],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-modal #confirm title="Delete file?" [(opened)]="opened"
(closed)="onClosed($event)">
<p>This cannot be undone.</p>
<div *ogeModalFooter="let close">
<oge-button text="Cancel" stylingMode="text" (clicked)="close()" />
<oge-button text="Delete" severity="danger" (clicked)="close('delete')" />
</div>
</oge-modal>
`,
})
export class Demo {
protected readonly opened = signal(false);
// $event: { reason: 'api' | 'escape' | 'backdrop' | 'closeButton', result? }
protected onClosed(event: OgeModalClosedEvent): void {
if (event.result === 'delete') this.remove();
}
private remove(): void {
console.log('deleted');
}
}Notes
autoFocuspicks the initial focus target:'first-tabbable'(default),'panel', or any CSS selector; an[autofocus]element always wins.- Headerless modals (
showCloseButton=false, no title) should setariaLabel;[padding]="false"makes the body flush for grids and custom layouts. - The ✕ button's aria label localizes via
provideOgeOverlayConfig({ messages: { modalClose: '…' } })or the per-instancemessagesinput. - Body scroll locks while open (scrollbar-width compensated, ref-counted across stacked modals) — disable with
[scrollLock]="false".