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 (pinned at 0.2.0-alpha.6 since #4687) 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 and positions (width, height, min/max sizes, position) under m3e, which owns that geometry; they take effect only in the native dialog. The exception is a width in pixels wider than m3e's 560px default, which becomes m3e's adaptiveWidth (the centred width at 600px and above, capped at the viewport less 32px); percentages, viewport units and min()/calc() keep the default. A caller can also pass adaptiveWidth itself (m3e only; the native dialog ignores it, and alert dialogs drop it and compactActions). Widths are checked first (a plain positive length or percentage that the browser's CSS.supports accepts, as m3e checks), so nothing SyRF passes can make m3e throw. Adaptive dialogs put their actions in a bottom bar below 600px (compactActions: 'footer') unless the caller passes 'header'. 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). |
closeButtonClicks(ref), dismissRequests(ref) |
disableClose blocks m3e's close button (×) as well as Escape and the backdrop (camaradesuk/m3e-angular#108). closeButtonClicks emits on every × click under m3e and never under the native dialog, which has no ×; dismissRequests merges it with backdrop clicks and Escape without a modifier key. A dialog that sets disableClose must route the × to one of its own actions, or the × does nothing: error recovery, create question and create search close; the bulk PDF upload dialog sends all three to requestClose(), which asks before closing a live upload. disable-close-routes-close-button.spec.ts fails when an adaptive disableClose dialog routes neither (exceptions, with a reason, in disable-close-routing.allowlist.json). They are functions, not SyrfDialogRef members, because the native branch returns Angular Material's own MatDialogRef. |
openConfirmation, openInfoConfirmation, openDangerConfirmation (syrf-confirmations.ts) |
The only openers of the three confirmation kinds (DM-3). Each opens in m3e alert mode with an explicit role (dialog; alertdialog for an info confirmation with negativeAction and for every danger confirmation), a unique id and ariaDescribedBy: id + '-message'; the content renders its message element with that id. Openers pass only data and, where they had them, disableClose, autoFocus, restoreFocus and width. Under the native dialog (flag off) role and ariaDescribedBy apply to the native container too. |
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'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. A confirmation does not open a component of its own: it callsopenConfirmation(HTML content, optional third button),openInfoConfirmation(information box, text, list) oropenDangerConfirmation(an irreversible destructive action the user enables by typing an exact string) fromshared/dialog/syrf-confirmations.ts, which own the alert-mode presentation. - Specs: content specs provide
MatDialogRef(or open through the real service with the flag both ways); opener specs mockSyrfDialogServiceand assert the component,modeandtitle(for a confirmation: the component,mode: 'alert',roleand anariaDescribedByending-message). - 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 | DeleteModalComponent became DangerConfirmationDialogComponent in W6a |
| 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) |
| W3b | Search error, create search (the legacy path while searchImportWizard is off), delete search, create stage, and their openers (systematic-searches.component.ts, create-stage.service.ts) |
#4653 | systematic-searches.component.ts stayed listed because it still opened the bulk PDF dialog (migrated in W3b-2); the search import wizard and its launcher are unchanged |
| W3b-2 | Bulk PDF upload dialog (BulkPdfUploadDialogComponent) and its opener in systematic-searches.component.ts |
#4681 | Owner decision (2026-10-29): migrated before #4574 rather than after it; whichever lands second resolves the conflict. Esc and the backdrop still go through the dialog's requestClose() in both modes (a live upload asks first). Under the native dialog it keeps its own close button, pinned top right; under m3e, m3e's header close button closes it without asking, because it ignores disableClose (camaradesuk/m3e-angular#108); closing is the existing "watch in background" path, and the transfer and leave warning live in the root store (superseded by L1, #4687: disableClose now blocks the × too, and the dialog routes it through requestClose(), so it asks before closing a live upload). Its footer (note and actions) fills the actions row. Button labels are sentence case. systematic-searches.component.ts leaves the allow-list |
| W4 | stage, shared/annotation, screening, studies: review preferences, review progress, save annotation, stage progress, member time progress, unit delete, outcome cell editor, data extraction, graph extraction, graph selector, and every opener of them |
#4638 | AF2 rules apply (.claude/rules/web-af2.md). REVIEW_PANEL_DOCUMENT_PROVIDERS also scopes SyrfDialogService and MatExpDialog, so a dialog opened from a review panel in a separate window opens there; that file keeps its panel-scoped MatDialog until the flag retires (W6). Graph extraction (full screen) and the graph selector (PDF pages at scale 1) need more than 560px: width gaps for m3e-angular |
| W5 | project/data-export, project-index, manage, admin, shared/beta-features: question selection, export error details, unmask blinding, create project wizard, join project, add email, image cropper, confirm impersonation, runtime flag change and policy, project beta confirm, GUID decode, and every opener of them |
#4639 | The test dialog and ProjectInfoDialogEntryComponent had no opener or route and are deleted. The wizard keeps its steps (each with its own actions row) inside <syrf-dialog-layout>; add email and the runtime policy dialog name later steps or outcomes in the body. The runtime flag change dialog's Cancel keeps closing with '', which its opener tells apart from undefined. Nav's openers all go through SyrfDialogService now, so nav.component.ts leaves the allow-list |
| W6a | Confirmations in m3e alert mode (DM-3, #4643): openConfirmation, openInfoConfirmation and openDangerConfirmation in shared/dialog/syrf-confirmations.ts; every opener of the confirmation, info confirmation and delete modal moves to them; DeleteModalComponent is generalised into DangerConfirmationDialogComponent (typed exact-string confirmation; project delete and delete search) |
#4654 | Delete search's own dialog (DeleteSearchComponent, from W3b) is deleted; its opener in systematic-searches.component.ts dispatches the removal once the danger confirmation closes with true. Names in warningHtml go through escapeHtml, so they show as typed. Openers in W4/W5 files already use the helpers |
| L1 | Adopt m3e-angular 0.2.0-alpha.6 (#4643 library fixes): the library's corner shape replaces SyRF's override; the × respects disableClose and every disableClose dialog routes it; wide widths map to adaptiveWidth (question selection asks for 820px directly); phone actions in a bottom bar |
#4687 | Flag on only; nothing changes with the flag off apart from one detail of the bulk PDF dialog: Escape with a modifier key no longer reaches its requestClose(), as for every other dialog. The close button's focus ring now shows only for keyboard focus (camaradesuk/m3e-angular#101), with no SyRF change |
| 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. Resolved in L1: m3e's adaptive dialog is 560px wide by default (the stepper is 880px), and
adaptiveWidth(camaradesuk/m3e-angular#100) widens it.SyrfDialogServicemaps a pixelwidthabove 560px to it. Openers whose native width is not a plain pixel value keep 560px for now: review progress (80%), stage progress (min(800px, …)), the annotation question designer's create question (minWidth: '620px') and graph extraction (minWidth: '100vw'); each can passadaptiveWidth. - Dialog corner shape. Resolved in L1: the library sets the extra-large corner on
.mat-exp-dialog-panel(camaradesuk/m3e-angular#109), som3e-angular.scssno longer overrides it (SyRF's M2 theme defines the token as 4px). The stylesheet still gives the action row M3 Expressive button shape and type (#4674). - Phone actions. Resolved in L1: m3e's default puts an adaptive dialog's actions in the header row below 600px,
which cannot hold SyRF's longer actions (#4643).
SyrfDialogServiceopens adaptive dialogs withcompactActions: 'footer'(camaradesuk/m3e-angular#99). The search import wizard opensMatExpDialogdirectly, not through the service, and keeps the header row. - 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>; W6a opens them inalertmode through thesyrf-confirmations.tshelpers, which followconfirm()'sid + '-message'description pattern. - Alert width (W6a).
SyrfDialogServicedrops per-dialog sizes only in adaptive mode. Five openers (four confirmations and project delete) passwidth(520px or 600px) for the native dialog; under m3e alert mode that inline width is capped by the alert panel's stylesheet maximum (560px, or the viewport less 32px), so the dialog is wider than the library's content-sized alert. Dropping sizes in alert mode too is a one-line change in the service, left for the owner.
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.