mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'develop' into release/4.5
This commit is contained in:
392
.claude/skills/css-keyframes-animations/SKILL.md
Normal file
392
.claude/skills/css-keyframes-animations/SKILL.md
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
---
|
||||||
|
name: css-keyframes-animations
|
||||||
|
description: This skill should be used when writing or reviewing CSS animations in Angular components. Use when creating entrance/exit animations, implementing @keyframes instead of @angular/animations, applying timing functions and fill modes, creating staggered animations, or ensuring GPU-accelerated performance. Essential for modern Angular 20+ components using animate.enter/animate.leave directives and converting legacy Angular animations to native CSS.
|
||||||
|
---
|
||||||
|
|
||||||
|
# CSS @keyframes Animations
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement native CSS @keyframes animations for Angular applications, replacing @angular/animations with GPU-accelerated, zero-bundle-size alternatives. This skill provides comprehensive guidance on creating performant entrance/exit animations, staggered effects, and proper timing configurations.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Apply this skill when:
|
||||||
|
- **Writing Angular components** with entrance/exit animations
|
||||||
|
- **Converting @angular/animations** to native CSS @keyframes
|
||||||
|
- **Implementing animate.enter/animate.leave** in Angular 20+ templates
|
||||||
|
- **Creating staggered animations** for lists or collections
|
||||||
|
- **Debugging animation issues** (snap-back, wrong starting positions, choppy playback)
|
||||||
|
- **Optimizing animation performance** for GPU acceleration
|
||||||
|
- **Reviewing animation code** for accessibility and best practices
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Animation Setup
|
||||||
|
|
||||||
|
1. **Define @keyframes** in component CSS:
|
||||||
|
```css
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Apply animation** to element:
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use with Angular 20+ directives**:
|
||||||
|
```html
|
||||||
|
@if (visible()) {
|
||||||
|
<div animate.enter="fade-in" animate.leave="fade-out">
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Pitfall: Element Snaps Back
|
||||||
|
|
||||||
|
**Problem:** Element returns to original state after animation completes.
|
||||||
|
|
||||||
|
**Solution:** Add `forwards` fill mode:
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
animation: fadeOut 1s forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Pitfall: Animation Conflicts with State Transitions
|
||||||
|
|
||||||
|
**Problem:** Entrance animation overrides initial state transforms (e.g., stacked cards appear unstacked then jump).
|
||||||
|
|
||||||
|
**Solution:** Animate only properties that don't conflict with state. Use opacity-only animations when transforms are state-driven:
|
||||||
|
```css
|
||||||
|
/* BAD: Overrides stacked transform */
|
||||||
|
@keyframes cardEntrance {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GOOD: Only animates opacity, allows state transforms to apply */
|
||||||
|
@keyframes cardEntrance {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. GPU-Accelerated Properties Only
|
||||||
|
|
||||||
|
**Always use** for animations:
|
||||||
|
- `transform` (translate, rotate, scale)
|
||||||
|
- `opacity`
|
||||||
|
|
||||||
|
**Avoid animating** (triggers layout recalculation):
|
||||||
|
- `width`, `height`
|
||||||
|
- `top`, `left`, `right`, `bottom`
|
||||||
|
- `margin`, `padding`
|
||||||
|
- `font-size`
|
||||||
|
|
||||||
|
### 2. Fill Modes
|
||||||
|
|
||||||
|
| Fill Mode | Behavior | Use Case |
|
||||||
|
|-----------|----------|----------|
|
||||||
|
| `forwards` | Keep end state | Exit animations (stay invisible) |
|
||||||
|
| `backwards` | Apply start state during delay | Entrance with delay (prevent flash) |
|
||||||
|
| `both` | Both of above | Complex sequences |
|
||||||
|
|
||||||
|
### 3. Timing Functions
|
||||||
|
|
||||||
|
Choose appropriate easing for animation type:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Entrance animations - ease-out (fast start, slow end) */
|
||||||
|
animation-timing-function: cubic-bezier(0, 0, 0.58, 1);
|
||||||
|
|
||||||
|
/* Exit animations - ease-in (slow start, fast end) */
|
||||||
|
animation-timing-function: cubic-bezier(0.42, 0, 1, 1);
|
||||||
|
|
||||||
|
/* Bouncy overshoot effect */
|
||||||
|
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool: [cubic-bezier.com](https://cubic-bezier.com) for custom curves.
|
||||||
|
|
||||||
|
### 4. Staggered Animations
|
||||||
|
|
||||||
|
Create cascading effects using CSS custom properties:
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```html
|
||||||
|
@for (item of items(); track item.id; let idx = $index) {
|
||||||
|
<div [style.--i]="idx" class="stagger-item">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSS:**
|
||||||
|
```css
|
||||||
|
.stagger-item {
|
||||||
|
animation: fadeSlideIn 0.5s ease-out backwards;
|
||||||
|
animation-delay: calc(var(--i, 0) * 100ms);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Accessibility
|
||||||
|
|
||||||
|
Always respect reduced motion preferences:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animated {
|
||||||
|
animation: none;
|
||||||
|
/* Or use simpler, faster animation */
|
||||||
|
animation-duration: 0.1s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Animation Patterns
|
||||||
|
|
||||||
|
### Fade Entrance/Exit
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
.fade-out { animation: fadeOut 0.3s ease-in forwards; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slide Entrance
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-up { animation: slideInUp 0.3s ease-out; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scale Entrance
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-in { animation: scaleIn 0.2s ease-out; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading Spinner
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shake (Error Feedback)
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-input {
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Angular 20+ Integration
|
||||||
|
|
||||||
|
### Basic Usage with animate.enter/animate.leave
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
@if (show()) {
|
||||||
|
<div animate.enter="fade-in" animate.leave="fade-out">
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
.fade-out { animation: fadeOut 0.3s ease-in forwards; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Animation Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
@if (show()) {
|
||||||
|
<div [animate.enter]="enterAnim()" [animate.leave]="leaveAnim()">
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class DynamicAnimComponent {
|
||||||
|
show = signal(false);
|
||||||
|
enterAnim = signal('slide-in-up');
|
||||||
|
leaveAnim = signal('slide-out-down');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Animations
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Problem | Cause | Solution |
|
||||||
|
|---------|-------|----------|
|
||||||
|
| Animation doesn't run | Missing duration | Add `animation-duration: 0.3s` |
|
||||||
|
| Element snaps back | No fill mode | Add `animation-fill-mode: forwards` |
|
||||||
|
| Wrong starting position during delay | No backwards fill | Add `animation-fill-mode: backwards` |
|
||||||
|
| Choppy animation | Animating layout properties | Use `transform` instead |
|
||||||
|
| State conflict (jump/snap) | Animation overrides state transforms | Animate only opacity, not transform |
|
||||||
|
|
||||||
|
### Browser DevTools
|
||||||
|
|
||||||
|
- **Chrome DevTools** → More Tools → Animations panel
|
||||||
|
- **Firefox DevTools** → Inspector → Animations tab
|
||||||
|
|
||||||
|
### Animation Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
element.addEventListener('animationend', (e) => {
|
||||||
|
console.log('Animation completed:', e.animationName);
|
||||||
|
// Clean up, remove element, etc.
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
### references/keyframes-guide.md
|
||||||
|
|
||||||
|
Comprehensive deep-dive covering:
|
||||||
|
- Complete @keyframes syntax reference
|
||||||
|
- Detailed timing functions and cubic-bezier curves
|
||||||
|
- Advanced techniques (direction, play-state, @starting-style)
|
||||||
|
- Performance optimization strategies
|
||||||
|
- Extensive common patterns library
|
||||||
|
- Debugging techniques and troubleshooting
|
||||||
|
|
||||||
|
**When to reference:** Complex animation requirements, custom easing curves, advanced techniques, performance optimization, or learning comprehensive details.
|
||||||
|
|
||||||
|
### assets/animations.css
|
||||||
|
|
||||||
|
Ready-to-use CSS file with common animation patterns:
|
||||||
|
- Fade animations (in/out)
|
||||||
|
- Slide animations (up/down/left/right)
|
||||||
|
- Scale animations (in/out)
|
||||||
|
- Utility animations (spin, shimmer, shake, breathe, attention-pulse)
|
||||||
|
- Toast/notification animations
|
||||||
|
- Accessibility (@media prefers-reduced-motion)
|
||||||
|
|
||||||
|
**Usage:** Copy this file to project and import in component styles or global styles:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import 'path/to/animations.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use classes directly:
|
||||||
|
```html
|
||||||
|
<div animate.enter="fade-in" animate.leave="slide-out-down">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from @angular/animations
|
||||||
|
|
||||||
|
### Before (Angular Animations)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { trigger, state, style, transition, animate } from '@angular/animations';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
animations: [
|
||||||
|
trigger('fadeIn', [
|
||||||
|
transition(':enter', [
|
||||||
|
style({ opacity: 0 }),
|
||||||
|
animate('300ms ease-out', style({ opacity: 1 }))
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (CSS @keyframes)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
@if (show()) {
|
||||||
|
<div animate.enter="fade-in">Content</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Zero JavaScript bundle size (~60KB savings)
|
||||||
|
- GPU hardware acceleration
|
||||||
|
- Standard CSS (transferable skills)
|
||||||
|
- Better performance
|
||||||
278
.claude/skills/css-keyframes-animations/assets/animations.css
Normal file
278
.claude/skills/css-keyframes-animations/assets/animations.css
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Reusable CSS @keyframes Animations
|
||||||
|
*
|
||||||
|
* Common animation patterns for Angular applications.
|
||||||
|
* Import this file in your component styles or global styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FADE ANIMATIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-out {
|
||||||
|
animation: fadeOut 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SLIDE ANIMATIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutDown {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutUp {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutLeft {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutRight {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-up { animation: slideInUp 0.3s ease-out; }
|
||||||
|
.slide-out-down { animation: slideOutDown 0.3s ease-in; }
|
||||||
|
.slide-in-down { animation: slideInDown 0.3s ease-out; }
|
||||||
|
.slide-out-up { animation: slideOutUp 0.3s ease-in; }
|
||||||
|
.slide-in-left { animation: slideInLeft 0.3s ease-out; }
|
||||||
|
.slide-out-left { animation: slideOutLeft 0.3s ease-in; }
|
||||||
|
.slide-in-right { animation: slideInRight 0.3s ease-out; }
|
||||||
|
.slide-out-right { animation: slideOutRight 0.3s ease-in; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SCALE ANIMATIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-in { animation: scaleIn 0.2s ease-out; }
|
||||||
|
.scale-out { animation: scaleOut 0.2s ease-in; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UTILITY ANIMATIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton Loading */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#f0f0f0 25%,
|
||||||
|
#e0e0e0 50%,
|
||||||
|
#f0f0f0 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attention Pulse */
|
||||||
|
@keyframes attention-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attention-pulse {
|
||||||
|
animation: attention-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shake (Error Feedback) */
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake {
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breathing/Pulsing */
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breathe {
|
||||||
|
animation: breathe 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TOAST/NOTIFICATION ANIMATIONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-in {
|
||||||
|
animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-out {
|
||||||
|
animation: toastOut 0.2s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ACCESSIBILITY
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Respect user's motion preferences */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
# CSS @keyframes Deep Dive
|
||||||
|
|
||||||
|
A comprehensive guide for Angular developers transitioning from `@angular/animations` to native CSS animations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Understanding @keyframes](#understanding-keyframes)
|
||||||
|
2. [Basic Syntax](#basic-syntax)
|
||||||
|
3. [Animation Properties](#animation-properties)
|
||||||
|
4. [Timing Functions (Easing)](#timing-functions-easing)
|
||||||
|
5. [Fill Modes](#fill-modes)
|
||||||
|
6. [Advanced Techniques](#advanced-techniques)
|
||||||
|
7. [Angular 20+ Integration](#angular-20-integration)
|
||||||
|
8. [Common Patterns & Recipes](#common-patterns--recipes)
|
||||||
|
9. [Performance Tips](#performance-tips)
|
||||||
|
10. [Debugging Animations](#debugging-animations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Understanding @keyframes
|
||||||
|
|
||||||
|
The `@keyframes` at-rule controls the intermediate steps in a CSS animation sequence by defining styles for keyframes (waypoints) along the animation. Unlike transitions (which only animate between two states), keyframes let you define multiple intermediate steps.
|
||||||
|
|
||||||
|
### How It Differs from @angular/animations
|
||||||
|
|
||||||
|
| @angular/animations | Native CSS @keyframes |
|
||||||
|
|---------------------|----------------------|
|
||||||
|
| ~60KB JavaScript bundle | Zero JS overhead |
|
||||||
|
| CPU-based rendering | GPU hardware acceleration |
|
||||||
|
| Angular-specific syntax | Standard CSS (transferable skills) |
|
||||||
|
| `trigger()`, `state()`, `animate()` | `@keyframes` + CSS classes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Syntax
|
||||||
|
|
||||||
|
### The @keyframes Rule
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes animation-name {
|
||||||
|
from {
|
||||||
|
/* Starting styles (same as 0%) */
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
/* Ending styles (same as 100%) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Percentage-Based Keyframes
|
||||||
|
|
||||||
|
For more control, use percentages to define multiple waypoints:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes bounce {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-30px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(-15px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining Multiple Percentages
|
||||||
|
|
||||||
|
You can apply the same styles to multiple keyframes:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Applying the Animation
|
||||||
|
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
animation: bounce 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Properties
|
||||||
|
|
||||||
|
### Individual Properties
|
||||||
|
|
||||||
|
| Property | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `animation-name` | Name of the @keyframes | `animation-name: bounce;` |
|
||||||
|
| `animation-duration` | How long one cycle takes | `animation-duration: 2s;` |
|
||||||
|
| `animation-timing-function` | Speed curve (easing) | `animation-timing-function: ease-in;` |
|
||||||
|
| `animation-delay` | Wait before starting | `animation-delay: 500ms;` |
|
||||||
|
| `animation-iteration-count` | How many times to run | `animation-iteration-count: 3;` or `infinite` |
|
||||||
|
| `animation-direction` | Forward, reverse, or alternate | `animation-direction: alternate;` |
|
||||||
|
| `animation-fill-mode` | Styles before/after animation | `animation-fill-mode: forwards;` |
|
||||||
|
| `animation-play-state` | Pause or play | `animation-play-state: paused;` |
|
||||||
|
|
||||||
|
### Shorthand Syntax
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* animation: name duration timing-function delay iteration-count direction fill-mode play-state */
|
||||||
|
.element {
|
||||||
|
animation: slideIn 0.5s ease-out 0.2s 1 normal forwards running;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minimum required:** name and duration
|
||||||
|
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
animation: fadeIn 1s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Animations
|
||||||
|
|
||||||
|
Apply multiple animations to a single element:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
animation:
|
||||||
|
fadeIn 0.5s ease-out,
|
||||||
|
slideUp 0.5s ease-out,
|
||||||
|
pulse 2s ease-in-out 0.5s infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timing Functions (Easing)
|
||||||
|
|
||||||
|
The timing function controls how the animation progresses over time—where it speeds up and slows down.
|
||||||
|
|
||||||
|
### Keyword Values
|
||||||
|
|
||||||
|
| Keyword | Cubic-Bezier Equivalent | Description |
|
||||||
|
|---------|------------------------|-------------|
|
||||||
|
| `linear` | `cubic-bezier(0, 0, 1, 1)` | Constant speed |
|
||||||
|
| `ease` | `cubic-bezier(0.25, 0.1, 0.25, 1)` | Default: slow start, fast middle, slow end |
|
||||||
|
| `ease-in` | `cubic-bezier(0.42, 0, 1, 1)` | Slow start, fast end |
|
||||||
|
| `ease-out` | `cubic-bezier(0, 0, 0.58, 1)` | Fast start, slow end |
|
||||||
|
| `ease-in-out` | `cubic-bezier(0.42, 0, 0.58, 1)` | Slow start and end |
|
||||||
|
|
||||||
|
### Custom Cubic-Bezier
|
||||||
|
|
||||||
|
Create custom easing curves with `cubic-bezier(x1, y1, x2, y2)`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Bouncy overshoot effect */
|
||||||
|
.element {
|
||||||
|
animation-timing-function: cubic-bezier(0.68, -0.6, 0.32, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth deceleration */
|
||||||
|
.element {
|
||||||
|
animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tool:** Use [cubic-bezier.com](https://cubic-bezier.com) to visualize and create custom curves.
|
||||||
|
|
||||||
|
### Popular Easing Functions
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Ease Out Quart - Great for enter animations */
|
||||||
|
cubic-bezier(0.25, 1, 0.5, 1)
|
||||||
|
|
||||||
|
/* Ease In Out Cubic - Smooth state changes */
|
||||||
|
cubic-bezier(0.65, 0, 0.35, 1)
|
||||||
|
|
||||||
|
/* Ease Out Back - Slight overshoot */
|
||||||
|
cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||||
|
|
||||||
|
/* Ease In Out Back - Overshoot both ends */
|
||||||
|
cubic-bezier(0.68, -0.6, 0.32, 1.6)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steps Function
|
||||||
|
|
||||||
|
For frame-by-frame animations (like sprite sheets):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 6 discrete steps */
|
||||||
|
.sprite {
|
||||||
|
animation: walk 1s steps(6) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step positions */
|
||||||
|
steps(4, jump-start) /* Jump at start of each interval */
|
||||||
|
steps(4, jump-end) /* Jump at end of each interval (default) */
|
||||||
|
steps(4, jump-both) /* Jump at both ends */
|
||||||
|
steps(4, jump-none) /* No jump at ends */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timing Function Per Keyframe
|
||||||
|
|
||||||
|
Apply different easing to different segments:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes complexMove {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(100px);
|
||||||
|
animation-timing-function: ease-in;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(200px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The timing function applies to each step individually, not the entire animation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fill Modes
|
||||||
|
|
||||||
|
Fill modes control what styles apply before and after the animation runs.
|
||||||
|
|
||||||
|
### Values
|
||||||
|
|
||||||
|
| Value | Before Animation | After Animation |
|
||||||
|
|-------|-----------------|-----------------|
|
||||||
|
| `none` | Original styles | Original styles |
|
||||||
|
| `forwards` | Original styles | **Last keyframe styles** |
|
||||||
|
| `backwards` | **First keyframe styles** | Original styles |
|
||||||
|
| `both` | **First keyframe styles** | **Last keyframe styles** |
|
||||||
|
|
||||||
|
### Common Problem: Element Snaps Back
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* BAD: Element disappears then reappears after animation */
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.element {
|
||||||
|
animation: fadeOut 1s; /* Element snaps back to opacity: 1 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GOOD: Element stays invisible */
|
||||||
|
.element {
|
||||||
|
animation: fadeOut 1s forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backwards Fill Mode (for delays)
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without backwards: element visible at original position during delay */
|
||||||
|
/* With backwards: element starts at first keyframe position during delay */
|
||||||
|
.element {
|
||||||
|
animation: slideIn 0.5s ease-out 1s backwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Techniques
|
||||||
|
|
||||||
|
### Animation Direction
|
||||||
|
|
||||||
|
Control playback direction:
|
||||||
|
|
||||||
|
```css
|
||||||
|
animation-direction: normal; /* 0% → 100% */
|
||||||
|
animation-direction: reverse; /* 100% → 0% */
|
||||||
|
animation-direction: alternate; /* 0% → 100% → 0% */
|
||||||
|
animation-direction: alternate-reverse; /* 100% → 0% → 100% */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Case:** Breathing/pulsing effects
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes breathe {
|
||||||
|
from { transform: scale(1); }
|
||||||
|
to { transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.element {
|
||||||
|
animation: breathe 2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staggered Animations
|
||||||
|
|
||||||
|
Create cascading effects with animation-delay:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.item { animation: fadeSlideIn 0.5s ease-out backwards; }
|
||||||
|
.item:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.item:nth-child(2) { animation-delay: 100ms; }
|
||||||
|
.item:nth-child(3) { animation-delay: 200ms; }
|
||||||
|
.item:nth-child(4) { animation-delay: 300ms; }
|
||||||
|
|
||||||
|
/* Or use CSS custom properties */
|
||||||
|
.item {
|
||||||
|
animation: fadeSlideIn 0.5s ease-out backwards;
|
||||||
|
animation-delay: calc(var(--i, 0) * 100ms);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In your template:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="item" style="--i: 0">First</div>
|
||||||
|
<div class="item" style="--i: 1">Second</div>
|
||||||
|
<div class="item" style="--i: 2">Third</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### @starting-style (Modern CSS)
|
||||||
|
|
||||||
|
Define styles for when an element first enters the DOM:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.modal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animating Auto Height
|
||||||
|
|
||||||
|
Use CSS Grid for height: auto animations:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.accordion-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content.open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content > div {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pause/Play with CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
animation-play-state: running;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Or with a class */
|
||||||
|
.element.paused {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Angular 20+ Integration
|
||||||
|
|
||||||
|
### Using animate.enter and animate.leave
|
||||||
|
|
||||||
|
Angular 20.2+ provides `animate.enter` and `animate.leave` to apply CSS classes when elements enter/leave the DOM.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
selector: 'app-example',
|
||||||
|
template: `
|
||||||
|
@if (isVisible()) {
|
||||||
|
<div animate.enter="fade-in" animate.leave="fade-out">
|
||||||
|
Content here
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button (click)="toggle()">Toggle</button>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-out {
|
||||||
|
animation: fadeOut 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ExampleComponent {
|
||||||
|
isVisible = signal(false);
|
||||||
|
toggle() { this.isVisible.update(v => !v); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Animation Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
@if (show()) {
|
||||||
|
<div [animate.enter]="enterAnimation()" [animate.leave]="leaveAnimation()">
|
||||||
|
Dynamic animations!
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class DynamicAnimComponent {
|
||||||
|
show = signal(false);
|
||||||
|
enterAnimation = signal('slide-in-right');
|
||||||
|
leaveAnimation = signal('slide-out-left');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reusable Animation CSS File
|
||||||
|
|
||||||
|
Create a shared `animations.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* animations.css */
|
||||||
|
|
||||||
|
/* Fade animations */
|
||||||
|
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
.fade-out { animation: fadeOut 0.3s ease-in; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide animations */
|
||||||
|
.slide-in-up { animation: slideInUp 0.3s ease-out; }
|
||||||
|
.slide-out-down { animation: slideOutDown 0.3s ease-in; }
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutDown {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale animations */
|
||||||
|
.scale-in { animation: scaleIn 0.2s ease-out; }
|
||||||
|
.scale-out { animation: scaleOut 0.2s ease-in; }
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Import in `styles.css` or `angular.json`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import 'animations.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns & Recipes
|
||||||
|
|
||||||
|
### Loading Spinner
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skeleton Loading
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#f0f0f0 25%,
|
||||||
|
#e0e0e0 50%,
|
||||||
|
#f0f0f0 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attention Pulse
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes attention-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
animation: attention-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shake (Error Feedback)
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-input {
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slide Down Menu
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
animation: slideDown 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast Notification
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes toastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.leaving {
|
||||||
|
animation: toastOut 0.2s ease-in forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
### Use Transform and Opacity
|
||||||
|
|
||||||
|
These properties are GPU-accelerated and don't trigger layout:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* GOOD - GPU accelerated */
|
||||||
|
@keyframes good {
|
||||||
|
from { transform: translateX(0); opacity: 0; }
|
||||||
|
to { transform: translateX(100px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AVOID - Triggers layout recalculation */
|
||||||
|
@keyframes avoid {
|
||||||
|
from { left: 0; width: 100px; }
|
||||||
|
to { left: 100px; width: 200px; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use will-change Sparingly
|
||||||
|
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove after animation */
|
||||||
|
.element.animation-complete {
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Respect Reduced Motion
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes fadeSlide {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.element {
|
||||||
|
animation: fadeSlide 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.element {
|
||||||
|
animation: none;
|
||||||
|
/* Or use a simpler fade */
|
||||||
|
animation: fadeIn 0.1s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid Animating Layout Properties
|
||||||
|
|
||||||
|
Properties that trigger layout (reflow):
|
||||||
|
|
||||||
|
- `width`, `height`
|
||||||
|
- `top`, `left`, `right`, `bottom`
|
||||||
|
- `margin`, `padding`
|
||||||
|
- `font-size`
|
||||||
|
- `border-width`
|
||||||
|
|
||||||
|
Use `transform: scale()` instead of `width/height` when possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging Animations
|
||||||
|
|
||||||
|
### Browser DevTools
|
||||||
|
|
||||||
|
1. **Chrome DevTools** → More Tools → Animations
|
||||||
|
- Pause, slow down, or step through animations
|
||||||
|
- Inspect timing curves
|
||||||
|
|
||||||
|
2. **Firefox** → Inspector → Animations tab
|
||||||
|
- Visual timeline of all animations
|
||||||
|
|
||||||
|
### Force Slow Motion
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Temporarily add to debug */
|
||||||
|
* {
|
||||||
|
animation-duration: 3s !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Events in JavaScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
element.addEventListener('animationstart', (e) => {
|
||||||
|
console.log('Started:', e.animationName);
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('animationend', (e) => {
|
||||||
|
console.log('Ended:', e.animationName);
|
||||||
|
// Clean up class, remove element, etc.
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('animationiteration', (e) => {
|
||||||
|
console.log('Iteration:', e.animationName);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Animation not running | Check `animation-duration` is > 0 |
|
||||||
|
| Element snaps back | Add `animation-fill-mode: forwards` |
|
||||||
|
| Animation starts wrong | Use `animation-fill-mode: backwards` with delay |
|
||||||
|
| Choppy animation | Use `transform` instead of layout properties |
|
||||||
|
| Animation restarts on state change | Ensure Angular doesn't recreate the element |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Card
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Basic setup */
|
||||||
|
@keyframes name {
|
||||||
|
from { /* start */ }
|
||||||
|
to { /* end */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
.element {
|
||||||
|
animation: name 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Angular 20+ */
|
||||||
|
<div animate.enter="fade-in" animate.leave="fade-out">
|
||||||
|
|
||||||
|
/* Shorthand order */
|
||||||
|
animation: name duration timing delay count direction fill-mode state;
|
||||||
|
|
||||||
|
/* Common timing functions */
|
||||||
|
ease-out: cubic-bezier(0, 0, 0.58, 1) /* Enter animations */
|
||||||
|
ease-in: cubic-bezier(0.42, 0, 1, 1) /* Exit animations */
|
||||||
|
ease-in-out: cubic-bezier(0.42, 0, 0.58, 1) /* State changes */
|
||||||
|
|
||||||
|
/* Fill modes */
|
||||||
|
forwards → Keep end state
|
||||||
|
backwards → Apply start state during delay
|
||||||
|
both → Both of the above
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [MDN CSS Animations Guide](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_animations/Using_CSS_animations)
|
||||||
|
- [Angular Animation Migration Guide](https://angular.dev/guide/animations/migration)
|
||||||
|
- [Cubic Bezier Tool](https://cubic-bezier.com)
|
||||||
|
- [Easing Functions Cheat Sheet](https://easings.net)
|
||||||
|
- [Josh W. Comeau's Keyframe Guide](https://www.joshwcomeau.com/animation/keyframe-animations/)
|
||||||
@@ -44,6 +44,7 @@ This file contains meta-instructions for how Claude should work with the ISA-Fro
|
|||||||
| Writing HTML with interactivity | `html-template` | E2E attributes (data-what, data-which) + ARIA |
|
| Writing HTML with interactivity | `html-template` | E2E attributes (data-what, data-which) + ARIA |
|
||||||
| Applying Tailwind classes | `tailwind` | Design system consistency |
|
| Applying Tailwind classes | `tailwind` | Design system consistency |
|
||||||
| Writing Angular code | `logging` | Mandatory logging via @isa/core/logging |
|
| Writing Angular code | `logging` | Mandatory logging via @isa/core/logging |
|
||||||
|
| Creating CSS animations | `css-keyframes-animations` | Native @keyframes + animate.enter/leave + GPU acceleration |
|
||||||
| Creating new library | `library-scaffolder` | Proper Nx setup + Vitest config |
|
| Creating new library | `library-scaffolder` | Proper Nx setup + Vitest config |
|
||||||
| Regenerating API clients | `swagger-sync-manager` | All 10 clients + validation |
|
| Regenerating API clients | `swagger-sync-manager` | All 10 clients + validation |
|
||||||
| Migrating to standalone | `standalone-component-migrator` | Complete migration workflow |
|
| Migrating to standalone | `standalone-component-migrator` | Complete migration workflow |
|
||||||
@@ -86,6 +87,7 @@ Assistant: [Writes component following all skill guidelines]
|
|||||||
| Task | Required Skill Chain | Order |
|
| Task | Required Skill Chain | Order |
|
||||||
|------|---------------------|-------|
|
|------|---------------------|-------|
|
||||||
| New Angular component | `angular-template` → `html-template` → `logging` → `tailwind` | Template syntax → HTML attributes → Logging → Styling |
|
| New Angular component | `angular-template` → `html-template` → `logging` → `tailwind` | Template syntax → HTML attributes → Logging → Styling |
|
||||||
|
| Component with animations | `angular-template` → `html-template` → `css-keyframes-animations` → `logging` → `tailwind` | Template → HTML → Animations → Logging → Styling |
|
||||||
| New library | `library-scaffolder` → `architecture-enforcer` | Scaffold → Validate structure |
|
| New library | `library-scaffolder` → `architecture-enforcer` | Scaffold → Validate structure |
|
||||||
| API sync | `api-change-analyzer` → `swagger-sync-manager` | Analyze changes → Regenerate clients |
|
| API sync | `api-change-analyzer` → `swagger-sync-manager` | Analyze changes → Regenerate clients |
|
||||||
| Component migration | `standalone-component-migrator` → `test-migration-specialist` | Migrate component → Migrate tests |
|
| Component migration | `standalone-component-migrator` → `test-migration-specialist` | Migrate component → Migrate tests |
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
/* Customer card styles - using Tailwind, no additional CSS needed */
|
/* Customer card styles - using Tailwind, no additional CSS needed */
|
||||||
|
:host {
|
||||||
|
@apply rounded-2xl overflow-hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- Card container: 337×213px, rounded-2xl, shadow -->
|
<!-- Card container: 337×213px, rounded-2xl, shadow -->
|
||||||
<div
|
<div
|
||||||
class="relative flex h-[14.8125rem] w-[21.0625rem] flex-col overflow-hidden rounded-2xl bg-isa-black shadow-card"
|
class="relative flex h-[14.8125rem] w-[21.0625rem] flex-col"
|
||||||
|
[class.opacity-40]="!card().isActive"
|
||||||
[attr.data-what]="'customer-card'"
|
[attr.data-what]="'customer-card'"
|
||||||
[attr.data-which]="card().code"
|
[attr.data-which]="card().code"
|
||||||
>
|
>
|
||||||
@@ -11,13 +12,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- Card type label (grey) -->
|
<!-- Card type label (grey) -->
|
||||||
<div class="isa-text-body-2-bold text-center text-isa-neutral-500">
|
<div class="isa-text-body-2-bold text-center text-isa-neutral-500">
|
||||||
{{ card().isPrimary ? 'Kundenkarte Nr.:' : 'Mitarbeitendenkarte Nr.:' }}
|
Kundenkarte Nr.:
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card number (white, large) -->
|
<!-- Card number (white, large) -->
|
||||||
<div
|
<div class="isa-text-subtitle-1-bold text-center text-isa-white">
|
||||||
class="isa-text-subtitle-1-bold w-[150px] text-center text-isa-white"
|
|
||||||
>
|
|
||||||
{{ card().code }}
|
{{ card().code }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +29,6 @@
|
|||||||
[width]="barcodeWidth"
|
[width]="barcodeWidth"
|
||||||
[margin]="barcodeMargin"
|
[margin]="barcodeMargin"
|
||||||
[format]="'CODE128'"
|
[format]="'CODE128'"
|
||||||
[background]="'#ffffff'"
|
|
||||||
[attr.data-what]="'card-barcode'"
|
[attr.data-what]="'card-barcode'"
|
||||||
[attr.data-which]="card().code"
|
[attr.data-which]="card().code"
|
||||||
class="rounded-[0.25rem] overflow-hidden"
|
class="rounded-[0.25rem] overflow-hidden"
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({ selector: '[crmCardStackContainer]' })
|
||||||
|
export class CardStackContainerDirective implements AfterViewInit {
|
||||||
|
readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
|
readonly centerX = signal(0);
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.centerX.set(this.getHorizontalCenter());
|
||||||
|
}
|
||||||
|
|
||||||
|
getHorizontalCenter(): number {
|
||||||
|
const el = this.elementRef.nativeElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.left + rect.width / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
computed,
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CardStackContainerDirective } from './card-stack-container.directive';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[crmCardStackDistance]',
|
||||||
|
host: {
|
||||||
|
'[style.--distance-to-center.px]': 'distanceToCenter()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class CardStackDistanceDirective implements AfterViewInit {
|
||||||
|
readonly container = inject(CardStackContainerDirective, { host: true });
|
||||||
|
readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
|
distanceToCenter = computed(() => {
|
||||||
|
const containerCenterX = this.container.centerX();
|
||||||
|
const centerX = this.centerX();
|
||||||
|
return containerCenterX - centerX;
|
||||||
|
});
|
||||||
|
|
||||||
|
centerX = signal(0);
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.centerX.set(this.getHorizontalCenter());
|
||||||
|
}
|
||||||
|
|
||||||
|
getHorizontalCenter(): number {
|
||||||
|
const el = this.elementRef.nativeElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.left + rect.width / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,68 @@
|
|||||||
/* Carousel container styles - using Tailwind and ui-carousel, no additional CSS needed */
|
/* Carousel container styles - using Tailwind and ui-carousel, no additional CSS needed */
|
||||||
|
|
||||||
|
/* Keyframes for card entrance animation */
|
||||||
|
@keyframes cardEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyframes for unstacking animation */
|
||||||
|
@keyframes unstack {
|
||||||
|
from {
|
||||||
|
transform: translateX(var(--distance-to-center));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crm-customer-card {
|
||||||
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); /* Ease-out-back for slight overshoot */
|
||||||
|
box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
/* Initial entrance animation with staggered delay */
|
||||||
|
animation: cardEntrance 0.4s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
animation-delay: calc(var(--card-index, 0) * 80ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unstacked state (default) */
|
||||||
|
crm-customer-card {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stacked state transformations */
|
||||||
|
.stacked crm-customer-card {
|
||||||
|
transform: translateX(var(--distance-to-center));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked crm-customer-card:nth-child(1) {
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked crm-customer-card:nth-child(2) {
|
||||||
|
transform: translateX(calc(var(--distance-to-center) - 3.6rem))
|
||||||
|
translateY(1rem) rotate(-2.538deg);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked crm-customer-card:nth-child(3) {
|
||||||
|
transform: translateX(calc(var(--distance-to-center) + 3.6rem))
|
||||||
|
translateY(1rem) rotate(2.538deg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked crm-customer-card:nth-child(n + 4) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect user's motion preferences */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
crm-customer-card {
|
||||||
|
animation: none;
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<div class="relative" data-what="customer-cards-carousel">
|
<div class="relative" data-what="customer-cards-carousel">
|
||||||
<!-- Carousel with navigation arrows -->
|
<!-- Carousel with navigation arrows -->
|
||||||
<ui-carousel
|
<ui-carousel
|
||||||
[gap]="'1rem'"
|
[class.stacked]="stacked()"
|
||||||
|
[gap]="stacked() ? '0rem' : '1rem'"
|
||||||
[arrowAutoHide]="true"
|
[arrowAutoHide]="true"
|
||||||
[padding]="'0.75rem 0.5rem'"
|
crmCardStackContainer
|
||||||
|
[disabled]="stacked()"
|
||||||
>
|
>
|
||||||
@for (card of sortedCards(); track card.code) {
|
@for (card of sortedCards(); track card.code; let idx = $index) {
|
||||||
<crm-customer-card [card]="card" (cardLocked)="cardLocked.emit()" />
|
<crm-customer-card
|
||||||
|
[card]="card"
|
||||||
|
(cardLocked)="cardLocked.emit()"
|
||||||
|
crmCardStackDistance
|
||||||
|
[style.--card-index]="idx"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</ui-carousel>
|
</ui-carousel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Component, computed, input, output } from '@angular/core';
|
import { Component, computed, input, output, signal } from '@angular/core';
|
||||||
import { BonusCardInfo } from '@isa/crm/data-access';
|
import { BonusCardInfo } from '@isa/crm/data-access';
|
||||||
import { CarouselComponent } from '@isa/ui/carousel';
|
import { CarouselComponent } from '@isa/ui/carousel';
|
||||||
import { CustomerCardComponent } from '../customer-card';
|
import { CustomerCardComponent } from '../customer-card';
|
||||||
|
import { CardStackContainerDirective } from './card-stack-container.directive';
|
||||||
|
import { CardStackDistanceDirective } from './card-stack-distance.directive';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Carousel container for displaying multiple customer loyalty cards.
|
* Carousel container for displaying multiple customer loyalty cards.
|
||||||
@@ -23,11 +25,21 @@ import { CustomerCardComponent } from '../customer-card';
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'crm-customer-cards-carousel',
|
selector: 'crm-customer-cards-carousel',
|
||||||
imports: [CarouselComponent, CustomerCardComponent],
|
imports: [
|
||||||
|
CarouselComponent,
|
||||||
|
CustomerCardComponent,
|
||||||
|
CardStackDistanceDirective,
|
||||||
|
CardStackContainerDirective,
|
||||||
|
],
|
||||||
templateUrl: './customer-cards-carousel.component.html',
|
templateUrl: './customer-cards-carousel.component.html',
|
||||||
styleUrl: './customer-cards-carousel.component.css',
|
styleUrl: './customer-cards-carousel.component.css',
|
||||||
|
host: {
|
||||||
|
'(click)': 'unStack()',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class CustomerCardsCarouselComponent {
|
export class CustomerCardsCarouselComponent {
|
||||||
|
stacked = signal(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All bonus cards to display in carousel.
|
* All bonus cards to display in carousel.
|
||||||
*/
|
*/
|
||||||
@@ -51,4 +63,8 @@ export class CustomerCardsCarouselComponent {
|
|||||||
return a.isActive ? -1 : 1;
|
return a.isActive ? -1 : 1;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
unStack(): void {
|
||||||
|
this.stacked.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ vi.mock('jsbarcode', () => ({
|
|||||||
default: vi.fn((element, value, options) => {
|
default: vi.fn((element, value, options) => {
|
||||||
// Simulate JsBarcode by adding a rect element to the SVG
|
// Simulate JsBarcode by adding a rect element to the SVG
|
||||||
if (element && element.tagName === 'svg') {
|
if (element && element.tagName === 'svg') {
|
||||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
const rect = document.createElementNS(
|
||||||
|
'http://www.w3.org/2000/svg',
|
||||||
|
'rect',
|
||||||
|
);
|
||||||
rect.setAttribute('data-value', value);
|
rect.setAttribute('data-value', value);
|
||||||
rect.setAttribute('data-format', options?.format || 'CODE128');
|
rect.setAttribute('data-format', options?.format || 'CODE128');
|
||||||
element.appendChild(rect);
|
element.appendChild(rect);
|
||||||
@@ -108,7 +111,7 @@ describe('BarcodeComponent', () => {
|
|||||||
expect(component.lineColor()).toBe('#FF0000');
|
expect(component.lineColor()).toBe('#FF0000');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass background input to directive', () => {
|
it('should pass container background input', () => {
|
||||||
fixture.componentRef.setInput('value', '123');
|
fixture.componentRef.setInput('value', '123');
|
||||||
fixture.componentRef.setInput('background', '#F0F0F0');
|
fixture.componentRef.setInput('background', '#F0F0F0');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -116,6 +119,30 @@ describe('BarcodeComponent', () => {
|
|||||||
expect(component.background()).toBe('#F0F0F0');
|
expect(component.background()).toBe('#F0F0F0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pass svgBackground input to directive', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.componentRef.setInput('svgBackground', '#FFFFFF');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.svgBackground()).toBe('#FFFFFF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass containerWidth input', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.componentRef.setInput('containerWidth', '20rem');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.containerWidth()).toBe('20rem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass containerHeight input', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.componentRef.setInput('containerHeight', '6rem');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.containerHeight()).toBe('6rem');
|
||||||
|
});
|
||||||
|
|
||||||
it('should pass fontSize input to directive', () => {
|
it('should pass fontSize input to directive', () => {
|
||||||
fixture.componentRef.setInput('value', '123');
|
fixture.componentRef.setInput('value', '123');
|
||||||
fixture.componentRef.setInput('fontSize', 24);
|
fixture.componentRef.setInput('fontSize', 24);
|
||||||
@@ -169,13 +196,34 @@ describe('BarcodeComponent', () => {
|
|||||||
expect(component.lineColor()).toBe('#000000');
|
expect(component.lineColor()).toBe('#000000');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default background #ffffff', () => {
|
it('should use default container background #ffffff', () => {
|
||||||
fixture.componentRef.setInput('value', '123');
|
fixture.componentRef.setInput('value', '123');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.background()).toBe('#ffffff');
|
expect(component.background()).toBe('#ffffff');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use default svgBackground transparent', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.svgBackground()).toBe('transparent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default containerWidth 12.5rem', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.containerWidth()).toBe('12.5rem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default containerHeight 5.5rem', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.containerHeight()).toBe('5.5rem');
|
||||||
|
});
|
||||||
|
|
||||||
it('should use default fontSize 20', () => {
|
it('should use default fontSize 20', () => {
|
||||||
fixture.componentRef.setInput('value', '123');
|
fixture.componentRef.setInput('value', '123');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -191,6 +239,41 @@ describe('BarcodeComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Host Styling', () => {
|
||||||
|
it('should apply container width style', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.componentRef.setInput('containerWidth', '25rem');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.style.width).toBe('25rem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply container height style', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.componentRef.setInput('containerHeight', '8rem');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.style.height).toBe('8rem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply background color style', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.componentRef.setInput('background', '#EEEEEE');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.style.backgroundColor).toBe(
|
||||||
|
'rgb(238, 238, 238)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply flex display style', () => {
|
||||||
|
fixture.componentRef.setInput('value', '123');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.style.display).toBe('flex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Barcode Rendering', () => {
|
describe('Barcode Rendering', () => {
|
||||||
it('should render barcode with custom value', () => {
|
it('should render barcode with custom value', () => {
|
||||||
fixture.componentRef.setInput('value', '987654321');
|
fixture.componentRef.setInput('value', '987654321');
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { BarcodeDirective } from './barcode.directive';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Component wrapper for the barcode directive that provides an easier-to-use API.
|
* Component wrapper for the barcode directive that provides an easier-to-use API.
|
||||||
* Renders a Code 128 barcode as an SVG element.
|
* Renders a Code 128 barcode as an SVG element within a container.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```html
|
* ```html
|
||||||
* <shared-barcode [value]="'123456789'" [width]="2" [height]="100" />
|
* <shared-barcode
|
||||||
|
* [value]="'123456789'"
|
||||||
|
* [width]="2"
|
||||||
|
* [height]="100"
|
||||||
|
* [containerWidth]="'300px'"
|
||||||
|
* [containerHeight]="'88px'"
|
||||||
|
* [background]="'#ffffff'"
|
||||||
|
* />
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
@@ -23,12 +30,20 @@ import { BarcodeDirective } from './barcode.directive';
|
|||||||
[height]="height()"
|
[height]="height()"
|
||||||
[displayValue]="displayValue()"
|
[displayValue]="displayValue()"
|
||||||
[lineColor]="lineColor()"
|
[lineColor]="lineColor()"
|
||||||
[background]="background()"
|
[background]="svgBackground()"
|
||||||
[fontSize]="fontSize()"
|
[fontSize]="fontSize()"
|
||||||
[margin]="margin()"
|
[margin]="margin()"
|
||||||
></svg>
|
></svg>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'[style.width]': 'containerWidth()',
|
||||||
|
'[style.height]': 'containerHeight()',
|
||||||
|
'[style.background-color]': 'background()',
|
||||||
|
'[style.display]': '"flex"',
|
||||||
|
'[style.align-items]': '"center"',
|
||||||
|
'[style.justify-content]': '"center"',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class BarcodeComponent {
|
export class BarcodeComponent {
|
||||||
/**
|
/**
|
||||||
@@ -62,10 +77,15 @@ export class BarcodeComponent {
|
|||||||
lineColor = input<string>('#000000');
|
lineColor = input<string>('#000000');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Background color (default: #ffffff)
|
* Background color of the container (default: #ffffff)
|
||||||
*/
|
*/
|
||||||
background = input<string>('#ffffff');
|
background = input<string>('#ffffff');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background color of the SVG barcode itself (default: transparent)
|
||||||
|
*/
|
||||||
|
svgBackground = input<string>('transparent');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font size for the human-readable text (default: 20)
|
* Font size for the human-readable text (default: 20)
|
||||||
*/
|
*/
|
||||||
@@ -75,4 +95,14 @@ export class BarcodeComponent {
|
|||||||
* Margin around the barcode in pixels (default: 10)
|
* Margin around the barcode in pixels (default: 10)
|
||||||
*/
|
*/
|
||||||
margin = input<number>(10);
|
margin = input<number>(10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the container (default: 12.5rem)
|
||||||
|
*/
|
||||||
|
containerWidth = input<string>('12.5rem');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of the container (default: 5.5rem)
|
||||||
|
*/
|
||||||
|
containerHeight = input<string>('5.5rem');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ vi.mock('jsbarcode', () => ({
|
|||||||
default: vi.fn((element, value, options) => {
|
default: vi.fn((element, value, options) => {
|
||||||
// Simulate JsBarcode by adding a rect element to the SVG
|
// Simulate JsBarcode by adding a rect element to the SVG
|
||||||
if (element && element.tagName === 'svg') {
|
if (element && element.tagName === 'svg') {
|
||||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
const rect = document.createElementNS(
|
||||||
|
'http://www.w3.org/2000/svg',
|
||||||
|
'rect',
|
||||||
|
);
|
||||||
rect.setAttribute('data-value', value);
|
rect.setAttribute('data-value', value);
|
||||||
rect.setAttribute('data-format', options?.format || 'CODE128');
|
rect.setAttribute('data-format', options?.format || 'CODE128');
|
||||||
element.appendChild(rect);
|
element.appendChild(rect);
|
||||||
@@ -168,11 +171,7 @@ describe('BarcodeDirective', () => {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [BarcodeDirective],
|
imports: [BarcodeDirective],
|
||||||
template: `
|
template: `
|
||||||
<svg
|
<svg sharedBarcode [value]="'123456'" [lineColor]="'#FF0000'"></svg>
|
||||||
sharedBarcode
|
|
||||||
[value]="'123456'"
|
|
||||||
[lineColor]="'#FF0000'"
|
|
||||||
></svg>
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
class ColorTestComponent {}
|
class ColorTestComponent {}
|
||||||
@@ -190,11 +189,7 @@ describe('BarcodeDirective', () => {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [BarcodeDirective],
|
imports: [BarcodeDirective],
|
||||||
template: `
|
template: `
|
||||||
<svg
|
<svg sharedBarcode [value]="'123456'" [background]="'#F0F0F0'"></svg>
|
||||||
sharedBarcode
|
|
||||||
[value]="'123456'"
|
|
||||||
[background]="'#F0F0F0'"
|
|
||||||
></svg>
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
class BackgroundTestComponent {}
|
class BackgroundTestComponent {}
|
||||||
@@ -206,6 +201,21 @@ describe('BarcodeDirective', () => {
|
|||||||
expect(svg).toBeTruthy();
|
expect(svg).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render with transparent background by default', () => {
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [BarcodeDirective],
|
||||||
|
template: ` <svg sharedBarcode [value]="'123456'"></svg> `,
|
||||||
|
})
|
||||||
|
class DefaultBackgroundTestComponent {}
|
||||||
|
|
||||||
|
const bgFixture = TestBed.createComponent(DefaultBackgroundTestComponent);
|
||||||
|
bgFixture.detectChanges();
|
||||||
|
|
||||||
|
const svg = bgFixture.nativeElement.querySelector('svg');
|
||||||
|
expect(svg).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render with custom font size', () => {
|
it('should render with custom font size', () => {
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ export class BarcodeDirective implements OnDestroy {
|
|||||||
lineColor = input<string>('#000000');
|
lineColor = input<string>('#000000');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Background color (default: #ffffff)
|
* Background color of the SVG barcode itself (default: transparent)
|
||||||
*/
|
*/
|
||||||
background = input<string>('#ffffff');
|
background = input<string>('transparent');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font size for the human-readable text (default: 20)
|
* Font size for the human-readable text (default: 20)
|
||||||
@@ -112,14 +112,10 @@ export class BarcodeDirective implements OnDestroy {
|
|||||||
format: this.format(),
|
format: this.format(),
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.#logger.error(
|
this.#logger.error('Failed to render barcode', error as Error, () => ({
|
||||||
'Failed to render barcode',
|
value,
|
||||||
error as Error,
|
format: this.format(),
|
||||||
() => ({
|
}));
|
||||||
value,
|
|
||||||
format: this.format(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
.ui-carousel {
|
.ui-carousel {
|
||||||
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
// Focus outline for keyboard navigation
|
// Focus outline for keyboard navigation
|
||||||
&:focus {
|
&:focus {
|
||||||
@@ -13,32 +17,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-carousel__wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
overflow: visible; // Allow items to overflow and be visible at edges
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-carousel__container {
|
.ui-carousel__container {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
flex-wrap: nowrap;
|
||||||
overflow-y: visible; // Allow vertical overflow to be visible
|
cursor: grab;
|
||||||
scroll-behavior: smooth;
|
user-select: none;
|
||||||
width: 100%;
|
touch-action: pan-y; // Allow vertical scrolling, prevent horizontal scroll
|
||||||
box-sizing: border-box; // Padding is inset, maintains original size
|
width: max-content; // Container expands to fit all children
|
||||||
|
|
||||||
// Hide scrollbar while maintaining scroll functionality
|
transition: transform 0.3s linear;
|
||||||
scrollbar-width: none; // Firefox
|
will-change: transform; // Optimize for hardware acceleration
|
||||||
-ms-overflow-style: none; // IE/Edge
|
backface-visibility: hidden; // Prevent flickering
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&:active {
|
||||||
display: none; // Chrome/Safari
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enable touch scrolling on mobile
|
.ui-carousel__container--dragging {
|
||||||
-webkit-overflow-scrolling: touch;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-carousel__button {
|
.ui-carousel__button {
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
<div class="ui-carousel__wrapper">
|
@if (!disabled() && canScrollLeft()) {
|
||||||
@if (showLeftArrow()) {
|
<button
|
||||||
<button
|
uiIconButton
|
||||||
uiIconButton
|
class="ui-carousel__button ui-carousel__button--left"
|
||||||
class="ui-carousel__button ui-carousel__button--left"
|
type="button"
|
||||||
type="button"
|
name="isaActionChevronLeft"
|
||||||
name="isaActionChevronLeft"
|
size="large"
|
||||||
size="large"
|
color="tertiary"
|
||||||
color="tertiary"
|
data-what="button"
|
||||||
data-what="button"
|
data-which="carousel-previous"
|
||||||
data-which="carousel-previous"
|
(click)="navigateToPrevious()"
|
||||||
(click)="scrollToPrevious()"
|
[attr.aria-label]="'Previous'"
|
||||||
[attr.aria-label]="'Previous'"
|
></button>
|
||||||
></button>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
#scrollContainer
|
#container
|
||||||
class="ui-carousel__container"
|
class="ui-carousel__container"
|
||||||
[style.gap]="gap()"
|
[class.ui-carousel__container--dragging]="isDragging()"
|
||||||
[style.padding]="padding()"
|
[style.gap]="gap()"
|
||||||
>
|
[style.transform]="transformX()"
|
||||||
<ng-content></ng-content>
|
>
|
||||||
</div>
|
<ng-content></ng-content>
|
||||||
|
|
||||||
@if (showRightArrow()) {
|
|
||||||
<button
|
|
||||||
uiIconButton
|
|
||||||
class="ui-carousel__button ui-carousel__button--right"
|
|
||||||
type="button"
|
|
||||||
name="isaActionChevronRight"
|
|
||||||
size="large"
|
|
||||||
color="tertiary"
|
|
||||||
data-what="button"
|
|
||||||
data-which="carousel-next"
|
|
||||||
(click)="scrollToNext()"
|
|
||||||
[attr.aria-label]="'Next'"
|
|
||||||
></button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (!disabled() && canScrollRight()) {
|
||||||
|
<button
|
||||||
|
uiIconButton
|
||||||
|
class="ui-carousel__button ui-carousel__button--right"
|
||||||
|
type="button"
|
||||||
|
name="isaActionChevronRight"
|
||||||
|
size="large"
|
||||||
|
color="tertiary"
|
||||||
|
data-what="button"
|
||||||
|
data-which="carousel-next"
|
||||||
|
(click)="navigateToNext()"
|
||||||
|
[attr.aria-label]="'Next'"
|
||||||
|
></button>
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ describe('CarouselComponent', () => {
|
|||||||
it('should have default input values', () => {
|
it('should have default input values', () => {
|
||||||
expect(component.gap()).toBe('1rem');
|
expect(component.gap()).toBe('1rem');
|
||||||
expect(component.arrowAutoHide()).toBe(true);
|
expect(component.arrowAutoHide()).toBe(true);
|
||||||
|
expect(component.disabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply auto-hide class when arrowAutoHide is true', () => {
|
it('should apply auto-hide class when arrowAutoHide is true', () => {
|
||||||
@@ -154,8 +155,8 @@ describe('CarouselComponent keyboard navigation', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call scrollToPrevious on ArrowLeft keydown', () => {
|
it('should call navigateToPrevious on ArrowLeft keydown', () => {
|
||||||
const spy = vi.spyOn(component, 'scrollToPrevious');
|
const spy = vi.spyOn(component, 'navigateToPrevious');
|
||||||
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
|
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
|
||||||
|
|
||||||
const hostElement = fixture.nativeElement as HTMLElement;
|
const hostElement = fixture.nativeElement as HTMLElement;
|
||||||
@@ -164,8 +165,8 @@ describe('CarouselComponent keyboard navigation', () => {
|
|||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call scrollToNext on ArrowRight keydown', () => {
|
it('should call navigateToNext on ArrowRight keydown', () => {
|
||||||
const spy = vi.spyOn(component, 'scrollToNext');
|
const spy = vi.spyOn(component, 'navigateToNext');
|
||||||
const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
|
const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
|
||||||
|
|
||||||
const hostElement = fixture.nativeElement as HTMLElement;
|
const hostElement = fixture.nativeElement as HTMLElement;
|
||||||
@@ -174,3 +175,60 @@ describe('CarouselComponent keyboard navigation', () => {
|
|||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CarouselComponent disabled state', () => {
|
||||||
|
let component: CarouselComponent;
|
||||||
|
let fixture: ComponentFixture<CarouselComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CarouselComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CarouselComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide buttons when disabled', () => {
|
||||||
|
fixture.componentRef.setInput('disabled', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const buttons = fixture.nativeElement.querySelectorAll('.ui-carousel__button');
|
||||||
|
expect(buttons.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not navigate when disabled and navigateToPrevious is called', () => {
|
||||||
|
fixture.componentRef.setInput('disabled', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const initialTranslate = component['currentTranslateX']();
|
||||||
|
component.navigateToPrevious();
|
||||||
|
|
||||||
|
expect(component['currentTranslateX']()).toBe(initialTranslate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not navigate when disabled and navigateToNext is called', () => {
|
||||||
|
fixture.componentRef.setInput('disabled', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const initialTranslate = component['currentTranslateX']();
|
||||||
|
component.navigateToNext();
|
||||||
|
|
||||||
|
expect(component['currentTranslateX']()).toBe(initialTranslate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not navigate when disabled and navigateToStart is called', () => {
|
||||||
|
fixture.componentRef.setInput('disabled', false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
// Set a non-zero position first
|
||||||
|
component['currentTranslateX'].set(-100);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('disabled', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
component.navigateToStart();
|
||||||
|
|
||||||
|
expect(component['currentTranslateX']()).toBe(-100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
effect,
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||||
import { provideIcons } from '@ng-icons/core';
|
import { provideIcons } from '@ng-icons/core';
|
||||||
@@ -15,6 +16,7 @@ import { isaActionChevronLeft, isaActionChevronRight } from '@isa/icons';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ui-carousel',
|
selector: 'ui-carousel',
|
||||||
|
exportAs: 'uiCarousel',
|
||||||
imports: [IconButtonComponent],
|
imports: [IconButtonComponent],
|
||||||
templateUrl: './carousel.component.html',
|
templateUrl: './carousel.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
encapsulation: ViewEncapsulation.None,
|
||||||
@@ -23,19 +25,38 @@ import { isaActionChevronLeft, isaActionChevronRight } from '@isa/icons';
|
|||||||
host: {
|
host: {
|
||||||
'[class]': '["ui-carousel", arrowAutoHideClass()]',
|
'[class]': '["ui-carousel", arrowAutoHideClass()]',
|
||||||
'[tabindex]': '0',
|
'[tabindex]': '0',
|
||||||
'(keydown.ArrowLeft)': 'scrollToPrevious($event)',
|
'(keydown.ArrowLeft)': 'navigateToPrevious($event)',
|
||||||
'(keydown.ArrowRight)': 'scrollToNext($event)',
|
'(keydown.ArrowRight)': 'navigateToNext($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class CarouselComponent {
|
export class CarouselComponent implements AfterViewInit, OnDestroy {
|
||||||
|
#resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.updateBounds();
|
||||||
|
this.updateNavigationState();
|
||||||
|
});
|
||||||
|
|
||||||
// Input signals
|
// Input signals
|
||||||
gap = input<string>('1rem');
|
gap = input<string>('1rem');
|
||||||
arrowAutoHide = input<boolean>(true);
|
arrowAutoHide = input<boolean>(true);
|
||||||
padding = input<string>('0');
|
disabled = input<boolean>(false);
|
||||||
|
|
||||||
// View child for scroll container
|
// View child for container
|
||||||
scrollContainer =
|
container = viewChild.required<ElementRef<HTMLDivElement>>('container');
|
||||||
viewChild.required<ElementRef<HTMLDivElement>>('scrollContainer');
|
|
||||||
|
// Transform position in pixels
|
||||||
|
private currentTranslateX = signal(0);
|
||||||
|
|
||||||
|
// Touch/drag state
|
||||||
|
readonly isDragging = signal(false);
|
||||||
|
private dragStartX = 0;
|
||||||
|
private dragStartTranslateX = 0;
|
||||||
|
private containerWidth = 0;
|
||||||
|
private contentWidth = 0;
|
||||||
|
|
||||||
|
// Computed transform style (use translate3d for hardware acceleration)
|
||||||
|
transformX = computed(
|
||||||
|
() => `translate3d(${this.currentTranslateX()}px, 0, 0)`,
|
||||||
|
);
|
||||||
|
|
||||||
// Internal state signals
|
// Internal state signals
|
||||||
canScrollLeft = signal(false);
|
canScrollLeft = signal(false);
|
||||||
@@ -46,77 +67,95 @@ export class CarouselComponent {
|
|||||||
this.arrowAutoHide() ? 'ui-carousel--auto-hide' : '',
|
this.arrowAutoHide() ? 'ui-carousel--auto-hide' : '',
|
||||||
);
|
);
|
||||||
|
|
||||||
showLeftArrow = computed(() => this.canScrollLeft());
|
ngAfterViewInit(): void {
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
this.updateBounds();
|
||||||
|
this.updateNavigationState();
|
||||||
|
|
||||||
showRightArrow = computed(() => this.canScrollRight());
|
containerEl.addEventListener('touchstart', this.onTouchStart, {
|
||||||
|
passive: true,
|
||||||
constructor() {
|
|
||||||
// Update scroll state whenever the scroll container changes
|
|
||||||
effect(() => {
|
|
||||||
const container = this.scrollContainer()?.nativeElement;
|
|
||||||
if (container) {
|
|
||||||
this.updateScrollState();
|
|
||||||
|
|
||||||
// Add scroll event listener
|
|
||||||
const handleScroll = () => this.updateScrollState();
|
|
||||||
container.addEventListener('scroll', handleScroll);
|
|
||||||
|
|
||||||
// Add resize observer to detect content changes
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
this.updateScrollState();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
|
|
||||||
// Cleanup on destroy
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('scroll', handleScroll);
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
|
containerEl.addEventListener('touchmove', this.onTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
containerEl.addEventListener('touchend', this.onTouchEnd);
|
||||||
|
containerEl.addEventListener('mousedown', this.onMouseDown);
|
||||||
|
document.addEventListener('mousemove', this.onMouseMove);
|
||||||
|
document.addEventListener('mouseup', this.onMouseUp);
|
||||||
|
containerEl.addEventListener('mouseleave', this.onMouseLeave);
|
||||||
|
|
||||||
|
this.#resizeObserver.observe(containerEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
containerEl.removeEventListener('touchstart', this.onTouchStart);
|
||||||
|
containerEl.removeEventListener('touchmove', this.onTouchMove);
|
||||||
|
containerEl.removeEventListener('touchend', this.onTouchEnd);
|
||||||
|
containerEl.removeEventListener('mousedown', this.onMouseDown);
|
||||||
|
document.removeEventListener('mousemove', this.onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', this.onMouseUp);
|
||||||
|
containerEl.removeEventListener('mouseleave', this.onMouseLeave);
|
||||||
|
this.#resizeObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the scroll state (can scroll left/right)
|
* Update container and content width bounds
|
||||||
*/
|
*/
|
||||||
private updateScrollState(): void {
|
private updateBounds(): void {
|
||||||
const container = this.scrollContainer()?.nativeElement;
|
const containerEl = this.container()?.nativeElement;
|
||||||
if (!container) return;
|
if (!containerEl) return;
|
||||||
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = container;
|
// Container width = visible viewport (parent element)
|
||||||
|
const parentEl = containerEl.parentElement;
|
||||||
|
if (!parentEl) return;
|
||||||
|
this.containerWidth = parentEl.offsetWidth;
|
||||||
|
|
||||||
// Check if we can scroll left (not at the start)
|
// Content width = total width of all children (the container itself)
|
||||||
this.canScrollLeft.set(scrollLeft > 0);
|
this.contentWidth = containerEl.offsetWidth;
|
||||||
|
|
||||||
// Check if we can scroll right (not at the end)
|
|
||||||
// Add a small threshold (1px) to account for rounding errors
|
|
||||||
this.canScrollRight.set(scrollLeft < scrollWidth - clientWidth - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the scroll amount based on fully visible items
|
* Update navigation state (can navigate left/right)
|
||||||
*/
|
*/
|
||||||
private calculateScrollAmount(): number {
|
private updateNavigationState(): void {
|
||||||
const container = this.scrollContainer()?.nativeElement;
|
const translate = this.currentTranslateX();
|
||||||
if (!container) return 0;
|
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||||
|
|
||||||
const children = Array.from(container.children) as HTMLElement[];
|
// Can navigate left if not at start (translateX < 0)
|
||||||
if (children.length === 0) return container.clientWidth;
|
this.canScrollLeft.set(translate < 0);
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
// Can navigate right if not at end
|
||||||
const containerLeft = containerRect.left;
|
this.canScrollRight.set(translate > maxTranslate);
|
||||||
const containerRight = containerRect.right;
|
}
|
||||||
|
|
||||||
// Count fully visible items and get the first one
|
/**
|
||||||
|
* Calculate the navigation amount based on fully visible items
|
||||||
|
*/
|
||||||
|
private calculateNavigationAmount(): number {
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
if (!containerEl) return 0;
|
||||||
|
|
||||||
|
const parentEl = containerEl.parentElement;
|
||||||
|
if (!parentEl) return 0;
|
||||||
|
|
||||||
|
const children = Array.from(containerEl.children) as HTMLElement[];
|
||||||
|
if (children.length === 0) return this.containerWidth;
|
||||||
|
|
||||||
|
// Use parent rect (visible viewport) not container rect (full content)
|
||||||
|
const viewportRect = parentEl.getBoundingClientRect();
|
||||||
|
const viewportLeft = viewportRect.left;
|
||||||
|
const viewportRight = viewportRect.right;
|
||||||
|
|
||||||
|
// Count fully visible items
|
||||||
let firstFullyVisibleItem: HTMLElement | null = null;
|
let firstFullyVisibleItem: HTMLElement | null = null;
|
||||||
let fullyVisibleCount = 0;
|
let fullyVisibleCount = 0;
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const childRect = child.getBoundingClientRect();
|
const childRect = child.getBoundingClientRect();
|
||||||
const isFullyVisible =
|
const isFullyVisible =
|
||||||
childRect.left >= containerLeft - 1 &&
|
childRect.left >= viewportLeft - 1 &&
|
||||||
childRect.right <= containerRight + 1;
|
childRect.right <= viewportRight + 1;
|
||||||
|
|
||||||
if (isFullyVisible) {
|
if (isFullyVisible) {
|
||||||
if (!firstFullyVisibleItem) {
|
if (!firstFullyVisibleItem) {
|
||||||
@@ -126,56 +165,163 @@ export class CarouselComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have fully visible items, use their width as scroll amount
|
// Use fully visible items width if available
|
||||||
if (fullyVisibleCount >= 1 && firstFullyVisibleItem) {
|
if (fullyVisibleCount >= 1 && firstFullyVisibleItem) {
|
||||||
// Get the next sibling to calculate the width including gap
|
|
||||||
const nextSibling =
|
const nextSibling =
|
||||||
firstFullyVisibleItem.nextElementSibling as HTMLElement;
|
firstFullyVisibleItem.nextElementSibling as HTMLElement;
|
||||||
if (nextSibling) {
|
if (nextSibling) {
|
||||||
const firstRect = firstFullyVisibleItem.getBoundingClientRect();
|
const firstRect = firstFullyVisibleItem.getBoundingClientRect();
|
||||||
const nextRect = nextSibling.getBoundingClientRect();
|
const nextRect = nextSibling.getBoundingClientRect();
|
||||||
const itemWidthWithGap = nextRect.left - firstRect.left;
|
const itemWidthWithGap = nextRect.left - firstRect.left;
|
||||||
|
return itemWidthWithGap * fullyVisibleCount;
|
||||||
// Scroll by the width of fully visible items
|
|
||||||
const scrollAmount = itemWidthWithGap * fullyVisibleCount;
|
|
||||||
return scrollAmount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: just use the first item's width
|
|
||||||
return firstFullyVisibleItem.getBoundingClientRect().width;
|
return firstFullyVisibleItem.getBoundingClientRect().width;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: scroll by container width
|
// Fallback: use viewport width
|
||||||
return container.clientWidth;
|
return this.containerWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll to the previous set of items (left)
|
* Set transform position with bounds checking
|
||||||
*/
|
*/
|
||||||
scrollToPrevious(event?: Event): void {
|
private setTranslateX(value: number): void {
|
||||||
event?.preventDefault();
|
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||||
const container = this.scrollContainer()?.nativeElement;
|
const boundedValue = Math.max(maxTranslate, Math.min(0, value));
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const scrollAmount = this.calculateScrollAmount();
|
this.currentTranslateX.set(boundedValue);
|
||||||
container.scrollBy({
|
this.updateNavigationState();
|
||||||
left: -scrollAmount,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll to the next set of items (right)
|
* Navigate to the previous set of items
|
||||||
*/
|
*/
|
||||||
scrollToNext(event?: Event): void {
|
navigateToPrevious(event?: Event): void {
|
||||||
|
if (this.disabled()) return;
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
const container = this.scrollContainer()?.nativeElement;
|
const amount = this.calculateNavigationAmount();
|
||||||
if (!container) return;
|
this.setTranslateX(this.currentTranslateX() + amount);
|
||||||
|
|
||||||
const scrollAmount = this.calculateScrollAmount();
|
|
||||||
container.scrollBy({
|
|
||||||
left: scrollAmount,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the next set of items
|
||||||
|
*/
|
||||||
|
navigateToNext(event?: Event): void {
|
||||||
|
if (this.disabled()) return;
|
||||||
|
event?.preventDefault();
|
||||||
|
const amount = this.calculateNavigationAmount();
|
||||||
|
this.setTranslateX(this.currentTranslateX() - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to start
|
||||||
|
*/
|
||||||
|
navigateToStart(): void {
|
||||||
|
if (this.disabled()) return;
|
||||||
|
this.setTranslateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch event handlers
|
||||||
|
private onTouchStart = (e: TouchEvent): void => {
|
||||||
|
if (this.disabled()) return;
|
||||||
|
this.isDragging.set(true);
|
||||||
|
this.dragStartX = e.touches[0].clientX;
|
||||||
|
this.dragStartTranslateX = this.currentTranslateX();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTouchMove = (e: TouchEvent): void => {
|
||||||
|
if (this.disabled() || !this.isDragging()) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const currentX = e.touches[0].clientX;
|
||||||
|
const deltaX = currentX - this.dragStartX;
|
||||||
|
|
||||||
|
// Direct DOM manipulation for smooth dragging (no signal updates)
|
||||||
|
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||||
|
const newValue = this.dragStartTranslateX + deltaX;
|
||||||
|
const boundedValue = Math.max(maxTranslate, Math.min(0, newValue));
|
||||||
|
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
if (containerEl) {
|
||||||
|
containerEl.style.transform = `translate3d(${boundedValue}px, 0, 0)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTouchEnd = (): void => {
|
||||||
|
if (this.disabled()) return;
|
||||||
|
this.isDragging.set(false);
|
||||||
|
|
||||||
|
// Update signal with final position
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
if (containerEl) {
|
||||||
|
const transform = containerEl.style.transform;
|
||||||
|
const match = transform.match(/translate3d\(([-\d.]+)px/);
|
||||||
|
if (match) {
|
||||||
|
this.currentTranslateX.set(parseFloat(match[1]));
|
||||||
|
this.updateNavigationState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse event handlers (for desktop drag)
|
||||||
|
private onMouseDown = (e: MouseEvent): void => {
|
||||||
|
if (this.disabled()) return;
|
||||||
|
// Prevent dragging on buttons
|
||||||
|
if ((e.target as HTMLElement).closest('button')) return;
|
||||||
|
|
||||||
|
this.isDragging.set(true);
|
||||||
|
this.dragStartX = e.clientX;
|
||||||
|
this.dragStartTranslateX = this.currentTranslateX();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onMouseMove = (e: MouseEvent): void => {
|
||||||
|
if (!this.isDragging()) return;
|
||||||
|
|
||||||
|
const currentX = e.clientX;
|
||||||
|
const deltaX = currentX - this.dragStartX;
|
||||||
|
|
||||||
|
// Direct DOM manipulation for smooth dragging (no signal updates)
|
||||||
|
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||||
|
const newValue = this.dragStartTranslateX + deltaX;
|
||||||
|
const boundedValue = Math.max(maxTranslate, Math.min(0, newValue));
|
||||||
|
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
if (containerEl) {
|
||||||
|
containerEl.style.transform = `translate3d(${boundedValue}px, 0, 0)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onMouseUp = (): void => {
|
||||||
|
if (this.isDragging()) {
|
||||||
|
this.isDragging.set(false);
|
||||||
|
|
||||||
|
// Update signal with final position
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
if (containerEl) {
|
||||||
|
const transform = containerEl.style.transform;
|
||||||
|
const match = transform.match(/translate3d\(([-\d.]+)px/);
|
||||||
|
if (match) {
|
||||||
|
this.currentTranslateX.set(parseFloat(match[1]));
|
||||||
|
this.updateNavigationState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onMouseLeave = (): void => {
|
||||||
|
if (this.isDragging()) {
|
||||||
|
this.isDragging.set(false);
|
||||||
|
|
||||||
|
// Update signal with final position
|
||||||
|
const containerEl = this.container()?.nativeElement;
|
||||||
|
if (containerEl) {
|
||||||
|
const transform = containerEl.style.transform;
|
||||||
|
const match = transform.match(/translate3d\(([-\d.]+)px/);
|
||||||
|
if (match) {
|
||||||
|
this.currentTranslateX.set(parseFloat(match[1]));
|
||||||
|
this.updateNavigationState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user