Merged PR 2058: feat(crm): customer card copy-to-clipboard and carousel improvements

Customer Card Copy-to-Clipboard (#5508)

  - Click on card number copies it to clipboard using Angular CDK Clipboard
  - Shows success tooltip confirmation positioned on the right
  - Tooltip auto-dismisses after 3 seconds

  Card Stack Carousel Improvements (#5509)

  - Fix card centering by using afterNextRender instead of AfterViewInit
  - Add ResizeObserver to handle dynamic size changes
  - Disable transforms until natural position is measured (prevents initial jump)
  - Center single card in carousel view

  Tooltip Enhancements

  - Add success variant with green styling (isa-accent-green)
  - Add position input (left | right | top | bottom)
  - Add fade in/out CSS keyframes animations (150ms)
  - Respect prefers-reduced-motion for accessibility

  Related Tasks

  - Closes #5508
  - Refs #5509
This commit is contained in:
Lorenz Hilpert
2025-11-27 16:28:06 +00:00
committed by Nino Righi
parent 7950994d66
commit a5bb8b2895
10 changed files with 250 additions and 74 deletions

View File

@@ -15,8 +15,19 @@
Kundenkarte Nr.:
</div>
<!-- Card number (white, large) -->
<div class="isa-text-subtitle-1-bold text-center text-isa-white">
<!-- Card number (white, large) - click to copy -->
<div
class="isa-text-subtitle-1-bold text-center text-isa-white cursor-pointer"
(click)="copyCode($event)"
data-what="card-code"
[attr.data-which]="card().code"
uiTooltip
#copyTooltip="uiTooltip"
content="Kundenkarte Nr. kopiert!"
variant="success"
position="right"
[triggerOn]="[]"
>
{{ card().code }}
</div>
</div>

View File

@@ -1,11 +1,15 @@
import { Clipboard } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { BonusCardInfo } from '@isa/crm/data-access';
import { BarcodeComponent } from '@isa/shared/barcode';
import { TooltipDirective } from '@isa/ui/tooltip';
import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-card.component';
/**
@@ -29,12 +33,16 @@ import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-c
*/
@Component({
selector: 'crm-customer-card',
standalone: true,
templateUrl: './customer-card.component.html',
styleUrl: './customer-card.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [BarcodeComponent, LockCustomerCardComponent],
imports: [BarcodeComponent, LockCustomerCardComponent, TooltipDirective],
})
export class CustomerCardComponent {
readonly #clipboard = inject(Clipboard);
private readonly copyTooltip = viewChild<TooltipDirective>('copyTooltip');
/**
* Bonus card data to display.
*/
@@ -53,4 +61,18 @@ export class CustomerCardComponent {
protected readonly barcodeHeight = 4.5 * 16; // 4.5rem = 72px
protected readonly barcodeWidth = 0.125 * 16; // 0.125rem = 2px
protected readonly barcodeMargin = 0.5 * 16; // 0.5rem = 8px
/**
* Copies the card code to the clipboard and shows a tooltip confirmation.
*/
copyCode(event: Event): void {
event.stopPropagation();
this.#clipboard.copy(this.card().code);
const tooltip = this.copyTooltip();
if (tooltip) {
tooltip.show();
setTimeout(() => tooltip.hide(), 3000);
}
}
}

View File

@@ -1,10 +1,10 @@
import {
AfterViewInit,
afterNextRender,
computed,
Directive,
effect,
ElementRef,
inject,
OnDestroy,
signal,
} from '@angular/core';
import { CardStackContainerDirective } from './card-stack-container.directive';
@@ -13,12 +13,17 @@ import { CardStackContainerDirective } from './card-stack-container.directive';
selector: '[crmCardStackDistance]',
host: {
'[style.--distance-to-center.px]': 'distanceToCenter()',
// Disable ALL transforms until we've measured the natural position
'[style.transform]': 'ready() ? null : "none"',
},
})
export class CardStackDistanceDirective implements AfterViewInit {
export class CardStackDistanceDirective implements OnDestroy {
readonly container = inject(CardStackContainerDirective, { host: true });
readonly elementRef = inject(ElementRef<HTMLElement>);
/** True once we've measured the card's natural position */
readonly ready = signal(false);
distanceToCenter = computed(() => {
const containerCenterX = this.container.centerX();
const centerX = this.centerX();
@@ -26,26 +31,26 @@ export class CardStackDistanceDirective implements AfterViewInit {
});
centerX = signal(0);
private resizeObserver?: ResizeObserver;
constructor() {
// Recalculate position when container center changes
effect(() => {
// React to container center changes
const containerCenter = this.container.centerX();
// Wait for Angular to complete rendering and layout before calculating position
afterNextRender(() => {
// Measure natural position (with --distance-to-center: 0, card is in natural spot)
this.centerX.set(this.getHorizontalCenter());
// Now enable the transform
this.ready.set(true);
// Recalculate this card's center position
if (containerCenter !== 0) {
// Use requestAnimationFrame to ensure DOM is stable
requestAnimationFrame(() => {
this.centerX.set(this.getHorizontalCenter());
});
}
// Watch for this card's size changes
this.resizeObserver = new ResizeObserver(() => {
this.centerX.set(this.getHorizontalCenter());
});
this.resizeObserver.observe(this.elementRef.nativeElement);
});
}
ngAfterViewInit(): void {
// Initial calculation
this.centerX.set(this.getHorizontalCenter());
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
}
getHorizontalCenter(): number {

View File

@@ -35,7 +35,8 @@ crm-customer-card {
}
/* Stacked state transformations */
.stacked crm-customer-card {
.stacked crm-customer-card,
.single-card crm-customer-card {
transform: translateX(var(--distance-to-center));
}

View File

@@ -2,10 +2,11 @@
<!-- Carousel with navigation arrows -->
<ui-carousel
[class.stacked]="stacked()"
[class.single-card]="singleCard()"
[gap]="stacked() ? '0rem' : '1rem'"
[arrowAutoHide]="true"
crmCardStackContainer
[disabled]="stacked()"
[disabled]="stacked() || singleCard()"
>
@for (card of sortedCards(); track card.code; let idx = $index) {
<crm-customer-card

View File

@@ -59,6 +59,33 @@ describe('CustomerCardsCarouselComponent', () => {
});
});
describe('singleCard computed signal', () => {
it('should return true when there is exactly one card', () => {
const singleCard: BonusCardInfo[] = [
{ code: 'CARD-1', isActive: true, isPrimary: true, firstName: 'John', lastName: 'Doe', totalPoints: 100 } as BonusCardInfo,
];
fixture.componentRef.setInput('cards', singleCard);
fixture.detectChanges();
expect(component.singleCard()).toBe(true);
});
it('should return false when there are multiple cards', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
expect(component.singleCard()).toBe(false);
});
it('should return false when there are no cards', () => {
fixture.componentRef.setInput('cards', []);
fixture.detectChanges();
expect(component.singleCard()).toBe(false);
});
});
describe('sortedCards computed signal', () => {
it('should sort cards with active cards first', () => {
fixture.componentRef.setInput('cards', mockCards);

View File

@@ -38,13 +38,25 @@ import { CardStackDistanceDirective } from './card-stack-distance.directive';
},
})
export class CustomerCardsCarouselComponent {
stacked = signal(true);
#stacked = signal(true);
/**
* All bonus cards to display in carousel.
*/
readonly cards = input.required<BonusCardInfo[]>();
stacked = computed(() => {
const cards = this.cards();
const stacked = this.#stacked();
return stacked && cards.length > 1;
});
/**
* True when there is exactly one card - disables carousel scrolling.
*/
readonly singleCard = computed(() => this.cards().length === 1);
/**
* Event emitted when a card has been successfully locked or unlocked.
* Parent should reload cards and transactions.
@@ -74,6 +86,6 @@ export class CustomerCardsCarouselComponent {
});
unStack(): void {
this.stacked.set(false);
this.#stacked.set(false);
}
}

View File

@@ -36,15 +36,26 @@ export class TooltipComponent {
/**
* The visual variant of the tooltip.
*/
variant = signal<'default' | 'warning'>('default');
variant = signal<'default' | 'warning' | 'success'>('default');
/**
* Computed classes for the tooltip based on variant.
* Whether the tooltip is in the leaving state (playing exit animation).
*/
leaving = signal(false);
/**
* Computed classes for the tooltip based on variant and leaving state.
*/
tooltipClasses = computed(() => {
const classes = ['ui-tooltip'];
if (this.variant() === 'warning') {
const v = this.variant();
if (v === 'warning') {
classes.push('ui-tooltip--warning');
} else if (v === 'success') {
classes.push('ui-tooltip--success');
}
if (this.leaving()) {
classes.push('ui-tooltip--leaving');
}
return classes.join(' ');
});

View File

@@ -88,9 +88,12 @@ export class TooltipDirective implements OnDestroy {
#tooltipInstance: TooltipComponent | null = null;
#openTrigger: TooltipTrigger | null = null; // Tracks which trigger opened the tooltip
#isOpen = signal(false); // Internal signal to track open state for CloseOnScrollDirective
#isHiding = false; // Tracks if tooltip is currently animating out
// Distance between tooltip and anchor element
readonly #offset = 8; // 0.5rem = 8px
// Animation duration in ms (must match CSS)
readonly #animationDuration = 150;
/** Optional title for the tooltip. */
title = input<string>();
@@ -111,7 +114,13 @@ export class TooltipDirective implements OnDestroy {
* Visual variant of the tooltip.
* Defaults to 'default'.
*/
variant = input<'default' | 'warning'>('default');
variant = input<'default' | 'warning' | 'success'>('default');
/**
* Preferred position of the tooltip relative to the host element.
* Defaults to 'left'. Falls back to other positions if preferred doesn't fit.
*/
position = input<'left' | 'right' | 'top' | 'bottom'>('left');
constructor() {
// Set up effects to update tooltip instance when inputs change
@@ -155,45 +164,54 @@ export class TooltipDirective implements OnDestroy {
/**
* Calculates the connected positions for the tooltip overlay.
* The preferred position is to the left-center of the host element.
* Fallback positions are right-center, bottom, and top.
* Orders positions based on the preferred position input.
* @returns An array of `ConnectedPosition` objects.
*/
#getPositions(): ConnectedPosition[] {
return [
{
// Left-center position (default/preferred)
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -this.#offset,
},
{
// Right-center position
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: this.#offset,
},
{
// Bottom position
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: this.#offset,
},
{
// Top position
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -this.#offset,
},
];
const left: ConnectedPosition = {
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -this.#offset,
};
const right: ConnectedPosition = {
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: this.#offset,
};
const bottom: ConnectedPosition = {
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
offsetY: this.#offset,
};
const top: ConnectedPosition = {
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
offsetY: -this.#offset,
};
// Order positions based on preferred position
switch (this.position()) {
case 'right':
return [right, left, bottom, top];
case 'top':
return [top, bottom, left, right];
case 'bottom':
return [bottom, top, left, right];
case 'left':
default:
return [left, right, bottom, top];
}
}
/**
* Shows the tooltip.
@@ -259,9 +277,10 @@ export class TooltipDirective implements OnDestroy {
/**
* Hides the tooltip only if the trigger matches the open trigger.
* Plays fade-out animation before detaching.
*/
hide(trigger?: TooltipTrigger) {
if (!this.#overlayRef) {
if (!this.#overlayRef || this.#isHiding) {
return;
}
@@ -284,14 +303,26 @@ export class TooltipDirective implements OnDestroy {
}
}
this.#overlayRef.detach();
this.#overlayRef.dispose();
this.#overlayRef = null;
this.#tooltipInstance = null;
this.#openTrigger = null;
// Start exit animation
this.#isHiding = true;
if (this.#tooltipInstance) {
this.#tooltipInstance.leaving.set(true);
}
// Update open state for CloseOnScrollDirective
this.#isOpen.set(false);
// Wait for animation to complete before detaching
setTimeout(() => {
if (this.#overlayRef) {
this.#overlayRef.detach();
this.#overlayRef.dispose();
this.#overlayRef = null;
}
this.#tooltipInstance = null;
this.#openTrigger = null;
this.#isHiding = false;
// Update open state for CloseOnScrollDirective
this.#isOpen.set(false);
}, this.#animationDuration);
}
/**

View File

@@ -23,6 +23,17 @@
shadow-[0px_2px_8px_0px_rgba(223,0,27,0.15)];
}
.ui-tooltip--success {
@apply w-auto
max-w-[12rem]
p-3
rounded-lg
bg-green-50
border
border-isa-accent-green
shadow-[0px_2px_8px_0px_rgba(0,128,0,0.15)];
}
.ui-tooltip-title {
@apply isa-text-body-2-bold text-isa-neutral-900;
}
@@ -39,6 +50,14 @@
@apply text-isa-accent-red text-sm;
}
.ui-tooltip--success .ui-tooltip-title {
@apply text-isa-accent-green;
}
.ui-tooltip--success .ui-tooltip-content {
@apply text-isa-accent-green text-sm;
}
/*
Global styles for tooltip overlay container
These styles will be injected into the global stylesheet
@@ -48,10 +67,46 @@
pointer-events: none; /* Allow clicks to pass through */
}
/* Keyframes for tooltip animations */
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes tooltipFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
/* Animation for tooltip appearance */
.ui-tooltip {
opacity: 1;
transition: opacity 150ms ease-in-out;
animation: tooltipFadeIn 0.15s ease-out;
}
.ui-tooltip--leaving {
animation: tooltipFadeOut 0.15s ease-in forwards;
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.ui-tooltip {
animation: none;
}
.ui-tooltip--leaving {
animation: none;
}
}
.ui-tooltip-icon {