From 20e85a2a198ea127a6e150ecc414020b06665ce2 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 22 Jun 2026 15:42:44 +0200 Subject: [PATCH] refactor(tasks): restructure reminder dialog footer into primary + overflow (#8517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(tasks): restructure reminder dialog footer into primary + overflow The footer showed up to four visually identical stroked buttons (Snooze, Done, Add to Today, Start) with no hierarchy. Reduce to a single filled primary action plus a Snooze menu and an overflow (kebab) menu: - Primary: Start (single) / Schedule for today (multiple) - Snooze button keeps the quick-defer menu (10/30/60 min, tomorrow) - New overflow menu holds the rest (Done/Complete, Add to Today, edit, unschedule, dismiss-keep-today) All existing actions remain available; only their grouping changes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * refactor(tasks): add dropdown caret to reminder snooze button The Snooze button opens a menu but had no visible affordance signalling that. Add a trailing arrow_drop_down icon (native Material iconPositionEnd slot) so the menu trigger reads as such, surfaced by the review. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * test(reminders): open overflow menu to reach Done in deadline specs The reminder dialog's "Done" action moved from a footer button into the new overflow ("More actions") menu, so the two deadline e2e specs must open that menu before clicking Done. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * refactor(tasks): refine reminder footer hierarchy after UX review Follow-up to the footer restructure, addressing review findings: - Surface a one-tap "Done" (check) icon for single reminders, so the most common reminder response isn't two taps deep in the overflow menu. Left out for the multi-task case, where a one-tap complete-all is a footgun and per-row checks already exist. - Make the primary action deadline-aware: deadline reminders now default to the safe "Add to Today" instead of "Start" (which sets the current task and reorders Today as a side effect). Start moves into the overflow for deadlines; scheduled reminders keep Start as primary. - Move "Edit (reschedule)" into the Snooze ("when") menu so all time-deferral actions are grouped and the overflow holds pure dispositions. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * test(reminders): click one-tap Done icon in deadline specs The single-task reminder footer now surfaces "Done" as a one-tap icon button (aria-label "Mark as done"), so the deadline specs click it directly instead of opening the overflow menu. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * refactor(tasks): put primary reminder action on the right, Done in menu Per UX feedback: - Reverse the footer button order so the filled primary action is always on the right (overflow on the left, Snooze in the middle). - Move "Done" back into the overflow menu instead of surfacing it as a standalone icon button. Update the deadline e2e specs to open the overflow menu before clicking Done accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * feat(tasks): allow dismissing reminder dialog + snooze split-button Three reminder-dialog improvements: - Click-away to dismiss: backdrop click / Escape now close the dialog and dismiss the reminders (clearing the reminder while keeping the task and its schedule). disableClose is kept and the events are handled in the dialog, so the worker does not immediately re-open it in a loop. - Snooze split-button: a single click snoozes 10 min (the common default, previously a hidden double-click) and a joined dropdown caret opens the full options menu. Uses a new shared .g-split-btn style. - Overflow button: replace the lone vertical "three dots" icon with a low-emphasis labeled "More" button, giving a clean emphasis ramp (More < Snooze < primary) with the primary kept on the right. Updates the deadline e2e specs and wiki for the new dismiss behavior, and adds unit tests for the backdrop/Escape dismissal. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): make reminder dialog close on backdrop/Escape Per product decision, clicking the backdrop or pressing Escape now simply closes the reminder dialog (drop disableClose) rather than dismissing the reminders. Deadline reminders are still cleared on close (avoiding the 10s re-fire loop); scheduled reminders stay active and the worker re-shows them until the user acts on them. Reverts the earlier intercept-and-dismiss approach (and its unit tests); updates the wiki accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * feat(tasks): add split-button UI component, restructure reminder footer Add a reusable UI component (src/app/ui/split-button): a primary default action joined flush to a compact, centered overflow trigger that opens a passed-in menu. The joined treatment lives in the shared style layer (styles/components/split-button.scss) and now renders with no gap between the two halves and a properly centered trigger icon. Use it in the reminder dialog footer: limit the footer to two actions — "Snooze 10m" (showing the snooze duration) and the primary CTA (Start / Add to Today) — and fold the former "More actions" and snooze-options menus into a single overflow menu opened by the icon button right of snooze. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): fuse split-button halves and update reminder dialog test The split-button trigger was pushed 8px away from the default action by Angular Material's `.mat-mdc-dialog-actions .mat-mdc-button-base + .mat-mdc-button-base` rule (specificity 0,3,0), which outweighed the joining `margin-left: -1px`. Match Material's selector hooks so the negative margin wins and the two halves render flush inside dialogs. Also drop the stale `disableClose: true` assertion from the reminder module spec to match the now-dismissable dialog (fixes failing CI). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): clarify reminder dialog "add to today" button label The multi-task primary button said "Schedule for today", which is both long and misleading: those tasks are already scheduled — the action drops their time and keeps them on Today (all day), or for deadlines adds them to Today while keeping the deadline date. Key the label on deadline vs schedule instead of single vs multiple: - all deadlines -> "Add to Today" (added to today, deadline preserved) - scheduled/mixed -> "Today" (already scheduled; kept on today, all day) Reuses the existing TODAY_TAG_TITLE string; SCHEDULE_FOR_TODAY remains for the per-row tooltips. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): distinguish deadline vs scheduled in reminder today-button After weighing options, label the reminder dialog's primary today-action by what it actually does in each case: - all deadlines -> "Add to Today" (task is added to Today; deadline kept) - scheduled/mixed -> "Keep in Today" (already scheduled; kept on Today, all day, with the specific time dropped) This replaces the bare, verb-less "Today" with accurate wording consistent with the dialog's existing "keep in Today" vocabulary. Adds the KEEP_IN_TODAY en string (other locales fall back to English via fallbackLang). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * chore(reminders): drop unused SNOOZE_OPTIONS string, regenerate t.const.ts The reminder-dialog footer trigger uses MORE_ACTIONS, not SNOOZE_OPTIONS, which was a dead key from an earlier iteration. Remove it and regenerate t.const.ts through prettier so the file is back to the formatted form (the prior commit had committed raw generator output, churning ~760 lines). * refactor(ui): trim unused split-button inputs, add unit spec The split-button's only consumer never overrides color or triggerIcon, so drop both inputs (and the deprecated ThemePalette type) and inline the defaults. Add a unit spec covering content projection, mainClick, menu wiring, disabled state, and the trigger aria-label. --------- Co-authored-by: Claude --- docs/wiki/2.05-Manage-Scheduled-Tasks.md | 2 +- .../reminders/reminders-deadline.spec.ts | 29 +++-- .../features/reminder/reminder.module.spec.ts | 3 +- src/app/features/reminder/reminder.module.ts | 4 +- .../dialog-view-task-reminders.component.html | 103 +++++++++--------- .../dialog-view-task-reminders.component.ts | 4 + src/app/t.const.ts | 3 + .../split-button/split-button.component.html | 25 +++++ .../split-button/split-button.component.scss | 6 + .../split-button.component.spec.ts | 97 +++++++++++++++++ .../ui/split-button/split-button.component.ts | 29 +++++ src/assets/i18n/en.json | 3 + src/styles/components/_components.scss | 1 + src/styles/components/split-button.scss | 54 +++++++++ 14 files changed, 295 insertions(+), 68 deletions(-) create mode 100644 src/app/ui/split-button/split-button.component.html create mode 100644 src/app/ui/split-button/split-button.component.scss create mode 100644 src/app/ui/split-button/split-button.component.spec.ts create mode 100644 src/app/ui/split-button/split-button.component.ts create mode 100644 src/styles/components/split-button.scss diff --git a/docs/wiki/2.05-Manage-Scheduled-Tasks.md b/docs/wiki/2.05-Manage-Scheduled-Tasks.md index 48183b5248..847e445b5d 100755 --- a/docs/wiki/2.05-Manage-Scheduled-Tasks.md +++ b/docs/wiki/2.05-Manage-Scheduled-Tasks.md @@ -37,7 +37,7 @@ The task is scheduled immediately and the menu closes. To pick any date or set a 2. In **Remind at**, choose when you want to be reminded. Options in the app are: **Never**, **when it starts**, **5 minutes before it starts**, **10 minutes before it starts**, **15 minutes before it starts**, **30 minutes before it starts**, **1 hour before it starts**. 3. Click **Schedule**. -When a task reminder dialog appears, choose an explicit action such as snooze, unschedule, dismiss the reminder, add to today, start, or done. Clicking outside the dialog or pressing Escape does not dismiss the reminder. +When a task reminder dialog appears, choose an action such as snooze, unschedule, dismiss the reminder, add to today, start, or done. Clicking outside the dialog or pressing Escape just closes it: scheduled task reminders stay active and pop up again shortly until you act on them, while deadline reminders are cleared on close. ## Remove Scheduling from a Task diff --git a/e2e/tests/reminders/reminders-deadline.spec.ts b/e2e/tests/reminders/reminders-deadline.spec.ts index 1a74fb54d3..aca0c6ac40 100644 --- a/e2e/tests/reminders/reminders-deadline.spec.ts +++ b/e2e/tests/reminders/reminders-deadline.spec.ts @@ -83,8 +83,12 @@ test.describe('Deadline Reminders', () => { const dueForText = await dueForEl.textContent(); expect(dueForText!.trim().length).toBeGreaterThan(0); - // Dismiss by marking the task as done - await page.locator(REMINDER_DIALOG).locator('button:has-text("Done")').click(); + // Dismiss by marking the task as done (in the overflow "More actions" menu) + await page + .locator(REMINDER_DIALOG) + .locator('button[aria-label="More actions"]') + .click(); + await page.getByRole('menuitem', { name: 'Done' }).click(); // Wait for the reminder dialog to close await page.locator(REMINDER_DIALOG).waitFor({ state: 'hidden', timeout: 10000 }); @@ -94,7 +98,7 @@ test.describe('Deadline Reminders', () => { await expect(page.locator(REMINDER_DIALOG)).not.toBeVisible(); }); - test('should require an explicit action for the deadline reminder dialog', async ({ + test('should dismiss the deadline reminder dialog on Escape without re-triggering', async ({ page, workViewPage, testPrefix, @@ -146,14 +150,9 @@ test.describe('Deadline Reminders', () => { const reminderDialog = page.locator(REMINDER_DIALOG); await expect(reminderDialog).toBeVisible(); - // ESC and backdrop clicks must not passively dismiss the reminder dialog. + // Pressing Escape dismisses the reminder (clearing the reminder timestamp + // while keeping the task and its deadline) and closes the dialog. await page.keyboard.press('Escape'); - await expect(reminderDialog).toBeVisible(); - - await page.locator('.cdk-overlay-backdrop').last().click({ force: true }); - await expect(reminderDialog).toBeVisible(); - - await reminderDialog.locator('button:has-text("Done")').click(); await reminderDialog.waitFor({ state: 'hidden', timeout: 10000 }); // Poll over a window longer than the 10s reminder worker tick. If the @@ -218,12 +217,12 @@ test.describe('Deadline Reminders', () => { await page.waitForSelector(REMINDER_DIALOG_TASK_1, { state: 'visible' }); await expect(page.locator(REMINDER_DIALOG_TASK_1)).toContainText(taskTitle); - // Click "Snooze" to open snooze menu, then "Reschedule until tomorrow" - const snoozeBtn = page + // Open the overflow menu (the split-button dropdown right of snooze), then + // pick "Reschedule for tomorrow". The main snooze button snoozes 10m directly. + const snoozeMenuBtn = page .locator(REMINDER_DIALOG) - .locator('button:has(mat-icon:text("snooze"))') - .first(); - await snoozeBtn.click(); + .locator('button[aria-label="More actions"]'); + await snoozeMenuBtn.click(); const rescheduleOption = page.locator( 'button[mat-menu-item]:has-text("Reschedule for tomorrow")', diff --git a/src/app/features/reminder/reminder.module.spec.ts b/src/app/features/reminder/reminder.module.spec.ts index 1479c789aa..f6c1b719ec 100644 --- a/src/app/features/reminder/reminder.module.spec.ts +++ b/src/app/features/reminder/reminder.module.spec.ts @@ -87,7 +87,7 @@ describe('ReminderModule dialog opening', () => { }); }); - it('opens task reminder dialog without passive close paths', fakeAsync(() => { + it('opens task reminder dialog as dismissable (no disableClose)', fakeAsync(() => { TestBed.inject(ReminderModule); syncDone$.next(); @@ -96,7 +96,6 @@ describe('ReminderModule dialog opening', () => { expect(matDialogSpy.open).toHaveBeenCalledOnceWith(DialogViewTaskRemindersComponent, { restoreFocus: true, - disableClose: true, data: { reminders: [reminder], }, diff --git a/src/app/features/reminder/reminder.module.ts b/src/app/features/reminder/reminder.module.ts index 739f2a0189..935d7a470e 100644 --- a/src/app/features/reminder/reminder.module.ts +++ b/src/app/features/reminder/reminder.module.ts @@ -188,7 +188,9 @@ export class ReminderModule { } else { this._matDialog.open(DialogViewTaskRemindersComponent, { restoreFocus: true, - disableClose: true, + // Backdrop click / Escape closes the dialog. Deadline reminders are + // cleared on destroy; scheduled reminders stay active and the worker + // re-shows them until the user acts on them. data: { reminders, }, diff --git a/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.html b/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.html index 8e6bd959fb..ad84f14521 100644 --- a/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.html +++ b/src/app/features/tasks/dialog-view-task-reminders/dialog-view-task-reminders.component.html @@ -49,64 +49,37 @@ }
- - @if (isMultiple) { - - } - @if (!isMultiple) { - - } - @if (!(isSingleOnToday$ | async)) { + {{ T.F.TASK.D_REMINDER_VIEW.SNOOZE_FOR | translate: { m: 10 } }} + + @if (isMultiple || isAllDeadline) { - } - @if (!isMultiple || (isSingleOnToday$ | async)) { + } @else {
- - + + } @else { + + @if (isAllDeadline) { + + } + } + @if (!isMultiple && !isAllDeadline && !(isSingleOnToday$ | async)) { + + } + + diff --git a/src/app/ui/split-button/split-button.component.scss b/src/app/ui/split-button/split-button.component.scss new file mode 100644 index 0000000000..8e250f0e7b --- /dev/null +++ b/src/app/ui/split-button/split-button.component.scss @@ -0,0 +1,6 @@ +// Joined treatment (squared inner corners, shared border, compact centered +// trigger) lives in the shared layer so it can pierce Material's internal +// elements: styles/components/split-button.scss (.g-split-btn). +:host { + display: inline-flex; +} diff --git a/src/app/ui/split-button/split-button.component.spec.ts b/src/app/ui/split-button/split-button.component.spec.ts new file mode 100644 index 0000000000..e9010f586c --- /dev/null +++ b/src/app/ui/split-button/split-button.component.spec.ts @@ -0,0 +1,97 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatMenuModule } from '@angular/material/menu'; +import { SplitButtonComponent } from './split-button.component'; + +// Host wraps the component so the required `menu` input can be bound to a real +// and the projected default-action content is provided. Signal-backed +// state so writes mark the host dirty under zoneless change detection. +@Component({ + standalone: true, + imports: [SplitButtonComponent, MatMenuModule], + template: ` + + Snooze 10m + + + + + `, +}) +class HostComponent { + readonly disabled = signal(false); + readonly triggerLabel = signal(''); + mainClickCount = 0; + + onMainClick(): void { + this.mainClickCount++; + } +} + +describe('SplitButtonComponent', () => { + let fixture: ComponentFixture; + let host: HostComponent; + + const mainBtn = (): HTMLButtonElement => + fixture.nativeElement.querySelector('.split-btn-main'); + const triggerBtn = (): HTMLButtonElement => + fixture.nativeElement.querySelector('.split-btn-trigger'); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HostComponent, TranslateModule.forRoot()], + }).compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('renders both halves and projects the default-action content', () => { + expect(mainBtn()).toBeTruthy(); + expect(triggerBtn()).toBeTruthy(); + expect( + fixture.nativeElement.querySelector('.projected-content')?.textContent, + ).toContain('Snooze 10m'); + }); + + it('emits mainClick when the default-action half is clicked', () => { + mainBtn().click(); + expect(host.mainClickCount).toBe(1); + }); + + it('wires the trigger half to open the passed-in menu', () => { + const trigger = triggerBtn(); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + + trigger.click(); + fixture.detectChanges(); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + }); + + it('disables both halves when [disabled] is true', () => { + host.disabled.set(true); + fixture.detectChanges(); + expect(mainBtn().disabled).toBe(true); + expect(triggerBtn().disabled).toBe(true); + }); + + it('exposes triggerLabel as the trigger aria-label, and omits it when empty', () => { + // No translations loaded, so the translate pipe returns the key verbatim. + host.triggerLabel.set('F.TASK.D_REMINDER_VIEW.MORE_ACTIONS'); + fixture.detectChanges(); + expect(triggerBtn().getAttribute('aria-label')).toBe( + 'F.TASK.D_REMINDER_VIEW.MORE_ACTIONS', + ); + + host.triggerLabel.set(''); + fixture.detectChanges(); + expect(triggerBtn().getAttribute('aria-label')).toBeNull(); + }); +}); diff --git a/src/app/ui/split-button/split-button.component.ts b/src/app/ui/split-button/split-button.component.ts new file mode 100644 index 0000000000..953d50b359 --- /dev/null +++ b/src/app/ui/split-button/split-button.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenu, MatMenuTrigger } from '@angular/material/menu'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; + +/** + * Joined "split button": a primary default action plus an adjacent, compact + * trigger that opens a menu of related/overflow options. The two halves render + * as a single control (shared border, no gap). Pass the menu to open via + * [menu]; project the default-action content as children. + */ +@Component({ + selector: 'split-button', + standalone: true, + imports: [MatButton, MatIcon, MatMenuTrigger, MatTooltip, TranslatePipe], + templateUrl: './split-button.component.html', + styleUrl: './split-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SplitButtonComponent { + readonly menu = input.required(); + readonly disabled = input(false); + // Translation key used for the trigger's tooltip and aria-label. + readonly triggerLabel = input(''); + + readonly mainClick = output(); +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2accb161cb..28386e447c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1781,6 +1781,7 @@ "CLEAR_REMINDER": "Clear reminder", "COMPLETE": "Complete", "MARK_AS_DONE": "Mark as done", + "MORE_ACTIONS": "More actions", "OR_DIRECTLY_FOR_ALL": "Or directly for all:", "SCHEDULE_FOR_TODAY": "Schedule for today", "DROP_TIME_KEEP_TODAY": "Drop time, keep in Today", @@ -1789,6 +1790,7 @@ "DISMISS_ALL_REMINDERS_KEEP_TODAY": "Dismiss All Reminders (Keep in Today)", "DISMISS_REMINDER_KEEP_TODAY": "Dismiss Reminder (Keep in Today)", "DONE": "Done", + "KEEP_IN_TODAY": "Keep in Today", "DUE_SECTION": "Planned", "DUE_TASK": "Planned task reminder", "DUE_TASKS": "Planned tasks reminder", @@ -1796,6 +1798,7 @@ "RESCHEDULE_UNTIL_TOMORROW": "Reschedule for tomorrow", "SCHEDULED_FOR": "Scheduled for", "SNOOZE": "Snooze", + "SNOOZE_FOR": "Snooze {{m}}m", "START": "Start", "TASK_REMINDERS": "Task Reminders", "UNSCHEDULE": "Unschedule", diff --git a/src/styles/components/_components.scss b/src/styles/components/_components.scss index 73e6a8d8a4..3f8969dd73 100644 --- a/src/styles/components/_components.scss +++ b/src/styles/components/_components.scss @@ -13,6 +13,7 @@ @use './wrap-buttons'; @use './shepherd'; @use './multi-btn-wrapper'; +@use './split-button'; @use './planner-shared'; @use './mentions'; @use './bottom-panel'; diff --git a/src/styles/components/split-button.scss b/src/styles/components/split-button.scss new file mode 100644 index 0000000000..ce13ff5721 --- /dev/null +++ b/src/styles/components/split-button.scss @@ -0,0 +1,54 @@ +// Joined "split button": a primary default action plus an adjacent dropdown +// trigger that opens a menu of related options (e.g. snooze in the reminder +// dialog). Lives in the shared layer so the joined treatment is reusable +// rather than re-implemented locally per usage, and so it can pierce Material's +// internal button elements. Structure/API is provided by the reusable +// UI component (src/app/ui/split-button). +.g-split-btn { + display: inline-flex; + align-items: stretch; + + // Default-action button: square off the edge that meets the dropdown. + > button:first-of-type { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + // Overlap the shared edge so there is no visible gap or double border. + // Angular Material spaces adjacent dialog-action buttons with + // `.mat-mdc-dialog-actions .mat-mdc-button-base + .mat-mdc-button-base + // { margin-left: 8px }` (specificity 0,3,0); match its hooks here so the + // negative margin wins and the two halves stay flush inside dialogs too. + > button.mat-mdc-button-base + button.mat-mdc-button-base { + margin-left: -1px; + } + + // Dropdown trigger: a compact square that sits flush against the default + // action, sharing its 1px border so the two read as a single control. + > button:last-of-type { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + min-width: 0; + width: 36px; + padding: 0; + // Center the lone icon both axes; Material's defaults assume icon + text. + display: inline-flex; + align-items: center; + justify-content: center; + + .mat-mdc-button-persistent-ripple { + border-radius: 0; + } + + // The label wrapper otherwise keeps a min-width / text alignment meant for + // labelled buttons; collapse it so the icon is truly centered. + .mdc-button__label { + display: inline-flex; + } + + mat-icon, + .mat-icon { + margin: 0; + } + } +}