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

@@ -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 {