Dialog migration: Angular Material dialogs to m3e-angular¶
What this is: the delivery plan for #4599: every SyRF
dialog moves from Angular Material's MatDialog to m3e-angular's Material 3 Expressive MatExpDialog
(ADR-027). Owner direction (2026-10-27): "all dialog components in
syrf should be replaced with the m3e-angular version". This page is temporary; delete it when the programme ends.
Design¶
m3e-angular 0.1.0-alpha.7 opens a component with MatExpDialog.open(component, { mode, title, ... }), where mode
is 'adaptive' (tasks and forms: close button, full screen below 600px) or 'alert' (short confirmation, needs
role and ariaDescribedBy). Content uses <mat-exp-dialog-layout>, <ng-template matExpDialogActions>,
[matExpDialogClose] and MatExpDialogRef, which is not type-identical to MatDialogRef. MAT_DIALOG_DATA works
under both.
SyRF wraps that in src/services/web/src/app/shared/dialog/:
| Piece | Role |
|---|---|
SyrfDialogService |
The one opener. Reads expressiveDialogs once, at open: on, it calls MatExpDialog.open; off, it opens the same component with MatDialog, as before. Adaptive dialogs drop per-dialog sizes under m3e, which owns that geometry. Adds the panel class syrf-dialog-panel in both modes (an E2E hook). getDialogById searches both openers. |
<syrf-dialog-layout> + <ng-template syrfDialogActions> |
The frame: <mat-exp-dialog-layout> under m3e; the native title, content and actions under MatDialog. The heading is always the opener's title. |
[syrfDialogClose] |
Closes with a result under either opener (a bare marker closes with undefined; type="button" by default). |
injectSyrfDialogRef() |
The content's dialog reference from whichever opener opened it (SyrfDialogRef: close, afterClosed, beforeClosed, afterOpened, backdropClick, keydownEvents, disableClose, getState). |
Because content renders under either opener, the flag lives in one place, and switching it never affects an open dialog.
Regression guard: shared/dialog/no-legacy-mat-dialog.spec.ts fails when a file outside
legacy-mat-dialog.allowlist.json uses MatDialog, MatDialogRef, the native dialog directives or Material's dialog
classes in styles. The allow-list records each remaining file's wave and only shrinks (the guard also fails when a
listed file is already migrated). The two shared files keep their native branch, as justified exceptions, until the
flag retires.
Migrating one dialog¶
- Template: wrap the body in
<syrf-dialog-layout>and move the buttons into<ng-template syrfDialogActions>. Delete the native title element; the opener'stitleis the heading. ReplacefxLayouton the old content element with component SCSS. - Close:
[mat-dialog-close]="x"becomes[syrfDialogClose]="x", and an injectedMatDialogRefbecomesinjectSyrfDialogRef(). Keep every result value exactly as it was. A baremat-dialog-closeclosed with'', but a baresyrfDialogClosecloses withundefined(like m3e and Escape). Check the openers: if none tells''fromundefined(most only test truthiness), use the bare marker; otherwise bind[syrfDialogClose]="''". - Opener: inject
SyrfDialogService; passmode('adaptive'unless it is a short alert) and thetitlethe template used to show (export it as a constant next to the component when it is static). Keepdata,disableClose,autoFocus,restoreFocusandidas they were. - Specs: content specs provide
MatDialogRef(or open through the real service with the flag both ways); opener specs mockSyrfDialogServiceand assert the component,modeandtitle. - Remove the migrated files from the allow-list; update any E2E page object that targets
mat-dialog-containerto.syrf-dialog-panel(both modes).
Waves¶
One PR per wave, each ready for review, merged in order. Each wave lands with the flag off everywhere.
| Wave | Scope | PR | Notes |
|---|---|---|---|
| W0 | Shared layer, flag, guard; DeleteModalComponent, ErrorDialogComponent and their openers |
#4619 | |
| W1 | Rest of shared/dialogs (confirmation, info confirmation, access denied, permissions, stage overview, surplus notification, version info) and every opener of them |
#4629 | Confirmation and info confirmation have about 22 opener files |
| W2 | core (environment warning, PR description, error recovery, version check, signed out, unauthenticated) and every opener of them |
#4635 | Version check, signed out and unauthenticated had no opener or route and are deleted, not migrated. nav.component.ts stays listed for its W4/W5 dialogs; create-stage.service.ts stays until #4608 merges |
| W3 | project/project-overview, project/project-admin: create question, bulk confirmation, create project group, edit entity, invite members, bulk study update, and every opener of them (including the study table's bulk update action) |
#4637 | Search error, create search, delete search and create stage are opened only by systematic-searches.component.ts and create-stage.service.ts, which #4608 is rewriting; under a raw MatDialog.open a migrated dialog has no heading, so they wait (W3b after #4608). The bulk PDF dialog waits for #4574 (W3b after #4574) |
| W4 | stage, shared/annotation, screening, studies |
AF2 rules apply (.claude/rules/web-af2.md) |
|
| W5 | project/data-export, project-index, manage, admin, shared/beta-features |
||
| W6 | Allow-list empty (or justified exceptions); flag settles and the native branch is deleted | Owner decides when the flag switches on |
Acceptance criteria¶
| # | Criterion | Verification |
|---|---|---|
| AC1 | With expressiveDialogs off, every migrated dialog opens through MatDialog with the same title, content, actions, results, dismissal and focus behaviour as before |
Content and opener specs per wave; the flag-off cases in syrf-dialog.service.spec.ts |
| AC2 | With it on, every migrated dialog opens through MatExpDialog: Expressive surface, close button, full screen below 600px, Escape and backdrop dismissal unchanged |
Flag-on specs; preview screenshots at 1440px and 390px per wave |
| AC3 | Switching on, off and on is safe; an open dialog keeps the presentation it opened with | Round-trip spec in syrf-dialog.service.spec.ts |
| AC4 | Result values are unchanged for every dialog (including undefined on dismissal) |
Per-dialog specs asserting afterClosed() values |
| AC5 | No file uses native dialog APIs outside the allow-list and its justified exceptions; the allow-list only shrinks | no-legacy-mat-dialog.spec.ts |
| AC6 | E2E selectors work in both modes | Page objects target .syrf-dialog-panel; E2E smoke on the wave touching them |
| AC7 | Programme complete: allow-list empty apart from justified exceptions | Guard spec on main |
Open questions and library gaps¶
- Flag (DM-1). Applied: one default-off flag,
expressiveDialogs, read only bySyrfDialogService. The repo rules call for a kill switch on a programme of visible changes built on a pre-release library, and require main to stay promotable while waves land. This was not an owner answer. Dropping the flag later is a small change in one file. - Wide dialogs. m3e's adaptive dialog is 560px wide (the stepper is 880px). Dialogs that need more (tables, image cropper, graph tools) are assessed in their wave; a missing width option is a library gap, fixed in m3e-angular rather than worked around here.
- Plain confirmations.
MatExpDialog.confirm()takes plain text only. SyRF's confirmation dialogs take HTML and a third button, so W1 keeps them as components on<syrf-dialog-layout>.
Inventory¶
On main at ac4d774339 (2026-10-27), 148 non-spec files referenced native dialog APIs: about 50 dialog content
components and about 55 opener files. The live list, with each file's wave, is legacy-mat-dialog.allowlist.json.