feat: Implement tasks feature using NGRX signals and remove the old counter store, alongside general project configuration and skill documentation updates.
continuous-integration/drone/pr Build is passing

This commit is contained in:
Dennis Hundertmark
2026-03-08 09:50:17 +01:00
parent 2184971175
commit 9d13cc652a
47 changed files with 15272 additions and 14144 deletions
@@ -15,23 +15,23 @@
```typescript
@Directive({
selector: "[appAutoFocus]",
selector: '[appAutoFocus]',
})
export class AutoFocus {
private el = inject(ElementRef<HTMLElement>);
private el = inject(ElementRef<HTMLElement>);
enabled = input(true, { alias: "appAutoFocus", transform: booleanAttribute });
delay = input(0);
enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute });
delay = input(0);
constructor() {
afterNextRender(() => {
if (this.enabled()) {
setTimeout(() => {
this.el.nativeElement.focus();
}, this.delay());
}
});
}
constructor() {
afterNextRender(() => {
if (this.enabled()) {
setTimeout(() => {
this.el.nativeElement.focus();
}, this.delay());
}
});
}
}
// Usage: <input appAutoFocus />
@@ -42,26 +42,26 @@ export class AutoFocus {
```typescript
@Directive({
selector: "[appSelectAll]",
host: {
"(focus)": "onFocus()",
"(click)": "onClick($event)",
},
selector: '[appSelectAll]',
host: {
'(focus)': 'onFocus()',
'(click)': 'onClick($event)',
},
})
export class SelectAll {
private el = inject(ElementRef<HTMLInputElement>);
private el = inject(ElementRef<HTMLInputElement>);
onFocus() {
// Delay to ensure value is set
setTimeout(() => this.el.nativeElement.select(), 0);
}
onClick(event: MouseEvent) {
// Select all on first click if not already focused
if (document.activeElement !== this.el.nativeElement) {
this.el.nativeElement.select();
onFocus() {
// Delay to ensure value is set
setTimeout(() => this.el.nativeElement.select(), 0);
}
onClick(event: MouseEvent) {
// Select all on first click if not already focused
if (document.activeElement !== this.el.nativeElement) {
this.el.nativeElement.select();
}
}
}
}
// Usage: <input appSelectAll value="Select me on focus" />
@@ -71,26 +71,26 @@ export class SelectAll {
```typescript
@Directive({
selector: "[appCopyToClipboard]",
host: {
"(click)": "copy()",
"[style.cursor]": '"pointer"',
},
selector: '[appCopyToClipboard]',
host: {
'(click)': 'copy()',
'[style.cursor]': '"pointer"',
},
})
export class CopyToClipboard {
text = input.required<string>({ alias: "appCopyToClipboard" });
text = input.required<string>({ alias: 'appCopyToClipboard' });
copied = output<void>();
error = output<Error>();
copied = output<void>();
error = output<Error>();
async copy() {
try {
await navigator.clipboard.writeText(this.text());
this.copied.emit();
} catch (err) {
this.error.emit(err as Error);
async copy() {
try {
await navigator.clipboard.writeText(this.text());
this.copied.emit();
} catch (err) {
this.error.emit(err as Error);
}
}
}
}
// Usage:
@@ -105,24 +105,24 @@ export class CopyToClipboard {
```typescript
@Directive({
selector: "input[appTrim], textarea[appTrim]",
host: {
"(blur)": "onBlur()",
},
selector: 'input[appTrim], textarea[appTrim]',
host: {
'(blur)': 'onBlur()',
},
})
export class Trim {
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
private ngControl = inject(NgControl, { optional: true, self: true });
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
private ngControl = inject(NgControl, { optional: true, self: true });
onBlur() {
const value = this.el.nativeElement.value;
const trimmed = value.trim();
onBlur() {
const value = this.el.nativeElement.value;
const trimmed = value.trim();
if (value !== trimmed) {
this.el.nativeElement.value = trimmed;
this.ngControl?.control?.setValue(trimmed);
if (value !== trimmed) {
this.el.nativeElement.value = trimmed;
this.ngControl?.control?.setValue(trimmed);
}
}
}
}
// Usage: <input appTrim formControlName="name" />
@@ -132,92 +132,88 @@ export class Trim {
```typescript
@Directive({
selector: "[appMask]",
host: {
"(input)": "onInput($event)",
"(keydown)": "onKeydown($event)",
},
selector: '[appMask]',
host: {
'(input)': 'onInput($event)',
'(keydown)': 'onKeydown($event)',
},
})
export class Mask {
private el = inject(ElementRef<HTMLInputElement>);
private el = inject(ElementRef<HTMLInputElement>);
// Mask pattern: 9 = digit, A = letter, * = any
mask = input.required<string>({ alias: "appMask" });
// Mask pattern: 9 = digit, A = letter, * = any
mask = input.required<string>({ alias: 'appMask' });
onInput(event: InputEvent) {
const input = this.el.nativeElement;
const value = input.value;
const masked = this.applyMask(value);
onInput(event: InputEvent) {
const input = this.el.nativeElement;
const value = input.value;
const masked = this.applyMask(value);
if (value !== masked) {
input.value = masked;
}
}
onKeydown(event: KeyboardEvent) {
// Allow navigation keys
if (
["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(
event.key,
)
) {
return;
}
const input = this.el.nativeElement;
const position = input.selectionStart ?? 0;
const maskChar = this.mask()[position];
if (!maskChar) {
event.preventDefault();
return;
}
if (!this.isValidChar(event.key, maskChar)) {
event.preventDefault();
}
}
private applyMask(value: string): string {
const mask = this.mask();
let result = "";
let valueIndex = 0;
for (let i = 0; i < mask.length && valueIndex < value.length; i++) {
const maskChar = mask[i];
const inputChar = value[valueIndex];
if (maskChar === "9" || maskChar === "A" || maskChar === "*") {
if (this.isValidChar(inputChar, maskChar)) {
result += inputChar;
valueIndex++;
} else {
valueIndex++;
i--;
if (value !== masked) {
input.value = masked;
}
} else {
result += maskChar;
if (inputChar === maskChar) {
valueIndex++;
}
onKeydown(event: KeyboardEvent) {
// Allow navigation keys
if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(event.key)) {
return;
}
const input = this.el.nativeElement;
const position = input.selectionStart ?? 0;
const maskChar = this.mask()[position];
if (!maskChar) {
event.preventDefault();
return;
}
if (!this.isValidChar(event.key, maskChar)) {
event.preventDefault();
}
}
}
return result;
}
private applyMask(value: string): string {
const mask = this.mask();
let result = '';
let valueIndex = 0;
private isValidChar(char: string, maskChar: string): boolean {
switch (maskChar) {
case "9":
return /\d/.test(char);
case "A":
return /[a-zA-Z]/.test(char);
case "*":
return /[a-zA-Z0-9]/.test(char);
default:
return char === maskChar;
for (let i = 0; i < mask.length && valueIndex < value.length; i++) {
const maskChar = mask[i];
const inputChar = value[valueIndex];
if (maskChar === '9' || maskChar === 'A' || maskChar === '*') {
if (this.isValidChar(inputChar, maskChar)) {
result += inputChar;
valueIndex++;
} else {
valueIndex++;
i--;
}
} else {
result += maskChar;
if (inputChar === maskChar) {
valueIndex++;
}
}
}
return result;
}
private isValidChar(char: string, maskChar: string): boolean {
switch (maskChar) {
case '9':
return /\d/.test(char);
case 'A':
return /[a-zA-Z]/.test(char);
case '*':
return /[a-zA-Z0-9]/.test(char);
default:
return char === maskChar;
}
}
}
}
// Usage: <input appMask="(999) 999-9999" placeholder="(555) 123-4567" />
@@ -227,29 +223,29 @@ export class Mask {
```typescript
@Directive({
selector: "[appCharCount]",
selector: '[appCharCount]',
})
export class CharCount {
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
maxLength = input.required<number>({ alias: "appCharCount" });
maxLength = input.required<number>({ alias: 'appCharCount' });
currentLength = signal(0);
remaining = computed(() => this.maxLength() - this.currentLength());
isOverLimit = computed(() => this.remaining() < 0);
currentLength = signal(0);
remaining = computed(() => this.maxLength() - this.currentLength());
isOverLimit = computed(() => this.remaining() < 0);
constructor() {
effect(() => {
this.currentLength.set(this.el.nativeElement.value.length);
});
constructor() {
effect(() => {
this.currentLength.set(this.el.nativeElement.value.length);
});
// Listen for input changes
afterNextRender(() => {
this.el.nativeElement.addEventListener("input", () => {
this.currentLength.set(this.el.nativeElement.value.length);
});
});
}
// Listen for input changes
afterNextRender(() => {
this.el.nativeElement.addEventListener('input', () => {
this.currentLength.set(this.el.nativeElement.value.length);
});
});
}
}
// Usage with template:
@@ -263,59 +259,59 @@ export class CharCount {
```typescript
@Directive({
selector: "[appLazyLoad]",
selector: '[appLazyLoad]',
})
export class LazyLoad implements OnDestroy {
private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null;
private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null;
src = input.required<string>({ alias: "appLazyLoad" });
placeholder = input("/assets/placeholder.png");
src = input.required<string>({ alias: 'appLazyLoad' });
placeholder = input('/assets/placeholder.png');
loaded = output<void>();
loaded = output<void>();
constructor() {
afterNextRender(() => {
this.setupObserver();
});
}
private setupObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
this.observer?.disconnect();
}
constructor() {
afterNextRender(() => {
this.setupObserver();
});
},
{ rootMargin: "50px" },
);
this.observer.observe(this.el.nativeElement);
// Set placeholder
if (this.el.nativeElement instanceof HTMLImageElement) {
this.el.nativeElement.src = this.placeholder();
}
}
private loadImage() {
const element = this.el.nativeElement;
private setupObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
this.observer?.disconnect();
}
});
},
{ rootMargin: '50px' },
);
if (element instanceof HTMLImageElement) {
element.src = this.src();
element.onload = () => this.loaded.emit();
} else {
element.style.backgroundImage = `url(${this.src()})`;
this.loaded.emit();
this.observer.observe(this.el.nativeElement);
// Set placeholder
if (this.el.nativeElement instanceof HTMLImageElement) {
this.el.nativeElement.src = this.placeholder();
}
}
}
ngOnDestroy() {
this.observer?.disconnect();
}
private loadImage() {
const element = this.el.nativeElement;
if (element instanceof HTMLImageElement) {
element.src = this.src();
element.onload = () => this.loaded.emit();
} else {
element.style.backgroundImage = `url(${this.src()})`;
this.loaded.emit();
}
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
// Usage: <img [appLazyLoad]="imageUrl" alt="Lazy loaded image" />
@@ -325,49 +321,49 @@ export class LazyLoad implements OnDestroy {
```typescript
@Directive({
selector: "[appInfiniteScroll]",
selector: '[appInfiniteScroll]',
})
export class InfiniteScroll implements OnDestroy {
private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null;
private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null;
threshold = input(0.1);
disabled = input(false);
threshold = input(0.1);
disabled = input(false);
scrolled = output<void>();
scrolled = output<void>();
constructor() {
afterNextRender(() => {
this.setupObserver();
});
constructor() {
afterNextRender(() => {
this.setupObserver();
});
effect(() => {
if (this.disabled()) {
effect(() => {
if (this.disabled()) {
this.observer?.disconnect();
} else {
this.setupObserver();
}
});
}
private setupObserver() {
this.observer?.disconnect();
} else {
this.setupObserver();
}
});
}
private setupObserver() {
this.observer?.disconnect();
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.disabled()) {
this.scrolled.emit();
}
},
{ threshold: this.threshold() },
);
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.disabled()) {
this.scrolled.emit();
}
},
{ threshold: this.threshold() },
);
this.observer.observe(this.el.nativeElement);
}
this.observer.observe(this.el.nativeElement);
}
ngOnDestroy() {
this.observer?.disconnect();
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
// Usage:
@@ -385,35 +381,35 @@ export class InfiniteScroll implements OnDestroy {
```typescript
@Directive({
selector: "[appResize]",
selector: '[appResize]',
})
export class Resize implements OnDestroy {
private el = inject(ElementRef<HTMLElement>);
private observer: ResizeObserver | null = null;
private el = inject(ElementRef<HTMLElement>);
private observer: ResizeObserver | null = null;
width = signal(0);
height = signal(0);
width = signal(0);
height = signal(0);
resized = output<{ width: number; height: number }>();
resized = output<{ width: number; height: number }>();
constructor() {
afterNextRender(() => {
this.observer = new ResizeObserver((entries) => {
const entry = entries[0];
const { width, height } = entry.contentRect;
constructor() {
afterNextRender(() => {
this.observer = new ResizeObserver((entries) => {
const entry = entries[0];
const { width, height } = entry.contentRect;
this.width.set(width);
this.height.set(height);
this.resized.emit({ width, height });
});
this.width.set(width);
this.height.set(height);
this.resized.emit({ width, height });
});
this.observer.observe(this.el.nativeElement);
});
}
this.observer.observe(this.el.nativeElement);
});
}
ngOnDestroy() {
this.observer?.disconnect();
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
// Usage:
@@ -426,75 +422,72 @@ export class Resize implements OnDestroy {
```typescript
@Directive({
selector: "[appDraggable]",
host: {
draggable: "true",
"[class.dragging]": "isDragging()",
"(dragstart)": "onDragStart($event)",
"(dragend)": "onDragEnd($event)",
},
selector: '[appDraggable]',
host: {
'draggable': 'true',
'[class.dragging]': 'isDragging()',
'(dragstart)': 'onDragStart($event)',
'(dragend)': 'onDragEnd($event)',
},
})
export class Draggable {
data = input<any>(null, { alias: "appDraggable" });
effectAllowed = input<DataTransfer["effectAllowed"]>("move");
data = input<any>(null, { alias: 'appDraggable' });
effectAllowed = input<DataTransfer['effectAllowed']>('move');
isDragging = signal(false);
isDragging = signal(false);
dragStart = output<DragEvent>();
dragEnd = output<DragEvent>();
dragStart = output<DragEvent>();
dragEnd = output<DragEvent>();
onDragStart(event: DragEvent) {
this.isDragging.set(true);
onDragStart(event: DragEvent) {
this.isDragging.set(true);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = this.effectAllowed();
event.dataTransfer.setData(
"application/json",
JSON.stringify(this.data()),
);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = this.effectAllowed();
event.dataTransfer.setData('application/json', JSON.stringify(this.data()));
}
this.dragStart.emit(event);
}
this.dragStart.emit(event);
}
onDragEnd(event: DragEvent) {
this.isDragging.set(false);
this.dragEnd.emit(event);
}
onDragEnd(event: DragEvent) {
this.isDragging.set(false);
this.dragEnd.emit(event);
}
}
@Directive({
selector: "[appDropZone]",
host: {
"[class.drag-over]": "isDragOver()",
"(dragover)": "onDragOver($event)",
"(dragleave)": "onDragLeave($event)",
"(drop)": "onDrop($event)",
},
selector: '[appDropZone]',
host: {
'[class.drag-over]': 'isDragOver()',
'(dragover)': 'onDragOver($event)',
'(dragleave)': 'onDragLeave($event)',
'(drop)': 'onDrop($event)',
},
})
export class DropZone {
isDragOver = signal(false);
isDragOver = signal(false);
dropped = output<any>();
dropped = output<any>();
onDragOver(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(true);
}
onDragLeave(event: DragEvent) {
this.isDragOver.set(false);
}
onDrop(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(false);
const data = event.dataTransfer?.getData("application/json");
if (data) {
this.dropped.emit(JSON.parse(data));
onDragOver(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(true);
}
onDragLeave(event: DragEvent) {
this.isDragOver.set(false);
}
onDrop(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(false);
const data = event.dataTransfer?.getData('application/json');
if (data) {
this.dropped.emit(JSON.parse(data));
}
}
}
}
// Usage:
@@ -506,42 +499,42 @@ export class DropZone {
```typescript
@Directive({
selector: "[appHasPermission]",
selector: '[appHasPermission]',
})
export class HasPermission {
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private authService = inject(Auth);
private hasView = false;
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private authService = inject(Auth);
private hasView = false;
permission = input.required<string | string[]>({ alias: "appHasPermission" });
mode = input<"any" | "all">("any");
permission = input.required<string | string[]>({ alias: 'appHasPermission' });
mode = input<'any' | 'all'>('any');
constructor() {
effect(() => {
const hasPermission = this.checkPermission();
constructor() {
effect(() => {
const hasPermission = this.checkPermission();
if (hasPermission && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!hasPermission && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
});
}
private checkPermission(): boolean {
const required = this.permission();
const permissions = Array.isArray(required) ? required : [required];
const userPermissions = this.authService.permissions();
if (this.mode() === "all") {
return permissions.every((p) => userPermissions.includes(p));
if (hasPermission && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!hasPermission && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
});
}
return permissions.some((p) => userPermissions.includes(p));
}
private checkPermission(): boolean {
const required = this.permission();
const permissions = Array.isArray(required) ? required : [required];
const userPermissions = this.authService.permissions();
if (this.mode() === 'all') {
return permissions.every((p) => userPermissions.includes(p));
}
return permissions.some((p) => userPermissions.includes(p));
}
}
// Usage:
@@ -553,23 +546,23 @@ export class HasPermission {
```typescript
@Directive({
selector: "[appToggle]",
exportAs: "appToggle",
selector: '[appToggle]',
exportAs: 'appToggle',
})
export class Toggle {
isOpen = signal(false);
isOpen = signal(false);
toggle() {
this.isOpen.update((v) => !v);
}
toggle() {
this.isOpen.update((v) => !v);
}
open() {
this.isOpen.set(true);
}
open() {
this.isOpen.set(true);
}
close() {
this.isOpen.set(false);
}
close() {
this.isOpen.set(false);
}
}
// Usage: