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: `
- {{ offLabel() }} + {{ offLabel() }} - {{ onLabel() }} + {{ onLabel() }}
`, 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; diff --git a/frontend/src/app/components/tasks/tasks.component.ts b/frontend/src/app/components/tasks/tasks.component.ts index e30c6cb..183da91 100644 --- a/frontend/src/app/components/tasks/tasks.component.ts +++ b/frontend/src/app/components/tasks/tasks.component.ts @@ -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';
-

- {{ pending().length === 0 ? '' : pending().length }} - {{ pending().length === 0 ? '​' : pending().length === 1 ? 'task' : 'tasks' }} -

+ @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'); + }); +});