import { booleanAttribute, ChangeDetectionStrategy, Component, computed, inject, input, } from '@angular/core'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import { ModeColorService } from '@africanies/africanies-theme'; /** * Visual emphasis for {@link ButtonComponent}. * * - `primary` — filled mode accent (SFN green / STN orange) for the main action * - `secondary` — stroked mode accent (white + mode border/text; portal `.btn-stroked`) * - `flat` — neutral chrome (white + ink + soft border; portal `.btn-flat`) * - `ghost` — neutral text-only for low-emphasis actions (Cancel, tertiary) * - `ghost-primary` — text-only mode accent (SFN green / STN orange) * - `ghost-danger` — text-only destructive actions (Clear, Remove) * - `danger` — destructive confirmations and deletes */ export type ButtonVariant = | 'primary' | 'secondary' | 'flat' | 'ghost' | 'ghost-primary' | 'ghost-danger' | 'underline' | 'danger'; /** * Control size for {@link ButtonComponent}. */ export type ButtonSize = 'sm' | 'md' | 'lg'; /** * Theme-token button used across AFRICANIES surfaces. * * WHY an attribute selector on native `button` / `a`: keeps semantics and * keyboard behavior on the host element (Enter/Space, focus rings) instead of * nesting an interactive element inside a custom component host. * * **Primary** and **ghost-primary** follow {@link ModeColorService} / shipping mode: * SFN → export green, STN → import orange. * * @example * ```html * * * * * * * * ``` */ @Component({ selector: 'button[africanies-button], a[africanies-button]', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [AfricaniesIconComponent], template: ` @if (loading()) { } `, host: { '[class]': 'hostClass()', '[attr.aria-disabled]': 'inactive() ? true : null', '[attr.disabled]': 'inactive() ? true : null', '[attr.aria-busy]': 'loading() ? true : null', '[attr.tabindex]': 'inactive() ? -1 : null', '(click)': 'blockWhenDisabled($event)', }, }) /** * Theme-token button used across AFRICANIES surfaces. */ export class ButtonComponent { private readonly modeColor = inject(ModeColorService); /** * Visual variant. Defaults to `primary` so a bare `africanies-button` reads as the * main CTA without extra attributes. */ readonly variant = input('primary'); /** * Padding / type scale. Defaults to `md` for form toolbars and dialogs. */ readonly size = input('md'); /** * When true, blocks pointer and keyboard activation. Also sets * `aria-disabled` for anchors that cannot use the native `disabled` attribute. */ readonly disabled = input(false, { transform: booleanAttribute }); /** * In-flight mutation. Replaces the label with a circular spinner while * keeping the control's width, and treats the host as disabled. * * Plain boolean (no `booleanAttribute`): confirm/dialog `[loading]="signal()"` * bindings must pass through `true` without attribute-string coercion. */ readonly loading = input(false); protected readonly inactive = computed( () => this.disabled() || this.loading(), ); protected readonly spinnerSize = computed(() => { const size = this.size(); if (size === 'sm') { return 14; } if (size === 'lg') { return 18; } return 16; }); /** * Composed Tailwind classes from theme tokens — kept as string literals so * the scanner retains utilities in the published UI bundle. */ protected readonly hostClass = computed(() => { const base = 'relative inline-flex items-center justify-center gap-2 box-border font-sans font-medium rounded-md border transition-colors cursor-pointer ' + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus ' + 'disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed'; const sizes: Record = { sm: 'text-body-sm px-2.5 py-0 h-8', md: 'text-body px-3.5 py-0 h-10', lg: 'text-body-lg px-4 py-0 h-12', }; const variant = this.variant(); let variantClass: string; if (variant === 'primary') { variantClass = this.modeColor.classes().primary; } else if (variant === 'ghost-primary') { variantClass = this.modeColor.classes().ghostPrimary; } else if (variant === 'secondary') { variantClass = this.modeColor.classes().stroked; } else if (variant === 'flat') { variantClass = 'bg-surface dark:bg-ink-950 text-ink dark:text-white border-control dark:border-white/15 hover:bg-background-hover dark:hover:bg-white/10'; } else if (variant === 'ghost') { variantClass = 'bg-transparent text-ink dark:text-white border-transparent hover:bg-background-hover dark:hover:bg-white/10'; } else if (variant === 'ghost-danger') { variantClass = 'bg-transparent text-danger-dark border-transparent hover:bg-danger-subtle dark:text-danger dark:hover:bg-danger/10'; } else if (variant === 'underline') { variantClass = 'bg-transparent text-ink dark:text-white border-transparent underline underline-offset-2 hover:opacity-80 !px-0 !h-auto !min-h-0'; } else { variantClass = 'bg-danger-dark text-white border-transparent hover:bg-danger-strong dark:bg-danger dark:hover:bg-danger-dark'; } return `${base} ${sizes[this.size()]} ${variantClass}`; }); /** * Anchors lack native `disabled` — block activation while keeping the cursor. * * Uses `stopPropagation` (not `stopImmediatePropagation`) so the host click * does not swallow the same-element `(click)` that starts confirm work. * * @param event */ protected blockWhenDisabled(event: Event): void { if (this.inactive()) { event.preventDefault(); event.stopPropagation(); } } }