import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
signal,
} from '@angular/core';
import {
defer,
from,
type Observable,
of,
type Subscription,
take,
throwError,
} from 'rxjs';
import { AfricaniesIconComponent, type IconName } from '@africanies/africanies-icons';
import { AfricaniesUiI18n } from '../i18n/africanies-ui-i18n';
import { ButtonComponent } from '../button/button.component';
import { AfricaniesOverlayRef } from './africanies-overlay-ref';
import type { ConfirmOptions, ConfirmWork } from './confirm-options';
import { OVERLAY_DATA } from './overlay-data.token';
/**
* Built-in confirm dialog hosted by {@link ConfirmService} / {@link ModalService}.
*
* WHY a dedicated component: most apps need the same title/message/two-button
* pattern; shipping it here avoids every feature reinventing confirm UI while
* still allowing custom modals via {@link ModalService.open}.
*
* When {@link ConfirmOptions.onConfirm} is set, Confirm stays open and the
* primary button shows {@link ButtonComponent} loading until the work
* settles. Errors leave the dialog open.
*/
@Component({
selector: 'africanies-confirm-dialog',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AfricaniesIconComponent, ButtonComponent],
host: {
class: 'block w-full min-w-0',
},
template: `
{{ title() }}
{{ options.message }}
@if (emphasizeCancel()) {
} @else {
}
`,
})
/**
* Built-in confirm dialog hosted by {@link ConfirmService} / {@link ModalService}.
*/
export class ConfirmDialogComponent {
private readonly i18n = inject(AfricaniesUiI18n);
/** Stable ids so aria-labelledby / aria-describedby stay unique per instance. */
protected readonly titleId = `africanies-confirm-title-${cryptoRandom()}`;
protected readonly messageId = `africanies-confirm-message-${cryptoRandom()}`;
/** Options injected by {@link ConfirmService}. */
protected readonly options = inject(OVERLAY_DATA) as ConfirmOptions;
private readonly ref = inject(AfricaniesOverlayRef);
private readonly destroyRef = inject(DestroyRef);
private workSub: Subscription | null = null;
/** True while {@link ConfirmOptions.onConfirm} is in flight. */
protected readonly loading = signal(false);
/** Resolved heading with a sensible default when callers omit `title`. */
protected readonly title = computed(
() =>
this.options.title ??
this.i18n.t('africaniesUi.confirm.title', 'Confirm'),
);
/** Resolved confirm CTA label. */
protected readonly confirmLabel = computed(
() =>
this.options.confirmLabel ??
this.i18n.t('africaniesUi.confirm.confirm', 'Confirm'),
);
/** Resolved cancel CTA label. */
protected readonly cancelLabel = computed(
() =>
this.options.cancelLabel ??
this.i18n.t('africaniesUi.confirm.cancel', 'Cancel'),
);
/** When true, cancel is primary and listed after confirm (safe choice last). */
protected readonly emphasizeCancel = computed(
() => this.options.emphasizeCancel === true,
);
/** Warning for destructive confirms; question mark otherwise. */
protected readonly iconName = computed(
(): IconName => (this.options.danger ? 'warning' : 'question'),
);
/**
* Tinted well so the icon reads as a status, not a thin glyph beside the
* heading. Danger uses the red subtle token; default uses export green.
*/
protected readonly iconWellClass = computed(() => {
const well =
'inline-flex size-14 shrink-0 items-center justify-center rounded-2xl';
return this.options.danger
? `${well} bg-danger-subtle text-danger-dark dark:bg-danger/20 dark:text-danger`
: `${well} bg-export-subtle text-export-strong dark:bg-export/20 dark:text-export-light`;
});
constructor() {
this.destroyRef.onDestroy(() => this.workSub?.unsubscribe());
}
/**
* Affirms the action. When `onConfirm` is set, waits for it before closing
* with `true`; failures keep the dialog open.
*/
protected confirm(): void {
if (this.loading()) {
return;
}
const action = this.options.onConfirm;
if (!action) {
this.ref.close(true);
return;
}
this.loading.set(true);
// Let the spinner paint before starting work so a fast request still
// shows loading, and avoid takeUntilDestroyed in this click handler
// (it can complete EMPTY and close without subscribing).
setTimeout(() => this.runConfirmWork(action), 0);
}
/** Dismisses without affirming — emits `false`. */
protected cancel(): void {
if (this.loading()) {
return;
}
this.ref.close(false);
}
/**
* Subscribe to confirm work after loading is visible.
*
* @param action - Callback from {@link ConfirmOptions.onConfirm}.
*/
private runConfirmWork(action: ConfirmWork): void {
if (this.destroyRef.destroyed || !this.loading()) {
return;
}
this.workSub?.unsubscribe();
this.workSub = defer(() => confirmWork(action))
.pipe(take(1))
.subscribe({
next: () => this.ref.close(true),
error: () => this.loading.set(false),
});
}
}
/**
* Normalize {@link ConfirmWork} into a single-emission Observable.
*
* Uses `subscribe` duck-typing so a second RxJS copy still starts the HTTP
* call (`isObservable` is false across duplicate `rxjs` instances).
*
* @param action - Confirm callback from dialog options.
* @returns Observable that completes on success or errors on failure.
*/
function confirmWork(action: ConfirmWork): Observable {
let result: ReturnType;
try {
result = action();
} catch (err: unknown) {
return throwError(() =>
err instanceof Error ? err : new Error('Confirm action failed.'),
);
}
if (result == null) {
return of(undefined);
}
if (typeof (result as { subscribe?: unknown }).subscribe === 'function') {
return result as Observable;
}
return from(result);
}
/**
* Tiny unique suffix without pulling in a uuid dependency.
*
* @returns Short alphanumeric id fragment.
*/
function cryptoRandom(): string {
return Math.random().toString(36).slice(2, 10);
}