Merged PR 1967: Reward Shopping Cart Implementation

This commit is contained in:
Lorenz Hilpert
2025-10-14 16:02:18 +00:00
committed by Nino Righi
parent d761704dc4
commit f15848d5c0
158 changed files with 46339 additions and 39059 deletions

View File

@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
computed,
signal,
TemplateRef,
} from '@angular/core';
@@ -15,7 +16,7 @@ import { NgTemplateOutlet } from '@angular/common';
templateUrl: './tooltip.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '["ui-tooltip"]',
'[class]': 'tooltipClasses()',
},
standalone: true,
imports: [NgTemplateOutlet],
@@ -32,6 +33,22 @@ export class TooltipComponent {
*/
content = signal<string | TemplateRef<unknown> | undefined>(undefined);
/**
* The visual variant of the tooltip.
*/
variant = signal<'default' | 'warning'>('default');
/**
* Computed classes for the tooltip based on variant.
*/
tooltipClasses = computed(() => {
const classes = ['ui-tooltip'];
if (this.variant() === 'warning') {
classes.push('ui-tooltip--warning');
}
return classes.join(' ');
});
/**
* Returns the content as a TemplateRef if it is one.
* Used to render template content in the tooltip.

View File

@@ -1,332 +1,351 @@
import {
ConnectedPosition,
Overlay,
OverlayPositionBuilder,
OverlayRef,
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
Directive,
effect,
ElementRef,
inject,
input,
OnDestroy,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { TooltipComponent } from './tooltip.component';
/**
* Defines the trigger events for the tooltip.
*/
export const TooltipTrigger = {
Click: 'click',
Hover: 'hover',
Focus: 'focus',
} as const;
/**
* Type representing the possible tooltip trigger events.
*/
export type TooltipTrigger =
(typeof TooltipTrigger)[keyof typeof TooltipTrigger];
/**
* Directive to attach a tooltip to a host element.
* The tooltip can be triggered by click, hover, or focus events.
*
* @example
* ```html
* <button
* uiTooltip
* content="This is a tooltip"
* title="Tooltip Title"
* [triggerOn]="['hover']"
* >
* Hover me
* </button>
*
* <div uiTooltip [content]="templateRef" title="Template Tooltip">
* Show tooltip with template
* </div>
* <ng-template #templateRef>
* <p>This is content from a template!</p>
* </ng-template>
* ```
*/
@Directive({
selector: '[uiTooltip]',
host: {
'(click)': 'onClickEvent($event)',
'(mouseenter)': 'onMouseEnter()',
'(mouseleave)': 'onMouseLeave()',
'(focus)': 'onFocusEvent()',
'(blur)': 'onBlurEvent()',
'tabindex': '0',
},
standalone: true,
})
export class TooltipDirective implements OnDestroy {
#overlay = inject(Overlay);
#elementRef = inject(ElementRef);
#viewContainerRef = inject(ViewContainerRef);
#positionBuilder = inject(OverlayPositionBuilder);
#overlayRef: OverlayRef | null = null;
#tooltipInstance: TooltipComponent | null = null;
#openTrigger: TooltipTrigger | null = null; // Tracks which trigger opened the tooltip
// Distance between tooltip and anchor element (1.5rem as specified)
readonly #offset = 24; // 1.5rem = 24px (assuming 16px base)
/** Optional title for the tooltip. */
title = input<string>();
/** Content for the tooltip. Can be a string or a TemplateRef. */
content = input<string | TemplateRef<unknown>>();
/**
* Array of triggers that will cause the tooltip to show.
* Defaults to `['click', 'focus']`.
*/
triggerOn = input<TooltipTrigger[]>([
TooltipTrigger.Click,
TooltipTrigger.Hover,
TooltipTrigger.Focus,
]);
constructor() {
// Set up effects to update tooltip instance when inputs change
effect(() => {
const titleValue = this.title();
if (this.#tooltipInstance && titleValue !== undefined) {
this.#tooltipInstance.title.set(titleValue);
}
});
effect(() => {
const contentValue = this.content();
if (this.#tooltipInstance && contentValue !== undefined) {
this.#tooltipInstance.content.set(contentValue);
}
});
}
/**
* Determines if the tooltip should be shown for a given trigger event.
* @param trigger The trigger event to check.
* @returns True if the tooltip should show for the trigger, false otherwise.
*/
#shouldForTrigger(trigger: TooltipTrigger): boolean {
return this.triggerOn().includes(trigger);
}
/**
* Calculates the connected positions for the tooltip overlay.
* The preferred position is to the right of the host element.
* Fallback positions are left, bottom, and top.
* @returns An array of `ConnectedPosition` objects.
*/
#getPositions(): ConnectedPosition[] {
return [
{
// Right position (default/preferred)
originX: 'end',
originY: 'top',
overlayX: 'start',
overlayY: 'top',
offsetX: this.#offset,
offsetY: -this.#offset,
},
{
// Left position
originX: 'start',
originY: 'top',
overlayX: 'end',
overlayY: 'top',
offsetX: -this.#offset,
offsetY: -this.#offset,
},
{
// Bottom position
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetX: -this.#offset,
offsetY: this.#offset,
},
{
// Top position
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetX: -this.#offset,
offsetY: -this.#offset,
},
];
}
/**
* Shows the tooltip.
* Creates an overlay and attaches the `TooltipComponent` to it.
* Sets the initial title and content of the tooltip.
* If the tooltip is already visible, this method does nothing.
*/
show(trigger?: TooltipTrigger) {
// Don't create multiple tooltips
if (this.#overlayRef) {
return;
}
// Set the open trigger
this.#openTrigger = trigger ?? null;
// Create overlay positioned relative to the host element
const positionStrategy = this.#positionBuilder
.flexibleConnectedTo(this.#elementRef)
.withPositions(this.#getPositions())
.withFlexibleDimensions(false);
// Create the overlay
this.#overlayRef = this.#overlay.create({
positionStrategy,
scrollStrategy: this.#overlay.scrollStrategies.reposition(),
hasBackdrop: false,
panelClass: 'ui-tooltip-panel',
});
// Create the portal for the tooltip component
const portal = new ComponentPortal(
TooltipComponent,
this.#viewContainerRef,
);
// Attach the portal to the overlay and initialize content
// Effects will automatically update the instance when inputs change
this.#tooltipInstance = this.#overlayRef.attach(portal).instance;
// Set initial values (effects will handle subsequent updates)
const titleValue = this.title();
if (titleValue !== undefined) {
this.#tooltipInstance.title.set(titleValue);
}
const contentValue = this.content();
if (contentValue !== undefined) {
this.#tooltipInstance.content.set(contentValue);
}
}
/**
* Hides the tooltip only if the trigger matches the open trigger.
*/
hide(trigger?: TooltipTrigger) {
if (!this.#overlayRef) {
return;
}
if (
trigger === TooltipTrigger.Click &&
this.#openTrigger !== TooltipTrigger.Click
) {
this.#openTrigger = TooltipTrigger.Click;
return; // Do not close if the click trigger is not the one that opened it
}
if (trigger !== TooltipTrigger.Click) {
if (this.#openTrigger !== trigger) {
// If the tooltip is not opened by the same trigger, do not close
return;
}
}
this.#overlayRef.detach();
this.#overlayRef.dispose();
this.#overlayRef = null;
this.#tooltipInstance = null;
this.#openTrigger = null;
}
/**
* Toggles the visibility of the tooltip for a given trigger.
* If the tooltip is visible, it will be hidden by the same trigger.
* If the tooltip is hidden, it will be shown by the trigger.
*/
toggle(trigger: TooltipTrigger) {
if (this.#overlayRef) {
this.hide(trigger);
} else {
this.show(trigger);
}
}
/**
* Handles the click event on the host element.
* Toggles the tooltip visibility if 'click' is an active trigger.
* @param event The mouse event.
* @internal
*/
onClickEvent(event: MouseEvent) {
if (this.#shouldForTrigger(TooltipTrigger.Click)) {
event.preventDefault();
event.stopPropagation();
this.toggle(TooltipTrigger.Click);
}
}
/**
* Handles the mouseenter event on the host element.
* Shows the tooltip if 'hover' is an active trigger.
* @internal
*/
onMouseEnter() {
if (this.#shouldForTrigger(TooltipTrigger.Hover)) {
if (!this.#overlayRef) {
this.show(TooltipTrigger.Hover);
}
}
}
/**
* Handles the mouseleave event on the host element.
* Hides the tooltip if 'hover' is an active trigger and it was opened by hover.
* @internal
*/
onMouseLeave() {
if (this.#shouldForTrigger(TooltipTrigger.Hover)) {
this.hide(TooltipTrigger.Hover);
}
}
/**
* Handles the focus event on the host element.
* Shows the tooltip if 'focus' is an active trigger.
* @internal
*/
onFocusEvent() {
if (this.#shouldForTrigger(TooltipTrigger.Focus)) {
if (!this.#overlayRef) {
this.show(TooltipTrigger.Focus);
}
}
}
/**
* Handles the blur event on the host element.
* Hides the tooltip if 'focus' is an active trigger and it was opened by focus.
* @internal
*/
onBlurEvent() {
if (this.#shouldForTrigger(TooltipTrigger.Focus)) {
this.hide(TooltipTrigger.Focus);
}
}
/**
* Cleans up the tooltip when the directive is destroyed.
* Ensures the tooltip is hidden to prevent memory leaks.
*/
ngOnDestroy() {
this.hide();
}
}
import {
ConnectedPosition,
Overlay,
OverlayPositionBuilder,
OverlayRef,
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
Directive,
effect,
ElementRef,
inject,
input,
OnDestroy,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { TooltipComponent } from './tooltip.component';
/**
* Defines the trigger events for the tooltip.
*/
export const TooltipTrigger = {
Click: 'click',
Hover: 'hover',
Focus: 'focus',
} as const;
/**
* Type representing the possible tooltip trigger events.
*/
export type TooltipTrigger =
(typeof TooltipTrigger)[keyof typeof TooltipTrigger];
/**
* Directive to attach a tooltip to a host element.
* The tooltip can be triggered by click, hover, or focus events.
*
* @example
* ```html
* <button
* uiTooltip
* content="This is a tooltip"
* title="Tooltip Title"
* [triggerOn]="['hover']"
* >
* Hover me
* </button>
*
* <div uiTooltip [content]="templateRef" title="Template Tooltip">
* Show tooltip with template
* </div>
* <ng-template #templateRef>
* <p>This is content from a template!</p>
* </ng-template>
* ```
*/
@Directive({
selector: '[uiTooltip]',
host: {
'(click)': 'onClickEvent($event)',
'(mouseenter)': 'onMouseEnter()',
'(mouseleave)': 'onMouseLeave()',
'(focus)': 'onFocusEvent()',
'(blur)': 'onBlurEvent()',
'tabindex': '0',
},
standalone: true,
exportAs: 'uiTooltip',
})
export class TooltipDirective implements OnDestroy {
#overlay = inject(Overlay);
#elementRef = inject(ElementRef);
#viewContainerRef = inject(ViewContainerRef);
#positionBuilder = inject(OverlayPositionBuilder);
#overlayRef: OverlayRef | null = null;
#tooltipInstance: TooltipComponent | null = null;
#openTrigger: TooltipTrigger | null = null; // Tracks which trigger opened the tooltip
// Distance between tooltip and anchor element
readonly #offset = 8; // 0.5rem = 8px
/** Optional title for the tooltip. */
title = input<string>();
/** Content for the tooltip. Can be a string or a TemplateRef. */
content = input<string | TemplateRef<unknown>>();
/**
* Array of triggers that will cause the tooltip to show.
* Defaults to `['click', 'hover', 'focus']`.
*/
triggerOn = input<TooltipTrigger[]>([
TooltipTrigger.Click,
TooltipTrigger.Hover,
TooltipTrigger.Focus,
]);
/**
* Visual variant of the tooltip.
* Defaults to 'default'.
*/
variant = input<'default' | 'warning'>('default');
constructor() {
// Set up effects to update tooltip instance when inputs change
effect(() => {
const titleValue = this.title();
if (this.#tooltipInstance && titleValue !== undefined) {
this.#tooltipInstance.title.set(titleValue);
}
});
effect(() => {
const contentValue = this.content();
if (this.#tooltipInstance && contentValue !== undefined) {
this.#tooltipInstance.content.set(contentValue);
}
});
effect(() => {
const variantValue = this.variant();
if (this.#tooltipInstance && variantValue !== undefined) {
this.#tooltipInstance.variant.set(variantValue);
}
});
}
/**
* Determines if the tooltip should be shown for a given trigger event.
* @param trigger The trigger event to check.
* @returns True if the tooltip should show for the trigger, false otherwise.
*/
#shouldForTrigger(trigger: TooltipTrigger): boolean {
return this.triggerOn().includes(trigger);
}
/**
* 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.
* @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,
},
];
}
/**
* Shows the tooltip.
* Creates an overlay and attaches the `TooltipComponent` to it.
* Sets the initial title and content of the tooltip.
* If the tooltip is already visible, this method does nothing.
*/
show(trigger?: TooltipTrigger) {
// Don't create multiple tooltips
if (this.#overlayRef) {
return;
}
// Set the open trigger
this.#openTrigger = trigger ?? null;
// Create overlay positioned relative to the host element
const positionStrategy = this.#positionBuilder
.flexibleConnectedTo(this.#elementRef)
.withPositions(this.#getPositions())
.withFlexibleDimensions(false);
// Create the overlay
this.#overlayRef = this.#overlay.create({
positionStrategy,
scrollStrategy: this.#overlay.scrollStrategies.reposition(),
hasBackdrop: false,
panelClass: 'ui-tooltip-panel',
});
// Create the portal for the tooltip component
const portal = new ComponentPortal(
TooltipComponent,
this.#viewContainerRef,
);
// Attach the portal to the overlay and initialize content
// Effects will automatically update the instance when inputs change
this.#tooltipInstance = this.#overlayRef.attach(portal).instance;
// Set initial values (effects will handle subsequent updates)
const titleValue = this.title();
if (titleValue !== undefined) {
this.#tooltipInstance.title.set(titleValue);
}
const contentValue = this.content();
if (contentValue !== undefined) {
this.#tooltipInstance.content.set(contentValue);
}
const variantValue = this.variant();
if (variantValue !== undefined) {
this.#tooltipInstance.variant.set(variantValue);
}
}
/**
* Hides the tooltip only if the trigger matches the open trigger.
*/
hide(trigger?: TooltipTrigger) {
if (!this.#overlayRef) {
return;
}
if (
trigger === TooltipTrigger.Click &&
this.#openTrigger !== TooltipTrigger.Click
) {
this.#openTrigger = TooltipTrigger.Click;
return; // Do not close if the click trigger is not the one that opened it
}
// Treat null and undefined as equivalent for programmatic control
const normalizedTrigger = trigger ?? null;
const normalizedOpenTrigger = this.#openTrigger ?? null;
if (trigger !== TooltipTrigger.Click) {
if (normalizedOpenTrigger !== normalizedTrigger) {
// If the tooltip is not opened by the same trigger, do not close
return;
}
}
this.#overlayRef.detach();
this.#overlayRef.dispose();
this.#overlayRef = null;
this.#tooltipInstance = null;
this.#openTrigger = null;
}
/**
* Toggles the visibility of the tooltip for a given trigger.
* If the tooltip is visible, it will be hidden by the same trigger.
* If the tooltip is hidden, it will be shown by the trigger.
*/
toggle(trigger: TooltipTrigger) {
if (this.#overlayRef) {
this.hide(trigger);
} else {
this.show(trigger);
}
}
/**
* Handles the click event on the host element.
* Toggles the tooltip visibility if 'click' is an active trigger.
* @param event The mouse event.
* @internal
*/
onClickEvent(event: MouseEvent) {
if (this.#shouldForTrigger(TooltipTrigger.Click)) {
event.preventDefault();
event.stopPropagation();
this.toggle(TooltipTrigger.Click);
}
}
/**
* Handles the mouseenter event on the host element.
* Shows the tooltip if 'hover' is an active trigger.
* @internal
*/
onMouseEnter() {
if (this.#shouldForTrigger(TooltipTrigger.Hover)) {
if (!this.#overlayRef) {
this.show(TooltipTrigger.Hover);
}
}
}
/**
* Handles the mouseleave event on the host element.
* Hides the tooltip if 'hover' is an active trigger and it was opened by hover.
* @internal
*/
onMouseLeave() {
if (this.#shouldForTrigger(TooltipTrigger.Hover)) {
this.hide(TooltipTrigger.Hover);
}
}
/**
* Handles the focus event on the host element.
* Shows the tooltip if 'focus' is an active trigger.
* @internal
*/
onFocusEvent() {
if (this.#shouldForTrigger(TooltipTrigger.Focus)) {
if (!this.#overlayRef) {
this.show(TooltipTrigger.Focus);
}
}
}
/**
* Handles the blur event on the host element.
* Hides the tooltip if 'focus' is an active trigger and it was opened by focus.
* @internal
*/
onBlurEvent() {
if (this.#shouldForTrigger(TooltipTrigger.Focus)) {
this.hide(TooltipTrigger.Focus);
}
}
/**
* Cleans up the tooltip when the directive is destroyed.
* Ensures the tooltip is hidden to prevent memory leaks.
*/
ngOnDestroy() {
this.hide();
}
}

View File

@@ -12,6 +12,17 @@
gap-2;
}
.ui-tooltip--warning {
@apply w-auto
max-w-[12rem]
p-3
rounded-lg
bg-red-50
border
border-isa-accent-red
shadow-[0px_2px_8px_0px_rgba(223,0,27,0.15)];
}
.ui-tooltip-title {
@apply isa-text-body-2-bold text-isa-neutral-900;
}
@@ -20,6 +31,14 @@
@apply isa-text-body-2-regular text-isa-neutral-600;
}
.ui-tooltip--warning .ui-tooltip-title {
@apply text-isa-accent-red;
}
.ui-tooltip--warning .ui-tooltip-content {
@apply text-isa-accent-red text-sm;
}
/*
Global styles for tooltip overlay container
These styles will be injected into the global stylesheet