mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
committed by
Nino Righi
parent
7950994d66
commit
a5bb8b2895
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(' ');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user