feat: add @sda/flair-elements-ui library

Angular 19+ standalone library with 61 components, 15 directives,
and 8 services for decorative visual effects, animations, and
micro-interactions.

Components include particle systems, flow fields, aurora effects,
tilt/holographic/spotlight cards, text effects, dividers, scroll
animations, generative art, celebrations, image treatments,
morphing shapes, terminal output, and pattern textures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-03-09 10:24:52 +10:00
commit daf6182e94
230 changed files with 10642 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
.fl-counter-trigger {
display: inline-block;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,101 @@
import {
Component, ChangeDetectionStrategy, input, inject,
ElementRef, afterNextRender, DestroyRef, signal,
} from '@angular/core';
import { ReducedMotionService } from '../../services/reduced-motion.service';
import { ScrollObserverService } from '../../services/scroll-observer.service';
@Component({
selector: 'fl-counter-trigger',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ prefix() }}{{ formattedValue() }}{{ suffix() }}`,
styleUrl: './fl-counter-trigger.component.scss',
host: { 'class': 'fl-counter-trigger' },
})
export class FlCounterTriggerComponent {
private el = inject(ElementRef);
private reducedMotion = inject(ReducedMotionService);
private scrollObserver = inject(ScrollObserverService);
private destroyRef = inject(DestroyRef);
// Inputs
readonly value = input.required<number>();
readonly duration = input(2000);
readonly prefix = input('');
readonly suffix = input('');
// Internal
readonly formattedValue = signal('0');
private currentValue = 0;
private startValue = 0;
private targetValue = 0;
private startTime = 0;
private animationFrame: number | null = null;
private hasTriggered = false;
private observerCleanup: (() => void) | null = null;
constructor() {
afterNextRender(() => {
const host = this.el.nativeElement as HTMLElement;
this.observerCleanup = this.scrollObserver.observe(
host,
(entry) => {
if (entry.isIntersecting && !this.hasTriggered) {
this.hasTriggered = true;
this.startAnimation();
}
},
{ threshold: 0.5 }
);
});
this.destroyRef.onDestroy(() => {
this.observerCleanup?.();
if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame);
}
});
}
private startAnimation(): void {
this.startValue = 0;
this.targetValue = this.value();
if (this.reducedMotion.reduced()) {
this.currentValue = this.targetValue;
this.formattedValue.set(this.formatNumber(this.targetValue));
return;
}
this.startTime = performance.now();
this.animate();
}
private animate = (): void => {
const now = performance.now();
const elapsed = now - this.startTime;
const duration = this.duration();
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
this.currentValue = this.startValue + (this.targetValue - this.startValue) * eased;
this.formattedValue.set(this.formatNumber(this.currentValue));
if (progress < 1) {
this.animationFrame = requestAnimationFrame(this.animate);
} else {
this.currentValue = this.targetValue;
this.formattedValue.set(this.formatNumber(this.targetValue));
}
};
private formatNumber(value: number): string {
const fixed = Math.round(value).toString();
return fixed.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
}

View File

@@ -0,0 +1 @@
export * from './fl-counter-trigger.component';