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; + } + } +}