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();
}
}
}