@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>
255 lines
7.0 KiB
TypeScript
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}`,
|
|
);
|
|
}
|
|
}
|