import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { AfricaniesIconComponent } from '@africanies/africanies-icons';
import { ButtonComponent } from '../button/button.component';
import { splitMessageLines } from '../feedback/message-lines';
import { AfricaniesUiI18n } from '../i18n/africanies-ui-i18n';
import {
TOAST_ICONS,
type ToastItem,
type ToastVariant,
} from './toast.types';
/**
* Single toast card — dense, token-based, with optional countdown rail.
* Stacked identical toasts show a count; host Expand/Collapse all and Close all manage the stack; × peels one copy.
*/
@Component({
selector: 'africanies-toast-item',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AfricaniesIconComponent, ButtonComponent],
host: {
class: 'pointer-events-auto block w-full',
'[class.is-paused]': 'isPaused()',
'(mouseenter)': 'onPause()',
'(mouseleave)': 'onResume()',
'(focusin)': 'onPause()',
'(focusout)': 'onResume()',
},
template: `
@if (item().expanded && item().count > 1) {
@for (copy of copies(); track copy; let i = $index) {
@if (item().title; as heading) {
{{ heading }}
}
@if (messageLines().length > 1) {
@for (line of messageLines(); track $index) {
- {{ line }}
}
} @else {
{{ item().message }}
}
}
} @else {
@if (item().title; as heading) {
{{ heading }}
}
@if (messageLines().length > 1) {
@for (line of messageLines(); track $index) {
- {{ line }}
}
} @else {
{{ item().message }}
}
@if (item().count > 1) {
×{{ item().count }}
}
@if (item().durationMs) {
}
}
`,
styles: `
@keyframes africanies-toast-in {
from {
opacity: 0;
transform: translateX(0.75rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes africanies-toast-rail {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
.africanies-toast-enter {
animation: africanies-toast-in 220ms ease-out both;
}
.africanies-toast-rail {
animation-name: africanies-toast-rail;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
:host.is-paused .africanies-toast-rail {
animation-play-state: paused;
}
`,
})
/**
* Single toast card — dense, token-based, with optional countdown rail. Stacked identical toasts show a count; host Expand/Collapse all and Close all manage the stack; × peels one copy.
*/
export class ToastItemComponent {
private readonly i18n = inject(AfricaniesUiI18n);
/** Stack entry to render. */
readonly item = input.required();
/** Peel one copy (outermost) from the stack. */
readonly dismissOne = output();
/** Hover / focus — pause timer. */
readonly paused = output();
/** Leave — resume timer. */
readonly resumed = output();
protected readonly isPaused = signal(false);
protected readonly dismissLabel = computed(() =>
this.i18n.t('africaniesUi.toast.dismiss', 'Dismiss'),
);
protected readonly dismissAriaLabel = computed(() =>
this.item().count > 1
? this.i18n.t(
'africaniesUi.toast.dismissOutermost',
'Dismiss outermost',
)
: this.dismissLabel(),
);
protected readonly copies = computed(() =>
Array.from({ length: Math.max(1, this.item().count) }, (_, i) => i),
);
protected readonly messageLines = computed(() =>
splitMessageLines(this.item().message),
);
protected readonly defaultIcon = computed(
() => TOAST_ICONS[this.item().variant],
);
protected readonly a11yRole = computed(() => {
const v = this.item().variant;
return v === 'warning' || v === 'danger' ? 'alert' : 'status';
});
protected readonly a11yLive = computed(() => {
const v = this.item().variant;
return v === 'warning' || v === 'danger' ? 'assertive' : 'polite';
});
protected readonly shellClass = computed(
() =>
'africanies-toast-enter relative flex w-full overflow-hidden rounded-panel border border-border ' +
'bg-surface/95 text-ink shadow-floating backdrop-blur-md ' +
'dark:border-white/15 dark:bg-ink-950/95 dark:text-white',
);
protected readonly accentClass = computed(
() => `w-1 shrink-0 self-stretch ${accentFill(this.item().variant)}`,
);
protected readonly iconClass = computed(() => {
switch (this.item().variant) {
case 'success':
return 'text-export-strong dark:text-export-light';
case 'warning':
return 'text-warning-dark dark:text-warning';
case 'danger':
return 'text-danger-dark dark:text-danger';
default:
return 'text-ink-blue dark:text-neutral-300';
}
});
protected readonly titleClass = computed(() => {
const base = 'm-0 break-words text-body-sm font-semibold';
switch (this.item().variant) {
case 'success':
return `${base} text-export-strong dark:text-export-light`;
case 'warning':
return `${base} text-warning-dark dark:text-warning`;
case 'danger':
return `${base} text-danger-dark dark:text-danger`;
default:
return `${base} text-ink dark:text-white`;
}
});
protected readonly railFillClass = computed(() =>
railFill(this.item().variant),
);
protected onPause(): void {
this.isPaused.set(true);
this.paused.emit();
}
protected onResume(): void {
this.isPaused.set(false);
this.resumed.emit();
}
}
/**
* @param variant - Toast tone.
* @returns Accent bar fill.
*/
function accentFill(variant: ToastVariant): string {
switch (variant) {
case 'success':
return 'bg-export';
case 'warning':
return 'bg-warning';
case 'danger':
return 'bg-danger';
default:
return 'bg-neutral-400';
}
}
/**
* @param variant - Toast tone.
* @returns Progress rail fill class.
*/
function railFill(variant: ToastVariant): string {
return accentFill(variant);
}