Modernise & deploy #1

Merged
andras merged 25 commits from asch/modernise into master 2026-05-31 16:48:45 +01:00
3 changed files with 179 additions and 38 deletions
Showing only changes of commit 4fd9e6f6bc - Show all commits

View file

@ -11,7 +11,14 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="toggle"> <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> <label>
<input <input
type="checkbox" type="checkbox"
@ -20,7 +27,14 @@ import {
(change)="set(!checked())" (change)="set(!checked())"
/> />
</label> </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> </div>
`, `,
styles: ` styles: `
@ -30,7 +44,12 @@ import {
$size: 30px; $size: 30px;
@include center-child(); @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 { .toggle {
display: contents; display: contents;
@ -41,7 +60,7 @@ import {
// Fixed width (not max-width) so multiple toggles align column-wise // Fixed width (not max-width) so multiple toggles align column-wise
// — the thumb position is identical across rows regardless of label. // — the thumb position is identical across rows regardless of label.
flex: 0 0 auto; flex: 0 0 auto;
width: 4 * $size; width: var(--toggle-label-width, #{4 * $size});
box-sizing: border-box; box-sizing: border-box;
padding: 0 var(--small-padding); padding: 0 var(--small-padding);
line-height: 1.3; line-height: 1.3;
@ -50,10 +69,19 @@ import {
&.active { font-weight: bold; } &.active { font-weight: bold; }
&:first-of-type { text-align: right; } &:first-of-type { text-align: right; }
&:last-of-type { text-align: left; } &: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 { label {
display: block; display: block;
flex: 0 0 auto;
input[type='checkbox'] { input[type='checkbox'] {
-webkit-appearance: none; -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 { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color'; 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. * 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 colored tickbox marks the task done.
* Clicking the description opens the block-edit modal via the `edit` output. * Clicking the description opens the block-edit modal via the `edit` output.
*/ */
@ -17,16 +35,29 @@ import { getColorOfTag } from '../../utils/color';
<div <div
class="container" class="container"
[class.show-hover]="pending().length > 0" [class.show-hover]="pending().length > 0"
(click)="expanded.update(v => !v)" (pointerdown)="$event.stopPropagation()"
(mousedown)="$event.stopPropagation()"
(touchstart)="$event.stopPropagation()"
> >
<p class="header"> @if (!initiallyOpen()) {
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong> <button
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }} type="button"
</p> 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 <div
class="all-task"
#all #all
[style.height.px]="expanded() ? all.scrollHeight : 0" class="all-task"
[style.max-height]="taskListMaxHeight(expanded())"
> >
@for (b of pending(); track b.id) { @for (b of pending(); track b.id) {
<div class="task-container"> <div class="task-container">
@ -34,13 +65,17 @@ import { getColorOfTag } from '../../utils/color';
type="button" type="button"
class="tickbox" class="tickbox"
[style.background-color]="colorOf(b.tag)" [style.background-color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); markDone.emit(b)" (click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'" [attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button> ></button>
<p <button
type="button"
class="task-description"
[style.color]="colorOf(b.tag)" [style.color]="colorOf(b.tag)"
(click)="$event.stopPropagation(); edit.emit(b)" (click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</p> >{{ b.description || b.tag }}</button>
</div> </div>
} }
</div> </div>
@ -73,26 +108,51 @@ import { getColorOfTag } from '../../utils/color';
max-height: 30vh; max-height: 30vh;
overflow-y: auto; 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 { .all-task {
@include inner-spacing(var(--small-padding)); @include inner-spacing(var(--small-padding));
:first-child { margin-top: var(--small-padding); } :first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box; box-sizing: border-box;
transition: height $long-animation-time; transition: max-height $long-animation-time;
overflow-y: hidden;
/*
* 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 { .task-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--small-padding); 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) { @media (min-width: $mobile-width) {
color: inherit !important; color: inherit !important;
} }
@ -102,14 +162,16 @@ import { getColorOfTag } from '../../utils/color';
// done without opening the edit carousel. Hover & focus reveal a // done without opening the edit carousel. Hover & focus reveal a
// subtle inner check mark. // subtle inner check mark.
.tickbox { .tickbox {
flex: 0 0 auto;
all: unset; // strip native button styles all: unset; // strip native button styles
flex: 0 0 24px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
@include square(24px); @include square(24px);
min-width: 24px;
min-height: 24px;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
@include square(20px); @include square(24px);
} }
border-radius: 4px; border-radius: 4px;
box-shadow: $shadow-border; box-shadow: $shadow-border;
@ -119,14 +181,25 @@ import { getColorOfTag } from '../../utils/color';
content: '✓'; content: '✓';
position: absolute; position: absolute;
inset: 0; 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(); @include center-child();
color: $light-color; color: $light-color;
font-size: 18px; font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
font-weight: bold; opacity: 0; // hidden at rest — only revealed on hover/focus/active
line-height: 1;
opacity: 0.5;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4); 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; transition: opacity $short-animation-time, transform $short-animation-time;
} }
@ -138,23 +211,29 @@ import { getColorOfTag } from '../../utils/color';
} }
&:active { &:active {
transform: scale(0.95); 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; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow-x: hidden; overflow-x: hidden;
text-align: left; text-align: left;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0;
cursor: pointer; cursor: pointer;
@media (max-width: $mobile-width) { @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; position: relative;
&::after { content: none; }
} }
} }
} }
@ -173,20 +252,28 @@ export class TasksComponent {
/** Emitted when the description is clicked — parent opens the block-edit modal. */ /** Emitted when the description is clicked — parent opens the block-edit modal. */
readonly edit = output<Block>(); 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() { constructor() {
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping // When the page setting switches back to collapsed, discard any older manual
// the "Keep tasks open" page setting expands/collapses the accordion live. // open state so the setting is reflected immediately.
// User clicks (which mutate `expanded` directly) are respected until the
// setting changes again.
effect(() => { effect(() => {
const open = this.initiallyOpen(); const keepOpen = this.initiallyOpen();
untracked(() => this.expanded.set(open)); if (!keepOpen) untracked(() => this.manuallyExpanded.set(false));
}); });
} }
colorOf(tag: string): string { colorOf(tag: string): string {
return getColorOfTag(tag, this.baseColor()); 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');
});
});