fix(web): the ticket's header fits, with «Más» for the less frequent actions

The reminder and what is new are pi-notes; the poll editor's buttons align.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 21:17:24 +02:00
parent 285bd5c454
commit 425db24eea
3 changed files with 339 additions and 184 deletions

View File

@@ -69,12 +69,101 @@
// Follow and edit, together, level with the title's first line.
.ts-issue__actions {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.25rem;
}
/* «Más»: the ticket's less frequent actions, in a menu. */
/* Laid out as pi-icon-button lays out its own (its styles are scoped). */
.ts-more {
display: flex !important;
gap: 0.5rem;
align-items: center;
mat-icon {
display: flex;
width: 18px;
height: 18px;
}
}
.ts-menu {
display: grid;
width: min(22rem, calc(100vw - 2rem));
padding: 0.375rem;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
box-shadow: 0 12px 32px rgb(0 0 0 / 14%);
}
/* @pi/ui styles every <button> globally, hence the !important. */
.ts-menu__item {
display: grid !important;
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
min-width: 0 !important;
height: auto !important;
padding: 0.5rem 0.625rem !important;
border: 0 !important;
border-radius: 0.5rem !important;
background: transparent !important;
color: var(--ts-ink) !important;
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
&:hover,
&:focus,
&.cdk-menu-item-focused {
background: var(--ts-sunken) !important;
outline: none;
}
&:focus-visible {
outline: 2px solid var(--ts-brand);
outline-offset: -2px;
}
pi-icon {
color: var(--ts-ink-soft);
}
}
.ts-menu__text {
display: grid;
gap: 0.125rem;
font-weight: 600;
small {
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 400;
}
}
.ts-menu__check {
color: var(--ts-brand);
font-weight: 700;
}
/* A note above the ticket (my reminder, what is new). */
.ts-banner {
flex-wrap: wrap;
row-gap: 0.5rem;
}
.ts-banner__actions {
display: inline-flex;
flex-wrap: wrap;
gap: 0.5rem;
}
// Workflow: one button per allowed destination, in that status' colour.
.ts-transitions {
display: flex;
@@ -373,26 +462,6 @@ a.ts-key {
}
}
/* What others did since my last visit, above the ticket. */
.ts-news {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.625rem;
margin: 0;
padding: 0.5rem 0.875rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--ts-brand) 9%, transparent);
font-size: 0.875rem;
}
.ts-news__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ts-brand);
}
.ts-comment.is-pointed {
animation: ts-pointed 2.4s ease-out;
}
@@ -827,25 +896,6 @@ a.ts-key {
background: var(--ts-c-green, #2e9e6a);
}
/* My own reminder on the ticket. */
.ts-reminder {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.625rem;
margin: 0;
padding: 0.5rem 0.875rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent);
font-size: 0.875rem;
}
.ts-reminder__actions {
display: inline-flex;
gap: 0.25rem;
margin-left: auto;
}
.ts-reminder__choices {
display: grid;
gap: 0.5rem;

View File

@@ -16,6 +16,14 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { form, FormField, required } from '@angular/forms/signals';
import { catchError, distinctUntilChanged, filter, of, switchMap } from 'rxjs';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
CdkMenu,
CdkMenuItem,
CdkMenuItemCheckbox,
CdkMenuTrigger,
} from '@angular/cdk/menu';
import { ConnectedPosition } from '@angular/cdk/overlay';
import { MatIcon } from '@angular/material/icon';
import {
PiActionButtonComponent,
PiCheckboxComponent,
@@ -25,6 +33,7 @@ import {
PiIconComponent,
PiLabelDirective,
PiNoteComponent,
PiNoteActionContentDirective,
PiSegmentedControlComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
@@ -108,6 +117,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
RichEditorComponent,
NgTemplateOutlet,
RouterLink,
CdkMenu,
CdkMenuItem,
CdkMenuItemCheckbox,
CdkMenuTrigger,
MatIcon,
FormField,
AttachmentItem,
MediaRecorderComponent,
@@ -119,6 +133,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
PiIconButtonComponent,
PiLabelDirective,
PiNoteComponent,
PiNoteActionContentDirective,
PiSegmentedControlComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
@@ -223,79 +238,6 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (project.canContribute()) {
<pi-icon-button
icon="eye"
color="grey"
appearance="outline"
text="Pedir revisión"
title="Pedir a alguien del equipo que lo revise"
(execute)="openReviewRequest()"
/>
}
@if (project.myRole() && inMyDay() !== null) {
<pi-icon-button
icon="sun"
color="grey"
appearance="outline"
[text]="inMyDay() ? 'En mi día' : 'Mi día'"
[attr.aria-pressed]="inMyDay()"
[attr.title]="
inMyDay()
? 'Quitar de lo que quiero sacar hoy'
: 'Añadir a lo que quiero sacar hoy (en el inicio)'
"
(execute)="toggleMyDay(issue)"
/>
}
@if (project.myRole()) {
<pi-icon-button
icon="bell"
color="grey"
appearance="outline"
[text]="issue.myReminder ? 'Recordatorio' : 'Recordármelo'"
[attr.aria-pressed]="!!issue.myReminder"
title="Avisarme de este ticket cuando yo diga"
(execute)="openReminder()"
/>
}
@if (
project.myRole() &&
(facade.children().length || issue.followingTree)
) {
<pi-icon-button
icon="kanban"
color="grey"
appearance="outline"
[text]="
issue.followingTree
? 'Siguiendo todo · ' + facade.children().length
: 'Seguir todo'
"
[attr.aria-pressed]="issue.followingTree"
[attr.title]="
issue.followingTree
? 'Dejar de seguir lo que cuelga de este ticket'
: 'Avisarme de este ticket y de todo lo que cuelga de él, también lo que se añada'
"
(execute)="facade.setFollowingTree(!issue.followingTree)"
/>
}
@if (project.myRole()) {
<pi-icon-button
icon="pin"
color="grey"
appearance="outline"
[text]="issue.pinned ? 'Fijado' : 'Fijar'"
[attr.aria-pressed]="issue.pinned"
[attr.title]="
issue.pinned
? 'Quitar de mis fijados'
: 'Tenerlo a mano en el inicio y al buscar'
"
(execute)="facade.setPinned(!issue.pinned)"
/>
}
@if (project.canContribute()) {
<pi-icon-button
icon="edit"
@@ -305,64 +247,210 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
title="Editar (tecla E)"
(execute)="openEdit()"
/>
<pi-icon-button
icon="copy"
color="grey"
appearance="outline"
text="Duplicar"
title="Crear otro ticket con lo mismo (sin estado, persona ni comentarios)"
(execute)="duplicate()"
/>
}
@if (project.myRole()) {
<!-- @pi/ui's look on a native button: the menu needs it. -->
<button
type="button"
class="ts-more has-text pi-button pi-button__appearance--outline pi-button__icon pi-button__medium pi-color__grey pi-color__mode--dark"
[cdkMenuTriggerFor]="moreMenu"
[cdkMenuPosition]="morePositions"
>
<span>Más</span>
<mat-icon svgIcon="chevron-down" aria-hidden="true" />
</button>
}
<ng-template #moreMenu>
<div class="ts-menu" cdkMenu>
@if (project.canContribute()) {
<button
type="button"
class="ts-menu__item"
cdkMenuItem
(cdkMenuItemTriggered)="openReviewRequest()"
>
<pi-icon icon="eye" size="small" />
<span class="ts-menu__text"
>Pedir revisión<small
>A alguien del equipo, que lo verá en «Mi
trabajo»</small
></span
>
</button>
}
@if (inMyDay() !== null) {
<button
type="button"
class="ts-menu__item"
cdkMenuItemCheckbox
[cdkMenuItemChecked]="!!inMyDay()"
(cdkMenuItemTriggered)="toggleMyDay(issue)"
>
<pi-icon icon="sun" size="small" />
<span class="ts-menu__text"
>{{ inMyDay() ? 'En mi día' : 'Añadir a mi día'
}}<small
>Lo que quiero sacar hoy, en el inicio</small
></span
>
@if (inMyDay()) {
<span class="ts-menu__check" aria-hidden="true"
>✓</span
>
}
</button>
}
<button
type="button"
class="ts-menu__item"
cdkMenuItem
(cdkMenuItemTriggered)="openReminder()"
>
<pi-icon icon="bell" size="small" />
<span class="ts-menu__text"
>{{
issue.myReminder
? 'Cambiar el recordatorio'
: 'Recordármelo'
}}<small
>Un aviso solo para mí, cuando yo diga</small
></span
>
</button>
@if (facade.children().length || issue.followingTree) {
<button
type="button"
class="ts-menu__item"
cdkMenuItemCheckbox
[cdkMenuItemChecked]="issue.followingTree"
(cdkMenuItemTriggered)="
facade.setFollowingTree(!issue.followingTree)
"
>
<pi-icon icon="kanban" size="small" />
<span class="ts-menu__text"
>{{
issue.followingTree
? 'Siguiendo todo · ' + facade.children().length
: 'Seguir todo'
}}<small
>Este ticket y lo que cuelga de él, también lo
nuevo</small
></span
>
@if (issue.followingTree) {
<span class="ts-menu__check" aria-hidden="true"
>✓</span
>
}
</button>
}
<button
type="button"
class="ts-menu__item"
cdkMenuItemCheckbox
[cdkMenuItemChecked]="issue.pinned"
(cdkMenuItemTriggered)="facade.setPinned(!issue.pinned)"
>
<pi-icon icon="pin" size="small" />
<span class="ts-menu__text"
>{{ issue.pinned ? 'Fijado' : 'Fijar'
}}<small>A mano en el inicio y al buscar</small></span
>
@if (issue.pinned) {
<span class="ts-menu__check" aria-hidden="true">✓</span>
}
</button>
@if (project.canContribute()) {
<button
type="button"
class="ts-menu__item"
cdkMenuItem
(cdkMenuItemTriggered)="duplicate()"
>
<pi-icon icon="copy" size="small" />
<span class="ts-menu__text"
>Duplicar<small
>Otro ticket con lo mismo, sin estado ni
comentarios</small
></span
>
</button>
}
</div>
</ng-template>
</div>
</div>
</header>
@if (issue.myReminder; as reminder) {
<p class="ts-reminder" role="status">
<span aria-hidden="true">⏰</span>
Te lo recordaré {{ when(reminder.at) }}
@if (reminder.note) {
· «{{ reminder.note }}»
}
<span class="ts-reminder__actions">
<pi-action-button
size="x-small"
color="grey"
appearance="outline"
text="Cambiar"
(execute)="openReminder()"
/>
<pi-action-button
size="x-small"
color="grey"
appearance="ghost"
text="Quitar"
(execute)="facade.setReminder(null)"
/>
</span>
</p>
<pi-note
class="ts-banner"
gap="xs"
status="info"
appearance="fill"
type="status"
justify="space-between"
alignment="center"
[title]="'Te lo recordaré ' + when(reminder.at)"
[text]="reminder.note ? '«' + reminder.note + '»' : ''"
>
<pi-note-action-content>
<span class="ts-banner__actions">
<pi-icon-button
icon="edit"
color="grey"
appearance="outline"
size="small"
text="Cambiar"
(execute)="openReminder()"
/>
<pi-icon-button
icon="trash"
color="grey"
appearance="outline"
size="small"
text="Quitar"
(execute)="facade.setReminder(null)"
/>
</span>
</pi-note-action-content>
</pi-note>
}
@if (news(); as news) {
<p class="ts-news" role="status">
<span class="ts-news__dot" aria-hidden="true"></span>
<span
>Desde tu última visita, {{ ago(news.since) }}:
<strong>{{ news.what }}</strong> de {{ news.who }}.</span
>
<pi-note
class="ts-banner"
gap="xs"
status="info"
appearance="fill"
type="status"
justify="space-between"
alignment="center"
[title]="
'Desde tu última visita (' +
ago(news.since) +
'): ' +
news.what +
' de ' +
news.who
"
text=""
>
@if (news.firstComment; as first) {
<span class="ts-reminder__actions">
<pi-action-button
size="x-small"
color="grey"
appearance="outline"
text="Ver lo nuevo"
(execute)="goToNews(first)"
/>
</span>
<pi-note-action-content>
<span class="ts-banner__actions">
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Ver lo nuevo"
(execute)="goToNews(first)"
/>
</span>
</pi-note-action-content>
}
</p>
</pi-note>
}
@if (project.canContribute() && issue.allowedTransitions.length) {
@@ -1311,11 +1399,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="pollOptions.set([...pollOptions(), ''])"
/>
}
<pi-action-button
size="small"
<pi-icon-button
icon="trash"
color="grey"
appearance="ghost"
[hasPadding]="false"
appearance="outline"
size="small"
text="Quitar la encuesta"
(execute)="pollOn.set(false)"
/>
@@ -2226,6 +2314,23 @@ export class IssuePage {
protected readonly inMyDay = signal<boolean | null>(null);
/** Changes with the ticket, not with each reload of it. */
private readonly openIssueId = computed(() => this.facade.issue()?.id);
/** «Más» opens under its button, aligned to its right edge. */
protected readonly morePositions: ConnectedPosition[] = [
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: 6,
},
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -6,
},
];
/* «Lo nuevo desde tu última visita»: what others did since I last came. */
private readonly visit = signal<{