Clean up
This commit is contained in:
parent
3930982bd8
commit
ad7968dadd
53 changed files with 564 additions and 1013 deletions
|
|
@ -3,7 +3,6 @@ import {
|
|||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
|
|
@ -33,6 +32,10 @@ interface EditedValue {
|
|||
difficulty: number;
|
||||
}
|
||||
|
||||
function clampDifficulty(value: number): number {
|
||||
return Math.max(1, Math.min(100, value));
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-block-edit',
|
||||
standalone: true,
|
||||
|
|
@ -48,6 +51,8 @@ interface EditedValue {
|
|||
class="carousel"
|
||||
(scroll)="onScroll()"
|
||||
(click)="onBackdropClick($event)"
|
||||
(keydown.enter)="onBackdropClick($any($event))"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="card placeholder"></div>
|
||||
|
||||
|
|
@ -56,12 +61,22 @@ interface EditedValue {
|
|||
class="card"
|
||||
[class.active]="activeIdx() === i + 1"
|
||||
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
|
||||
(click)="onCardClick(i + 1)"
|
||||
(keydown.enter)="onCardClick(i + 1)"
|
||||
(keydown.space)="$event.preventDefault(); onCardClick(i + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
(click)="close.emit(); $event.stopPropagation()"
|
||||
></button>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfTagForBlock(b.id)"
|
||||
|
|
@ -83,6 +98,7 @@ interface EditedValue {
|
|||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
maxlength="10000"
|
||||
[value]="editedFor(b.id).description"
|
||||
(input)="updateDescription(b.id, $any($event.target).value)"
|
||||
(blur)="flushExisting(b.id)"
|
||||
|
|
@ -112,6 +128,7 @@ interface EditedValue {
|
|||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
[disabled]="editedFor(b.id).difficulty >= 100"
|
||||
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
|
|
@ -127,12 +144,22 @@ interface EditedValue {
|
|||
class="card create-card"
|
||||
[class.active]="activeIdx() === blocks().length + 1"
|
||||
[class.near-active]="activeIdx() === blocks().length"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Focus create card"
|
||||
(click)="onCardClick(blocks().length + 1)"
|
||||
(keydown.enter)="onCardClick(blocks().length + 1)"
|
||||
(keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
(click)="close.emit(); $event.stopPropagation()"
|
||||
></button>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfNewTag()"
|
||||
|
|
@ -140,7 +167,7 @@ interface EditedValue {
|
|||
<h1>Create now</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
<div class="select-add-container" [class.required-empty]="!newValue().tag">
|
||||
<lt-select-add
|
||||
[items]="tags()"
|
||||
[selected]="newValue().tag"
|
||||
|
|
@ -154,6 +181,7 @@ interface EditedValue {
|
|||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
maxlength="10000"
|
||||
[value]="newValue().description"
|
||||
(input)="updateNewDescription($any($event.target).value)"
|
||||
></textarea>
|
||||
|
|
@ -182,6 +210,7 @@ interface EditedValue {
|
|||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
[disabled]="newValue().difficulty >= 100"
|
||||
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
|
|
@ -671,7 +700,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
updateDifficulty(id: string, delta: number): void {
|
||||
const next = Math.max(1, this.editedFor(id).difficulty + delta);
|
||||
const next = clampDifficulty(this.editedFor(id).difficulty + delta);
|
||||
this.patchEdited(id, { difficulty: next });
|
||||
this.flushExisting(id);
|
||||
}
|
||||
|
|
@ -717,7 +746,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
updateNewDifficulty(delta: number): void {
|
||||
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
|
||||
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
|
||||
}
|
||||
|
||||
submitNew(): void {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
class="modal"
|
||||
[class.active]="active()"
|
||||
(click)="onBackdropClick($event)"
|
||||
(keydown.enter)="onBackdropClick($any($event))"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="modal__dialog"
|
||||
|
|
@ -94,7 +96,6 @@ export class ModalComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
||||
private previousFocus: HTMLElement | null = null;
|
||||
private escListener!: (e: KeyboardEvent) => void;
|
||||
private readonly modalState = inject(ModalStateService);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Page } from '../../models';
|
||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
||||
|
||||
export interface PageSettingsResult {
|
||||
name: string;
|
||||
hide_create_tower_button: boolean;
|
||||
keep_tasks_open: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-page-settings',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, ToggleComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
<h2>{{ page() ? 'Page settings' : 'New page' }}</h2>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
id="ps-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
placeholder="Page name…"
|
||||
/>
|
||||
|
||||
<div class="toggle-row">
|
||||
<lt-toggle
|
||||
[checked]="hideCreateTowerButton()"
|
||||
(checkedChange)="hideCreateTowerButton.set($event)"
|
||||
offLabel="Show add-tower button"
|
||||
onLabel="Hide add-tower button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<lt-toggle
|
||||
[checked]="keepTasksOpen()"
|
||||
(checkedChange)="keepTasksOpen.set($event)"
|
||||
offLabel="Show tasks collapsed"
|
||||
onLabel="Keep tasks open"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="form.invalid">
|
||||
{{ page() ? 'Save' : 'Create page' }}
|
||||
</button>
|
||||
|
||||
@if (page()) {
|
||||
<button type="button" (click)="delete.emit()">Delete page</button>
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
display: block;
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
// Stay full-width on mobile, but switch to flex so forms.scss's
|
||||
// bottom-alignment keeps the underline hugging the label in the 42px
|
||||
// tap target (plain block centres the text and strands the underline).
|
||||
@media (max-width: $mobile-width) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PageSettingsComponent implements OnInit {
|
||||
readonly page = input<Page | null>(null);
|
||||
readonly save = output<PageSettingsResult>();
|
||||
readonly delete = output<void>();
|
||||
readonly close = output<void>();
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
});
|
||||
|
||||
hideCreateTowerButton = signal(false);
|
||||
readonly keepTasksOpen = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
const p = this.page();
|
||||
if (p) {
|
||||
this.form.patchValue({ name: p.name });
|
||||
this.hideCreateTowerButton.set(p.hide_create_tower_button);
|
||||
this.keepTasksOpen.set(p.keep_tasks_open);
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const v = this.form.value;
|
||||
this.save.emit({
|
||||
name: v.name ?? '',
|
||||
hide_create_tower_button: this.hideCreateTowerButton(),
|
||||
keep_tasks_open: this.keepTasksOpen(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ const UUIDV4_RE =
|
|||
<input
|
||||
type="text"
|
||||
[value]="pageName()"
|
||||
(blur)="onRenamePage($any($event.target).value)"
|
||||
(blur)="onRenamePage($any($event.target))"
|
||||
placeholder="Page name…"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
|
|
@ -271,10 +271,14 @@ export class SettingsComponent {
|
|||
});
|
||||
}
|
||||
|
||||
onRenamePage(value: string): void {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
onRenamePage(input: HTMLInputElement): void {
|
||||
const trimmed = input.value.trim();
|
||||
if (!trimmed) {
|
||||
input.value = this.pageName();
|
||||
return;
|
||||
}
|
||||
this.pageName.set(trimmed);
|
||||
input.value = trimmed;
|
||||
this.flushPageUpdate();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export interface TowerSettingsResult {
|
|||
imports: [ReactiveFormsModule, ColorPickerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -22,7 +22,16 @@
|
|||
}
|
||||
@if (!page().hide_create_tower_button) {
|
||||
<div class="add-tower-wrapper">
|
||||
<img class="add-tower" src="assets/plus-sign.svg" alt="Add tower" (click)="showAddTower.set(true)" />
|
||||
<img
|
||||
class="add-tower"
|
||||
src="assets/plus-sign.svg"
|
||||
alt="Add tower"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="showAddTower.set(true)"
|
||||
(keydown.enter)="showAddTower.set(true)"
|
||||
(keydown.space)="$event.preventDefault(); showAddTower.set(true)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -57,7 +66,7 @@
|
|||
<lt-modal (close)="cancelTowerDelete()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
|
||||
<button class="exit" type="button" (click)="cancelTowerDelete()" aria-label="Cancel"></button>
|
||||
<h2>Delete tower</h2>
|
||||
</div>
|
||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
HostListener,
|
||||
effect,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
|
|
@ -16,7 +19,6 @@ import {
|
|||
DoubleSliderComponent,
|
||||
DoubleSliderRange,
|
||||
} from '../shared/double-slider/double-slider.component';
|
||||
import { computed } from '@angular/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import { ModalStateService } from '../../services/modal-state.service';
|
||||
|
||||
|
|
@ -116,6 +118,14 @@ export class PageComponent {
|
|||
/** Selected date range — `null` = show everything. */
|
||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.showSlider()) {
|
||||
untracked(() => this.dateRange.set(null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
||||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
||||
}
|
||||
|
|
@ -147,7 +157,7 @@ export class PageComponent {
|
|||
}
|
||||
|
||||
onDeleteTower(towerId: string): void {
|
||||
this.store.deleteTower(this.page().id, towerId);
|
||||
this.confirmDeleteTowerId.set(towerId);
|
||||
}
|
||||
|
||||
// ── Block mutations ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
|
|
|
|||
|
|
@ -82,8 +82,6 @@ export class PagesComponent implements OnDestroy {
|
|||
return pages[0] ?? null;
|
||||
});
|
||||
|
||||
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
|
||||
|
||||
readonly confirmDeletePageName = computed(() => {
|
||||
const id = this.confirmDeletePageId();
|
||||
if (!id) return '';
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface DoubleSliderRange<T> {
|
|||
id="ds-1"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[max]="maxIndex()"
|
||||
[value]="oneValue()"
|
||||
(input)="oneValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
|
|
@ -40,7 +40,7 @@ export interface DoubleSliderRange<T> {
|
|||
id="ds-2"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[max]="maxIndex()"
|
||||
[value]="otherValue()"
|
||||
(input)="otherValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
|
|
@ -168,10 +168,9 @@ export class DoubleSliderComponent {
|
|||
|
||||
readonly rangeChange = output<DoubleSliderRange<unknown>>();
|
||||
|
||||
readonly MAX = 100;
|
||||
|
||||
readonly oneValue = signal(0);
|
||||
readonly otherValue = signal(this.MAX - 1);
|
||||
readonly otherValue = signal(0);
|
||||
readonly maxIndex = computed(() => Math.max(0, this.values().length - 1));
|
||||
|
||||
private prevValuesLength = 0;
|
||||
|
||||
|
|
@ -198,32 +197,33 @@ export class DoubleSliderComponent {
|
|||
const hi = Math.max(a, b);
|
||||
untracked(() => {
|
||||
this.rangeChange.emit({
|
||||
from: vs[this.indexFromValue(lo)],
|
||||
to: vs[this.indexFromValue(hi)],
|
||||
from: vs[this.clampIndex(lo)],
|
||||
to: vs[this.clampIndex(hi)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Snap the higher thumb to MAX - 1 when a new entry is appended.
|
||||
// Snap the higher thumb to the newest value when a new entry is appended.
|
||||
effect(() => {
|
||||
const len = this.values().length;
|
||||
untracked(() => {
|
||||
const max = Math.max(0, len - 1);
|
||||
if (len > this.prevValuesLength) {
|
||||
const a = this.oneValue();
|
||||
const b = this.otherValue();
|
||||
if (a > b) this.oneValue.set(this.MAX - 1);
|
||||
else this.otherValue.set(this.MAX - 1);
|
||||
if (a > b) this.oneValue.set(max);
|
||||
else this.otherValue.set(max);
|
||||
} else {
|
||||
if (this.oneValue() > max) this.oneValue.set(max);
|
||||
if (this.otherValue() > max) this.otherValue.set(max);
|
||||
}
|
||||
this.prevValuesLength = len;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private indexFromValue(value: number): number {
|
||||
return Math.min(
|
||||
this.values().length - 1,
|
||||
Math.floor((value / this.MAX) * this.values().length),
|
||||
);
|
||||
private clampIndex(value: number): number {
|
||||
return Math.max(0, Math.min(this.values().length - 1, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -231,9 +231,10 @@ export class DoubleSliderComponent {
|
|||
* upward and rotates -45° as a thumb approaches.
|
||||
*/
|
||||
getOffset(index: number): string {
|
||||
const labelIndex = index / Math.max(1, this.drawnLabels().length);
|
||||
const a = this.oneValue() / this.MAX - 0.1;
|
||||
const b = this.otherValue() / this.MAX - 0.1;
|
||||
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
|
||||
const max = Math.max(1, this.maxIndex());
|
||||
const a = this.oneValue() / max - 0.1;
|
||||
const b = this.otherValue() / max - 0.1;
|
||||
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
|
||||
const ACTIVE_ZONE = 0.2;
|
||||
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
|
|
@ -22,7 +20,15 @@ import {
|
|||
[class.always-shadow]="alwaysDropShadow()"
|
||||
>
|
||||
<div class="background" [class.active]="open()"></div>
|
||||
<div class="top" (click)="toggleOpen($event)">
|
||||
<div
|
||||
class="top"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-expanded]="open()"
|
||||
(click)="toggleOpen($event)"
|
||||
(keydown.enter)="toggleOpen($event)"
|
||||
(keydown.space)="$event.preventDefault(); toggleOpen($event)"
|
||||
>
|
||||
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
||||
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
||||
</div>
|
||||
|
|
@ -33,6 +39,7 @@ import {
|
|||
<input
|
||||
type="text"
|
||||
[value]="item"
|
||||
maxlength="200"
|
||||
(blur)="onRename(item, $any($event.target).value)"
|
||||
/>
|
||||
} @else {
|
||||
|
|
@ -46,6 +53,7 @@ import {
|
|||
type="text"
|
||||
#addInput
|
||||
placeholder="Add a value…"
|
||||
maxlength="200"
|
||||
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
|
||||
/>
|
||||
<button
|
||||
|
|
@ -365,7 +373,7 @@ import {
|
|||
}
|
||||
`,
|
||||
})
|
||||
export class SelectAddComponent implements OnChanges {
|
||||
export class SelectAddComponent {
|
||||
// ── New API (spec) ─────────────────────────────────────────────────────────
|
||||
/** List of string options */
|
||||
readonly items = input<string[]>([]);
|
||||
|
|
@ -415,10 +423,6 @@ export class SelectAddComponent implements OnChanges {
|
|||
return null;
|
||||
}
|
||||
|
||||
ngOnChanges(_changes: SimpleChanges): void {
|
||||
// Nothing to do — signals handle reactivity
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (!this.open()) return;
|
||||
|
|
@ -428,7 +432,7 @@ export class SelectAddComponent implements OnChanges {
|
|||
}
|
||||
}
|
||||
|
||||
toggleOpen(event: MouseEvent): void {
|
||||
toggleOpen(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.open.update((v) => !v);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: `
|
||||
|
|
|
|||
|
|
@ -20,19 +20,20 @@ import { getColorOfTag } from '../../utils/color';
|
|||
(pointerdown)="$event.stopPropagation()"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(touchstart)="$event.stopPropagation()"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
>
|
||||
<p
|
||||
<button
|
||||
type="button"
|
||||
class="header"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
|
||||
</p>
|
||||
@if (pending().length === 0) {
|
||||
<span aria-hidden="true"> </span>
|
||||
} @else {
|
||||
{{ pending().length === 1 ? 'task' : 'tasks' }}
|
||||
}
|
||||
</button>
|
||||
<div
|
||||
#all
|
||||
class="all-task"
|
||||
|
|
@ -49,12 +50,12 @@ import { getColorOfTag } from '../../utils/color';
|
|||
(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)"
|
||||
(pointerup)="$event.stopPropagation()"
|
||||
(touchend)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); edit.emit(b)"
|
||||
>{{ b.description || b.tag }}</p>
|
||||
>{{ b.description || b.tag }}</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -87,9 +88,19 @@ 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));
|
||||
|
|
@ -124,7 +135,7 @@ import { getColorOfTag } from '../../utils/color';
|
|||
gap: calc(var(--small-padding) / 2);
|
||||
}
|
||||
|
||||
&:hover p {
|
||||
&:hover .task-description {
|
||||
@media (min-width: $mobile-width) {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
|
@ -187,7 +198,9 @@ import { getColorOfTag } from '../../utils/color';
|
|||
}
|
||||
}
|
||||
|
||||
p {
|
||||
.task-description {
|
||||
all: unset;
|
||||
@include medium-text();
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
|
|
@ -203,6 +216,7 @@ import { getColorOfTag } from '../../utils/color';
|
|||
}
|
||||
|
||||
position: relative;
|
||||
&::after { content: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -222,7 +236,6 @@ export class TasksComponent {
|
|||
readonly edit = output<Block>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
private lastToggleAt = 0;
|
||||
|
||||
constructor() {
|
||||
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
|
||||
|
|
@ -241,12 +254,6 @@ export class TasksComponent {
|
|||
|
||||
toggleExpanded(event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (event.type === 'touchend') {
|
||||
event.preventDefault();
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this.lastToggleAt < 250) return;
|
||||
this.lastToggleAt = now;
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,6 @@ export function selectVisibleStyledBlocks(
|
|||
|
||||
<div
|
||||
class="container"
|
||||
[class.trash-highlight]="trashHighlight()"
|
||||
>
|
||||
<lt-tasks
|
||||
[pending]="pending()"
|
||||
|
|
@ -264,7 +263,7 @@ export function selectVisibleStyledBlocks(
|
|||
transform: scale(0.75);
|
||||
position: relative;
|
||||
|
||||
:before {
|
||||
&::before {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +313,7 @@ export function selectVisibleStyledBlocks(
|
|||
|
||||
width: 100%;
|
||||
|
||||
:before {
|
||||
&::before {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
|
|
@ -513,8 +512,6 @@ export function selectVisibleStyledBlocks(
|
|||
export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||
readonly tower = input.required<Tower>();
|
||||
/** Set by page component when this tower is being dragged over trash. */
|
||||
readonly trashHighlight = input<boolean>(false);
|
||||
/** Optional date range filter — when set, blocks with `created_at`
|
||||
* outside [from, to] are hidden from the falling stack. */
|
||||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||
|
|
@ -547,6 +544,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private destroyed = false;
|
||||
private readonly animationFrames = new Set<number>();
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||
|
|
@ -638,6 +637,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed = true;
|
||||
for (const id of this.animationFrames) cancelAnimationFrame(id);
|
||||
this.animationFrames.clear();
|
||||
this.resizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
|
|
@ -788,25 +790,48 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
block._opacity = '0';
|
||||
}
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const block of blocks) {
|
||||
block._anim = 'descend';
|
||||
block._transform = 'translateY(0)';
|
||||
block._opacity = '1';
|
||||
}
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
this.requestFrame(() => {
|
||||
this.requestFrame(() => {
|
||||
if (this.destroyed) return;
|
||||
this.finishDescendAnimation(blocks);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private requestFrame(callback: () => void): void {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const id = requestAnimationFrame(() => {
|
||||
this.animationFrames.delete(id);
|
||||
if (!this.destroyed) callback();
|
||||
});
|
||||
this.animationFrames.add(id);
|
||||
}
|
||||
|
||||
private finishDescendAnimation(blocks: StyledBlock[]): void {
|
||||
for (const block of blocks) {
|
||||
block._anim = 'descend';
|
||||
block._transform = 'translateY(0)';
|
||||
block._opacity = '1';
|
||||
}
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
onRename(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const newName = input.value.trim();
|
||||
if (newName && newName !== this.tower().name) {
|
||||
if (!newName) {
|
||||
input.value = this.tower().name;
|
||||
return;
|
||||
}
|
||||
if (newName !== this.tower().name) {
|
||||
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||
} else {
|
||||
input.value = this.tower().name;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue