refactor(tasks): restructure reminder dialog footer into primary + overflow (#8517)

* 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* feat(tasks): add split-button UI component, restructure reminder footer

Add a reusable <split-button> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Johannes Millan 2026-06-22 15:42:44 +02:00 committed by GitHub
parent 32aec63023
commit 20e85a2a19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 295 additions and 68 deletions

View file

@ -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

View file

@ -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")',

View file

@ -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],
},

View file

@ -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,
},

View file

@ -49,64 +49,37 @@
</div>
}
<div class="wrap-buttons">
<button
(dblclick)="snoozeAll(10)"
<split-button
[disabled]="isDisableControls"
[matMenuTriggerFor]="snoozeAllMenu"
color="primary"
mat-stroked-button
type="button"
[menu]="footerMenu"
[triggerLabel]="T.F.TASK.D_REMINDER_VIEW.MORE_ACTIONS"
(mainClick)="snoozeAll(10)"
>
<mat-icon>snooze</mat-icon>
{{ T.F.TASK.D_REMINDER_VIEW.SNOOZE | translate }}
</button>
@if (isMultiple) {
<button
(click)="markAllAsDone()"
[disabled]="isDisableControls"
mat-stroked-button
color="primary"
type="button"
>
<mat-icon>check</mat-icon>
{{ T.F.TASK.D_REMINDER_VIEW.COMPLETE | translate }}
</button>
}
@if (!isMultiple) {
<button
(click)="markSingleAsDone()"
[disabled]="isDisableControls"
color="primary"
mat-stroked-button
type="button"
>
<mat-icon>check</mat-icon>
{{ T.F.TASK.D_REMINDER_VIEW.DONE | translate }}
</button>
}
@if (!(isSingleOnToday$ | async)) {
{{ T.F.TASK.D_REMINDER_VIEW.SNOOZE_FOR | translate: { m: 10 } }}
</split-button>
@if (isMultiple || isAllDeadline) {
<button
(click)="addAllToToday()"
[disabled]="isDisableControls"
color="primary"
mat-stroked-button
mat-flat-button
type="button"
>
<mat-icon>wb_sunny</mat-icon>
{{
(isMultiple
? T.F.TASK.D_REMINDER_VIEW.SCHEDULE_FOR_TODAY
: T.F.TASK.D_REMINDER_VIEW.ADD_TO_TODAY
(isAllDeadline
? T.F.TASK.D_REMINDER_VIEW.ADD_TO_TODAY
: T.F.TASK.D_REMINDER_VIEW.KEEP_IN_TODAY
) | translate
}}
</button>
}
@if (!isMultiple || (isSingleOnToday$ | async)) {
} @else {
<button
(click)="play()"
[disabled]="isDisableControls"
color="primary"
mat-stroked-button
mat-flat-button
type="button"
>
<mat-icon>play_arrow</mat-icon>
@ -116,15 +89,7 @@
</div>
</div>
</mat-dialog-actions>
<mat-menu #snoozeAllMenu="matMenu">
<button
(click)="snoozeAll(10)"
[disabled]="isDisableControls"
mat-menu-item
>
<mat-icon>snooze</mat-icon>
{{ T.G.MINUTES | translate: { m: 10 } }}
</button>
<mat-menu #footerMenu="matMenu">
<button
(click)="snoozeAll(30)"
[disabled]="isDisableControls"
@ -158,6 +123,46 @@
{{ T.F.TASK.D_REMINDER_VIEW.RESCHEDULE_EDIT | translate }}
</button>
}
<mat-divider></mat-divider>
@if (isMultiple) {
<button
(click)="markAllAsDone()"
[disabled]="isDisableControls"
mat-menu-item
>
<mat-icon>done_all</mat-icon>
{{ T.F.TASK.D_REMINDER_VIEW.COMPLETE | translate }}
</button>
} @else {
<button
(click)="markSingleAsDone()"
[disabled]="isDisableControls"
mat-menu-item
>
<mat-icon>check</mat-icon>
{{ T.F.TASK.D_REMINDER_VIEW.DONE | translate }}
</button>
@if (isAllDeadline) {
<button
(click)="play()"
[disabled]="isDisableControls"
mat-menu-item
>
<mat-icon>play_arrow</mat-icon>
{{ T.F.TASK.D_REMINDER_VIEW.START | translate }}
</button>
}
}
@if (!isMultiple && !isAllDeadline && !(isSingleOnToday$ | async)) {
<button
(click)="addAllToToday()"
[disabled]="isDisableControls"
mat-menu-item
>
<mat-icon>wb_sunny</mat-icon>
{{ T.F.TASK.D_REMINDER_VIEW.ADD_TO_TODAY | translate }}
</button>
}
<button
(click)="dismissAll()"
[disabled]="isDisableControls"

View file

@ -29,6 +29,8 @@ import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/
import { MatIcon } from '@angular/material/icon';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatDivider } from '@angular/material/divider';
import { SplitButtonComponent } from '../../../ui/split-button/split-button.component';
import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
import { LocalDateStrPipe } from 'src/app/ui/pipes/local-date-str.pipe';
@ -74,6 +76,8 @@ export const shouldShowDeadlineScheduleHint = (
MatMenuItem,
MatDialogActions,
MatButton,
MatDivider,
SplitButtonComponent,
AsyncPipe,
NgTemplateOutlet,
TranslatePipe,

View file

@ -1827,6 +1827,7 @@ const T = {
CLEAR_REMINDER: 'F.TASK.D_REMINDER_VIEW.CLEAR_REMINDER',
COMPLETE: 'F.TASK.D_REMINDER_VIEW.COMPLETE',
MARK_AS_DONE: 'F.TASK.D_REMINDER_VIEW.MARK_AS_DONE',
MORE_ACTIONS: 'F.TASK.D_REMINDER_VIEW.MORE_ACTIONS',
OR_DIRECTLY_FOR_ALL: 'F.TASK.D_REMINDER_VIEW.OR_DIRECTLY_FOR_ALL',
SCHEDULE_FOR_TODAY: 'F.TASK.D_REMINDER_VIEW.SCHEDULE_FOR_TODAY',
DROP_TIME_KEEP_TODAY: 'F.TASK.D_REMINDER_VIEW.DROP_TIME_KEEP_TODAY',
@ -1836,6 +1837,7 @@ const T = {
'F.TASK.D_REMINDER_VIEW.DISMISS_ALL_REMINDERS_KEEP_TODAY',
DISMISS_REMINDER_KEEP_TODAY: 'F.TASK.D_REMINDER_VIEW.DISMISS_REMINDER_KEEP_TODAY',
DONE: 'F.TASK.D_REMINDER_VIEW.DONE',
KEEP_IN_TODAY: 'F.TASK.D_REMINDER_VIEW.KEEP_IN_TODAY',
DUE_SECTION: 'F.TASK.D_REMINDER_VIEW.DUE_SECTION',
DUE_TASK: 'F.TASK.D_REMINDER_VIEW.DUE_TASK',
DUE_TASKS: 'F.TASK.D_REMINDER_VIEW.DUE_TASKS',
@ -1843,6 +1845,7 @@ const T = {
RESCHEDULE_UNTIL_TOMORROW: 'F.TASK.D_REMINDER_VIEW.RESCHEDULE_UNTIL_TOMORROW',
SCHEDULED_FOR: 'F.TASK.D_REMINDER_VIEW.SCHEDULED_FOR',
SNOOZE: 'F.TASK.D_REMINDER_VIEW.SNOOZE',
SNOOZE_FOR: 'F.TASK.D_REMINDER_VIEW.SNOOZE_FOR',
START: 'F.TASK.D_REMINDER_VIEW.START',
TASK_REMINDERS: 'F.TASK.D_REMINDER_VIEW.TASK_REMINDERS',
UNSCHEDULE: 'F.TASK.D_REMINDER_VIEW.UNSCHEDULE',

View file

@ -0,0 +1,25 @@
<div class="g-split-btn">
<button
class="split-btn-main"
(click)="mainClick.emit($event)"
[disabled]="disabled()"
color="primary"
mat-stroked-button
type="button"
>
<ng-content></ng-content>
</button>
<button
class="split-btn-trigger"
[matMenuTriggerFor]="menu()"
[disabled]="disabled()"
[matTooltip]="triggerLabel() ? (triggerLabel() | translate) : ''"
[attr.aria-label]="triggerLabel() ? (triggerLabel() | translate) : null"
color="primary"
matTooltipShowDelay="0"
mat-stroked-button
type="button"
>
<mat-icon>more_vert</mat-icon>
</button>
</div>

View file

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

View file

@ -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
// <mat-menu> 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: `
<split-button
[menu]="menu"
[disabled]="disabled()"
[triggerLabel]="triggerLabel()"
(mainClick)="onMainClick()"
>
<span class="projected-content">Snooze 10m</span>
</split-button>
<mat-menu #menu="matMenu">
<button mat-menu-item>Option</button>
</mat-menu>
`,
})
class HostComponent {
readonly disabled = signal(false);
readonly triggerLabel = signal('');
mainClickCount = 0;
onMainClick(): void {
this.mainClickCount++;
}
}
describe('SplitButtonComponent', () => {
let fixture: ComponentFixture<HostComponent>;
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();
});
});

View file

@ -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<MatMenu>();
readonly disabled = input<boolean>(false);
// Translation key used for the trigger's tooltip and aria-label.
readonly triggerLabel = input<string>('');
readonly mainClick = output<MouseEvent>();
}

View file

@ -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",

View file

@ -13,6 +13,7 @@
@use './wrap-buttons';
@use './shepherd';
@use './multi-btn-wrapper';
@use './split-button';
@use './planner-shared';
@use './mentions';
@use './bottom-panel';

View file

@ -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
// <split-button> 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;
}
}
}