Skip to content

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

  1. Template: wrap the body in <syrf-dialog-layout> and move the buttons into <ng-template syrfDialogActions>. Delete the native title element; the opener's title is the heading. Replace fxLayout on the old content element with component SCSS.
  2. Close: [mat-dialog-close]="x" becomes [syrfDialogClose]="x", and an injected MatDialogRef becomes injectSyrfDialogRef(). Keep every result value exactly as it was. A bare mat-dialog-close closed with '', but a bare syrfDialogClose closes with undefined (like m3e and Escape). Check the openers: if none tells '' from undefined (most only test truthiness), use the bare marker; otherwise bind [syrfDialogClose]="''".
  3. Opener: inject SyrfDialogService; pass mode: 'adaptive' and the title the template used to show (export it as a constant next to the component when it is static). Keep data, disableClose, autoFocus, restoreFocus and id as they were. A confirmation does not open a component of its own: it calls openConfirmation (HTML content, optional third button), openInfoConfirmation (information box, text, list) or openDangerConfirmation (an irreversible destructive action the user enables by typing an exact string) from shared/dialog/syrf-confirmations.ts, which own the alert-mode presentation.
  4. Specs: content specs provide MatDialogRef (or open through the real service with the flag both ways); opener specs mock SyrfDialogService and assert the component, mode and title (for a confirmation: the component, mode: 'alert', role and an ariaDescribedBy ending -message).
  5. Remove the migrated files from the allow-list; update any E2E page object that targets mat-dialog-container to .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 by SyrfDialogService. 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. SyrfDialogService maps a pixel width above 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 pass adaptiveWidth.
  • Dialog corner shape. Resolved in L1: the library sets the extra-large corner on .mat-exp-dialog-panel (camaradesuk/m3e-angular#109), so m3e-angular.scss no 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). SyrfDialogService opens adaptive dialogs with compactActions: 'footer' (camaradesuk/m3e-angular#99). The search import wizard opens MatExpDialog directly, 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 in alert mode through the syrf-confirmations.ts helpers, which follow confirm()'s id + '-message' description pattern.
  • Alert width (W6a). SyrfDialogService drops per-dialog sizes only in adaptive mode. Five openers (four confirmations and project delete) pass width (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.