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.: Kundenkarte Nr.:
</div> </div>
<!-- Card number (white, large) --> <!-- Card number (white, large) - click to copy -->
<div class="isa-text-subtitle-1-bold text-center text-isa-white"> <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 }} {{ card().code }}
</div> </div>
</div> </div>

View File

@@ -1,11 +1,15 @@
import { Clipboard } from '@angular/cdk/clipboard';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
inject,
input, input,
output, output,
viewChild,
} from '@angular/core'; } from '@angular/core';
import { BonusCardInfo } from '@isa/crm/data-access'; import { BonusCardInfo } from '@isa/crm/data-access';
import { BarcodeComponent } from '@isa/shared/barcode'; import { BarcodeComponent } from '@isa/shared/barcode';
import { TooltipDirective } from '@isa/ui/tooltip';
import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-card.component'; import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-card.component';
/** /**
@@ -29,12 +33,16 @@ import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-c
*/ */
@Component({ @Component({
selector: 'crm-customer-card', selector: 'crm-customer-card',
standalone: true,
templateUrl: './customer-card.component.html', templateUrl: './customer-card.component.html',
styleUrl: './customer-card.component.css', styleUrl: './customer-card.component.css',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [BarcodeComponent, LockCustomerCardComponent], imports: [BarcodeComponent, LockCustomerCardComponent, TooltipDirective],
}) })
export class CustomerCardComponent { export class CustomerCardComponent {
readonly #clipboard = inject(Clipboard);
private readonly copyTooltip = viewChild<TooltipDirective>('copyTooltip');
/** /**
* Bonus card data to display. * Bonus card data to display.
*/ */
@@ -53,4 +61,18 @@ export class CustomerCardComponent {
protected readonly barcodeHeight = 4.5 * 16; // 4.5rem = 72px protected readonly barcodeHeight = 4.5 * 16; // 4.5rem = 72px
protected readonly barcodeWidth = 0.125 * 16; // 0.125rem = 2px protected readonly barcodeWidth = 0.125 * 16; // 0.125rem = 2px
protected readonly barcodeMargin = 0.5 * 16; // 0.5rem = 8px 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 { import {
AfterViewInit, afterNextRender,
computed, computed,
Directive, Directive,
effect,
ElementRef, ElementRef,
inject, inject,
OnDestroy,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { CardStackContainerDirective } from './card-stack-container.directive'; import { CardStackContainerDirective } from './card-stack-container.directive';
@@ -13,12 +13,17 @@ import { CardStackContainerDirective } from './card-stack-container.directive';
selector: '[crmCardStackDistance]', selector: '[crmCardStackDistance]',
host: { host: {
'[style.--distance-to-center.px]': 'distanceToCenter()', '[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 container = inject(CardStackContainerDirective, { host: true });
readonly elementRef = inject(ElementRef<HTMLElement>); readonly elementRef = inject(ElementRef<HTMLElement>);
/** True once we've measured the card's natural position */
readonly ready = signal(false);
distanceToCenter = computed(() => { distanceToCenter = computed(() => {
const containerCenterX = this.container.centerX(); const containerCenterX = this.container.centerX();
const centerX = this.centerX(); const centerX = this.centerX();
@@ -26,26 +31,26 @@ export class CardStackDistanceDirective implements AfterViewInit {
}); });
centerX = signal(0); centerX = signal(0);
private resizeObserver?: ResizeObserver;
constructor() { constructor() {
// Recalculate position when container center changes // Wait for Angular to complete rendering and layout before calculating position
effect(() => { afterNextRender(() => {
// React to container center changes // Measure natural position (with --distance-to-center: 0, card is in natural spot)
const containerCenter = this.container.centerX(); this.centerX.set(this.getHorizontalCenter());
// Now enable the transform
this.ready.set(true);
// Recalculate this card's center position // Watch for this card's size changes
if (containerCenter !== 0) { this.resizeObserver = new ResizeObserver(() => {
// Use requestAnimationFrame to ensure DOM is stable this.centerX.set(this.getHorizontalCenter());
requestAnimationFrame(() => { });
this.centerX.set(this.getHorizontalCenter()); this.resizeObserver.observe(this.elementRef.nativeElement);
});
}
}); });
} }
ngAfterViewInit(): void { ngOnDestroy(): void {
// Initial calculation this.resizeObserver?.disconnect();
this.centerX.set(this.getHorizontalCenter());
} }
getHorizontalCenter(): number { getHorizontalCenter(): number {

View File

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

View File

@@ -2,10 +2,11 @@
<!-- Carousel with navigation arrows --> <!-- Carousel with navigation arrows -->
<ui-carousel <ui-carousel
[class.stacked]="stacked()" [class.stacked]="stacked()"
[class.single-card]="singleCard()"
[gap]="stacked() ? '0rem' : '1rem'" [gap]="stacked() ? '0rem' : '1rem'"
[arrowAutoHide]="true" [arrowAutoHide]="true"
crmCardStackContainer crmCardStackContainer
[disabled]="stacked()" [disabled]="stacked() || singleCard()"
> >
@for (card of sortedCards(); track card.code; let idx = $index) { @for (card of sortedCards(); track card.code; let idx = $index) {
<crm-customer-card <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', () => { describe('sortedCards computed signal', () => {
it('should sort cards with active cards first', () => { it('should sort cards with active cards first', () => {
fixture.componentRef.setInput('cards', mockCards); fixture.componentRef.setInput('cards', mockCards);

View File

@@ -38,13 +38,25 @@ import { CardStackDistanceDirective } from './card-stack-distance.directive';
}, },
}) })
export class CustomerCardsCarouselComponent { export class CustomerCardsCarouselComponent {
stacked = signal(true); #stacked = signal(true);
/** /**
* All bonus cards to display in carousel. * All bonus cards to display in carousel.
*/ */
readonly cards = input.required<BonusCardInfo[]>(); 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. * Event emitted when a card has been successfully locked or unlocked.
* Parent should reload cards and transactions. * Parent should reload cards and transactions.
@@ -74,6 +86,6 @@ export class CustomerCardsCarouselComponent {
}); });
unStack(): void { 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. * 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(() => { tooltipClasses = computed(() => {
const classes = ['ui-tooltip']; const classes = ['ui-tooltip'];
if (this.variant() === 'warning') { const v = this.variant();
if (v === 'warning') {
classes.push('ui-tooltip--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(' '); return classes.join(' ');
}); });

View File

@@ -88,9 +88,12 @@ export class TooltipDirective implements OnDestroy {
#tooltipInstance: TooltipComponent | null = null; #tooltipInstance: TooltipComponent | null = null;
#openTrigger: TooltipTrigger | null = null; // Tracks which trigger opened the tooltip #openTrigger: TooltipTrigger | null = null; // Tracks which trigger opened the tooltip
#isOpen = signal(false); // Internal signal to track open state for CloseOnScrollDirective #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 // Distance between tooltip and anchor element
readonly #offset = 8; // 0.5rem = 8px readonly #offset = 8; // 0.5rem = 8px
// Animation duration in ms (must match CSS)
readonly #animationDuration = 150;
/** Optional title for the tooltip. */ /** Optional title for the tooltip. */
title = input<string>(); title = input<string>();
@@ -111,7 +114,13 @@ export class TooltipDirective implements OnDestroy {
* Visual variant of the tooltip. * Visual variant of the tooltip.
* Defaults to 'default'. * 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() { constructor() {
// Set up effects to update tooltip instance when inputs change // 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. * Calculates the connected positions for the tooltip overlay.
* The preferred position is to the left-center of the host element. * Orders positions based on the preferred position input.
* Fallback positions are right-center, bottom, and top.
* @returns An array of `ConnectedPosition` objects. * @returns An array of `ConnectedPosition` objects.
*/ */
#getPositions(): ConnectedPosition[] { #getPositions(): ConnectedPosition[] {
return [ const left: ConnectedPosition = {
{ originX: 'start',
// Left-center position (default/preferred) originY: 'center',
originX: 'start', overlayX: 'end',
originY: 'center', overlayY: 'center',
overlayX: 'end', offsetX: -this.#offset,
overlayY: 'center', };
offsetX: -this.#offset,
}, const right: ConnectedPosition = {
{ originX: 'end',
// Right-center position originY: 'center',
originX: 'end', overlayX: 'start',
originY: 'center', overlayY: 'center',
overlayX: 'start', offsetX: this.#offset,
overlayY: 'center', };
offsetX: this.#offset,
}, const bottom: ConnectedPosition = {
{ originX: 'center',
// Bottom position originY: 'bottom',
originX: 'start', overlayX: 'center',
originY: 'bottom', overlayY: 'top',
overlayX: 'start', offsetY: this.#offset,
overlayY: 'top', };
offsetY: this.#offset,
}, const top: ConnectedPosition = {
{ originX: 'center',
// Top position originY: 'top',
originX: 'start', overlayX: 'center',
originY: 'top', overlayY: 'bottom',
overlayX: 'start', offsetY: -this.#offset,
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. * Shows the tooltip.
@@ -259,9 +277,10 @@ export class TooltipDirective implements OnDestroy {
/** /**
* Hides the tooltip only if the trigger matches the open trigger. * Hides the tooltip only if the trigger matches the open trigger.
* Plays fade-out animation before detaching.
*/ */
hide(trigger?: TooltipTrigger) { hide(trigger?: TooltipTrigger) {
if (!this.#overlayRef) { if (!this.#overlayRef || this.#isHiding) {
return; return;
} }
@@ -284,14 +303,26 @@ export class TooltipDirective implements OnDestroy {
} }
} }
this.#overlayRef.detach(); // Start exit animation
this.#overlayRef.dispose(); this.#isHiding = true;
this.#overlayRef = null; if (this.#tooltipInstance) {
this.#tooltipInstance = null; this.#tooltipInstance.leaving.set(true);
this.#openTrigger = null; }
// Update open state for CloseOnScrollDirective // Wait for animation to complete before detaching
this.#isOpen.set(false); 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)]; 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 { .ui-tooltip-title {
@apply isa-text-body-2-bold text-isa-neutral-900; @apply isa-text-body-2-bold text-isa-neutral-900;
} }
@@ -39,6 +50,14 @@
@apply text-isa-accent-red text-sm; @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 Global styles for tooltip overlay container
These styles will be injected into the global stylesheet These styles will be injected into the global stylesheet
@@ -48,10 +67,46 @@
pointer-events: none; /* Allow clicks to pass through */ 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 */ /* Animation for tooltip appearance */
.ui-tooltip { .ui-tooltip {
opacity: 1; animation: tooltipFadeIn 0.15s ease-out;
transition: opacity 150ms ease-in-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 { .ui-tooltip-icon {