feat(tasks): add keep-tasks-open toggle

This commit is contained in:
Andras Schmelczer 2026-05-31 10:49:26 +01:00
parent c2c2598eab
commit 4fd9e6f6bc
3 changed files with 179 additions and 38 deletions

View file

@ -11,7 +11,14 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toggle">
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
<span
role="button"
tabindex="0"
[class.active]="!checked()"
(click)="set(false)"
(keydown.enter)="set(false)"
(keydown.space)="$event.preventDefault(); set(false)"
>{{ offLabel() }}</span>
<label>
<input
type="checkbox"
@ -20,7 +27,14 @@ import {
(change)="set(!checked())"
/>
</label>
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
<span
role="button"
tabindex="0"
[class.active]="checked()"
(click)="set(true)"
(keydown.enter)="set(true)"
(keydown.space)="$event.preventDefault(); set(true)"
>{{ onLabel() }}</span>
</div>
`,
styles: `
@ -30,7 +44,12 @@ import {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
gap: var(--medium-padding);
@media (max-width: $mobile-width) {
width: 100%;
gap: var(--small-padding);
}
.toggle {
display: contents;
@ -41,7 +60,7 @@ import {
// Fixed width (not max-width) so multiple toggles align column-wise
// — the thumb position is identical across rows regardless of label.
flex: 0 0 auto;
width: 4 * $size;
width: var(--toggle-label-width, #{4 * $size});
box-sizing: border-box;
padding: 0 var(--small-padding);
line-height: 1.3;
@ -50,10 +69,19 @@ import {
&.active { font-weight: bold; }
&:first-of-type { text-align: right; }
&:last-of-type { text-align: left; }
@media (max-width: $mobile-width) {
flex: 1 1 0;
width: auto;
min-width: 0;
padding: 0;
overflow-wrap: anywhere;
}
}
label {
display: block;
flex: 0 0 auto;
input[type='checkbox'] {
-webkit-appearance: none;

View file

@ -1,10 +1,28 @@
import { Component, ChangeDetectionStrategy, input, output, signal, effect, untracked } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
computed,
effect,
input,
output,
signal,
untracked,
} from '@angular/core';
import { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color';
export function shouldExpandTasks(keepTasksOpen: boolean, manuallyExpanded: boolean): boolean {
return keepTasksOpen || manuallyExpanded;
}
export function taskListMaxHeight(expanded: boolean): string {
return expanded ? 'none' : '0px';
}
/**
* Tasks accordion shows pending (not-done) blocks inside a tower.
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses.
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses
* unless the page setting is keeping tasks open.
* Clicking the colored tickbox marks the task done.
* Clicking the description opens the block-edit modal via the `edit` output.
*/
@ -17,16 +35,29 @@ import { getColorOfTag } from '../../utils/color';
<div
class="container"
[class.show-hover]="pending().length > 0"
(click)="expanded.update(v => !v)"
(pointerdown)="$event.stopPropagation()"
(mousedown)="$event.stopPropagation()"
(touchstart)="$event.stopPropagation()"
>
<p class="header">
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
</p>
@if (!initiallyOpen()) {
<button
type="button"
class="header"
(click)="toggleExpanded($event)"
[attr.aria-expanded]="expanded()"
>
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
@if (pending().length === 0) {
<span aria-hidden="true">&nbsp;</span>
} @else {
{{ pending().length === 1 ? 'task' : 'tasks' }}
}
</button>
}
<div
class="all-task"
#all
[style.height.px]="expanded() ? all.scrollHeight : 0"
class="all-task"
[style.max-height]="taskListMaxHeight(expanded())"
>
@for (b of pending(); track b.id) {
<div class="task-container">
@ -34,13 +65,17 @@ import { getColorOfTag } from '../../utils/color';
type="button"
class="tickbox"
[style.background-color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button>
<p
<button
type="button"
class="task-description"
[style.color]="colorOf(b.tag)"
(click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</p>
>{{ b.description || b.tag }}</button>
</div>
}
</div>
@ -73,26 +108,51 @@ import { getColorOfTag } from '../../utils/color';
max-height: 30vh;
overflow-y: auto;
.header { cursor: pointer; }
.header {
all: unset;
@include medium-text();
display: block;
width: 100%;
box-sizing: border-box;
cursor: pointer;
text-align: center;
p { font-size: var(--medium-font-size); }
&::after {
content: none;
}
}
.all-task {
@include inner-spacing(var(--small-padding));
:first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
transition: max-height $long-animation-time;
/*
* Clip while collapsed only. When open, let the outer .container own
* scrolling via max-height: 30vh; a nested scroller here pops a
* scrollbar the instant a tickbox grows on hover.
*/
overflow: hidden;
// Sideways breathing room so the clip doesn't shear the tickbox's
// hover shadow; negative side margins keep rows flush with the header,
// and the bottom padding clears the last row's shadow.
margin: 0 calc(var(--small-padding) / -2);
padding: 0 calc(var(--small-padding) / 2) calc(var(--small-padding) / 2);
.task-container {
display: flex;
align-items: center;
gap: var(--small-padding);
&:hover p {
@media (max-width: $mobile-width) {
gap: calc(var(--small-padding) / 2);
}
&:hover .task-description {
@media (min-width: $mobile-width) {
color: inherit !important;
}
@ -102,14 +162,16 @@ import { getColorOfTag } from '../../utils/color';
// done without opening the edit carousel. Hover & focus reveal a
// subtle inner check mark.
.tickbox {
flex: 0 0 auto;
all: unset; // strip native button styles
flex: 0 0 24px;
cursor: pointer;
position: relative;
box-sizing: border-box;
@include square(24px);
min-width: 24px;
min-height: 24px;
@media (max-width: $mobile-width) {
@include square(20px);
@include square(24px);
}
border-radius: 4px;
box-shadow: $shadow-border;
@ -119,14 +181,25 @@ import { getColorOfTag } from '../../utils/color';
content: '✓';
position: absolute;
inset: 0;
/*
* Neutralise the global animated-underline bar from
* forms.scss (button:after { height: 2px; width: 0->100% on
* hover; background-color: $text-color }). The all:unset on the
* button does NOT reach the pseudo-element, so without these
* resets the bar paints a dark stripe across the top AND
* squashes this box to 2px which centres the glyph near the
* top instead of the middle.
*/
width: 100%;
height: 100%;
background: none;
@include center-child();
color: $light-color;
font-size: 18px;
font-weight: bold;
line-height: 1;
opacity: 0.5;
font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
opacity: 0; // hidden at rest — only revealed on hover/focus/active
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
transform: translateY(2px);
// The ✓ glyph sits a touch high in its em-box; nudge to optical centre.
transform: translateY(1px);
transition: opacity $short-animation-time, transform $short-animation-time;
}
@ -138,23 +211,29 @@ import { getColorOfTag } from '../../utils/color';
}
&:active {
transform: scale(0.95);
&::after { opacity: 1; transform: translateY(2px) scale(1.05); }
&::after { opacity: 1; transform: translateY(1px) scale(1.05); }
}
}
p {
.task-description {
all: unset;
@include medium-text();
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
font-size: var(--medium-font-size);
font-weight: 500;
filter: saturate(0.85) brightness(0.82);
}
position: relative;
&::after { content: none; }
}
}
}
@ -173,20 +252,28 @@ export class TasksComponent {
/** Emitted when the description is clicked — parent opens the block-edit modal. */
readonly edit = output<Block>();
readonly expanded = signal(false);
private readonly manuallyExpanded = signal(false);
readonly expanded = computed(() =>
shouldExpandTasks(this.initiallyOpen(), this.manuallyExpanded()),
);
readonly taskListMaxHeight = taskListMaxHeight;
constructor() {
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
// the "Keep tasks open" page setting expands/collapses the accordion live.
// User clicks (which mutate `expanded` directly) are respected until the
// setting changes again.
// When the page setting switches back to collapsed, discard any older manual
// open state so the setting is reflected immediately.
effect(() => {
const open = this.initiallyOpen();
untracked(() => this.expanded.set(open));
const keepOpen = this.initiallyOpen();
if (!keepOpen) untracked(() => this.manuallyExpanded.set(false));
});
}
colorOf(tag: string): string {
return getColorOfTag(tag, this.baseColor());
}
toggleExpanded(event: Event): void {
event.stopPropagation();
if (this.initiallyOpen()) return;
this.manuallyExpanded.update((v) => !v);
}
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { shouldExpandTasks, taskListMaxHeight } from './tasks.component';
describe('shouldExpandTasks', () => {
it('expands when tasks should be kept open by page setting', () => {
expect(shouldExpandTasks(true, false)).toBe(true);
});
it('expands when the user manually opens the accordion', () => {
expect(shouldExpandTasks(false, true)).toBe(true);
});
it('collapses when keep-open is disabled', () => {
expect(shouldExpandTasks(false, false)).toBe(false);
});
});
describe('taskListMaxHeight', () => {
it('does not cap open task lists by a measured height', () => {
expect(taskListMaxHeight(true)).toBe('none');
});
it('clips collapsed task lists', () => {
expect(taskListMaxHeight(false)).toBe('0px');
});
});