feat(tasks): add keep-tasks-open toggle
This commit is contained in:
parent
c2c2598eab
commit
4fd9e6f6bc
3 changed files with 179 additions and 38 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"> </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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal file
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue