diff --git a/frontend/src/app/components/shared/toggle/toggle.component.ts b/frontend/src/app/components/shared/toggle/toggle.component.ts
index 5ec865e..945a794 100644
--- a/frontend/src/app/components/shared/toggle/toggle.component.ts
+++ b/frontend/src/app/components/shared/toggle/toggle.component.ts
@@ -11,7 +11,14 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
0"
- (click)="expanded.update(v => !v)"
+ (pointerdown)="$event.stopPropagation()"
+ (mousedown)="$event.stopPropagation()"
+ (touchstart)="$event.stopPropagation()"
>
-
+ @if (!initiallyOpen()) {
+
+ }
@for (b of pending(); track b.id) {
@@ -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'"
>
-
{{ b.description || b.tag }}
+ >{{ b.description || b.tag }}
}
@@ -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
();
- 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);
+ }
}
diff --git a/frontend/src/app/components/tasks/tasks.component.vitest.ts b/frontend/src/app/components/tasks/tasks.component.vitest.ts
new file mode 100644
index 0000000..fe12ec6
--- /dev/null
+++ b/frontend/src/app/components/tasks/tasks.component.vitest.ts
@@ -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');
+ });
+});