Files
task-stream/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts
Josep Piguillem 4969fb3f6d fix(web): settings and bell use accessible checkboxes, not slide toggles
@pi/ui's slide toggle hides its input with display: none and gives it
no name: neither the Tab key nor a screen reader could reach the
switches in settings (visibility, the public's proposals, the
definition of done, the link for clients) or the bell's (e-mail copy,
notices on this device). They are pi-checkbox now, each with a name
saying what it turns on: reachable by keyboard, toggled with the space
bar and announced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:20:06 +02:00

255 lines
7.0 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
inject,
TemplateRef,
viewChild,
} from '@angular/core';
import { Router } from '@angular/router';
import {
PiActionButtonComponent,
PiBadgeComponent,
PiIconButtonComponent,
PiIconComponent,
PiCheckboxComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { InboxFacade, PushFacade } from '@task-stream/web/tracker/application';
import {
Notification,
NotificationKind,
} from '@task-stream/web/tracker/domain';
import { timeAgo } from './copy';
const ICON: Record<NotificationKind, string> = {
[NotificationKind.ASSIGNED]: 'user-profile',
[NotificationKind.STATUS_CHANGED]: 'arrow-forward',
[NotificationKind.QA_FAILED]: 'exclamation',
[NotificationKind.COMMENTED]: 'chat',
[NotificationKind.MENTIONED]: 'chat',
[NotificationKind.REPLIED]: 'chat',
[NotificationKind.PROPOSED]: 'light-bulb',
[NotificationKind.WATCHED]: 'focus',
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
[NotificationKind.REMINDER]: 'calendar',
};
/**
* The bell in the header: how many unread, and the list in a side panel.
* Opening one goes to its ticket (or project) and marks it read.
*/
@Component({
selector: 'ts-notifications-bell',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiActionButtonComponent,
PiBadgeComponent,
PiIconButtonComponent,
PiIconComponent,
PiCheckboxComponent,
],
template: `
<pi-badge
[value]="inbox.unread()"
[max]="99"
color="warn"
size="small"
[isHidden]="!inbox.hasUnread()"
>
<pi-icon-button
icon="bell"
color="grey"
appearance="ghost"
[attr.aria-label]="
inbox.unread()
? 'Notificaciones: ' + inbox.unread() + ' sin leer'
: 'Notificaciones'
"
(execute)="open()"
/>
</pi-badge>
<ng-template #panel>
<div class="ts-inbox">
<div class="ts-inbox__actions">
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Marcar todo como leído"
[disabled]="!inbox.hasUnread()"
(execute)="inbox.markRead()"
/>
@if (inbox.settings()?.emailAvailable) {
<pi-checkbox
label="Copia por correo"
[checked]="inbox.settings()?.email ?? false"
(checkedChange)="inbox.setEmail($event)"
/>
}
@if (inbox.settings()?.pushPublicKey && push.supported) {
<pi-checkbox
label="Avisos en este dispositivo"
[checked]="push.on()"
[disabled]="push.busy()"
(checkedChange)="togglePush($event)"
/>
}
</div>
@if (push.error(); as error) {
<p class="ts-inbox__error" role="alert">{{ error }}</p>
}
@if (inbox.items().length) {
<ul class="ts-inbox__list">
@for (item of inbox.items(); track item.id) {
<li>
<button
type="button"
class="ts-inbox__item"
[class.is-unread]="!item.readAt"
(click)="go(item)"
>
<span class="ts-inbox__icon" aria-hidden="true">
<pi-icon [icon]="icon(item)" size="small" />
</span>
<span class="ts-inbox__text">
<span class="ts-inbox__summary">{{ item.summary }}</span>
<span class="ts-inbox__meta"
>{{ item.projectKey }} · {{ ago(item.createdAt) }}</span
>
</span>
@if (!item.readAt) {
<span class="ts-inbox__dot" aria-label="Sin leer"></span>
}
</button>
</li>
}
</ul>
} @else {
<p class="ts-inbox__empty">
Nada nuevo. Te avisaremos cuando te asignen algo, comenten en tus
tickets o los muevan.
</p>
}
</div>
</ng-template>
`,
styles: `
:host {
display: inline-flex;
align-items: center;
}
.ts-inbox {
display: grid;
gap: 1rem;
}
.ts-inbox__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.ts-inbox__error {
margin: 0;
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.8125rem;
}
.ts-inbox__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
/* As tall as its text: past @pi/ui's global button look (a fixed
height that let a two-line notice run over the next one). */
.ts-inbox__item {
width: 100%;
height: auto !important;
min-width: 0 !important;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.5rem !important;
line-height: 1.35;
border: 0;
border-radius: 0.5rem;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ts-inbox__item:hover,
.ts-inbox__item:focus-visible {
background: rgba(127, 127, 127, 0.12);
}
.ts-inbox__item.is-unread .ts-inbox__summary {
font-weight: 600;
}
.ts-inbox__icon {
flex: none;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(127, 127, 127, 0.14);
}
.ts-inbox__text {
display: grid;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.ts-inbox__meta {
font-size: 0.8125rem;
opacity: 0.7;
}
.ts-inbox__dot {
flex: none;
width: 0.5rem;
height: 0.5rem;
margin-top: 0.5rem;
border-radius: 50%;
background: var(--ts-brand, #a347e1);
}
.ts-inbox__empty {
opacity: 0.7;
}
`,
})
export class NotificationsBell {
protected readonly inbox = inject(InboxFacade);
protected readonly push = inject(PushFacade);
private readonly drawer = inject(DrawerService);
private readonly router = inject(Router);
private readonly panel = viewChild.required<TemplateRef<unknown>>('panel');
protected readonly ago = timeAgo;
protected icon = (item: Notification) => ICON[item.kind] ?? 'info';
protected open(): void {
void this.push.refresh();
this.drawer.open('Notificaciones', this.panel(), '28rem');
}
protected togglePush(on: boolean): void {
const key = this.inbox.settings()?.pushPublicKey;
if (on && key) void this.push.turnOn(key);
else if (!on) void this.push.turnOff();
}
protected go(item: Notification): void {
if (!item.readAt) this.inbox.markRead([item.id]);
this.drawer.closeAll();
const project = encodeURIComponent(item.projectKey);
void this.router.navigateByUrl(
item.issueKey
? `/p/${project}/issues/${encodeURIComponent(item.issueKey)}`
: `/p/${project}`,
);
}
}