feat(design-system): add reusable Button primitive

Sprint 6 (pulled forward as prerequisite to Sprint 3). First Design
System component: app-button, standalone/OnPush, variants
(primary/secondary/ghost/danger), sizes (sm/md/lg), loading + disabled
states, focus-visible ring, reduced-motion-aware spinner. Colors/radius/
shadow/spacing consumed via existing tenant-driven CSS vars
(--primary-color, --bg-primary, --radius-md, --shadow-sm, --space-*) —
no new hardcoded palette, fully compatible with bootstrap JSON theming
and Project Editor live preview.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-15 03:22:40 +04:00
parent cc885c009a
commit 4e63ff97bb
3 changed files with 161 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
<button
class="app-button"
[class.app-button--sm]="size() === 'sm'"
[class.app-button--md]="size() === 'md'"
[class.app-button--lg]="size() === 'lg'"
[class.app-button--primary]="variant() === 'primary'"
[class.app-button--secondary]="variant() === 'secondary'"
[class.app-button--ghost]="variant() === 'ghost'"
[class.app-button--danger]="variant() === 'danger'"
[class.app-button--loading]="loading()"
[type]="type()"
[disabled]="disabled() || loading()"
[attr.aria-busy]="loading() || null"
>
@if (loading()) {
<span class="app-button__spinner" aria-hidden="true"></span>
}
<span class="app-button__label"><ng-content /></span>
</button>

View File

@@ -0,0 +1,119 @@
:host {
display: inline-block;
}
:host.app-button-host--full-width {
display: block;
width: 100%;
}
.app-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs, 0.25rem);
width: 100%;
border: 1px solid transparent;
border-radius: var(--radius-md, 6px);
font-family: inherit;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: background-color var(--transition-fast, 120ms ease),
border-color var(--transition-fast, 120ms ease),
box-shadow var(--transition-fast, 120ms ease),
opacity var(--transition-fast, 120ms ease);
&:focus-visible {
outline: 2px solid var(--primary-color, #2f6e5d);
outline-offset: 2px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.app-button--sm {
height: 32px;
padding: 0 var(--space-sm, 0.5rem);
font-size: 0.8125rem;
}
.app-button--md {
height: 40px;
padding: 0 var(--space-md, 1rem);
font-size: 0.9375rem;
}
.app-button--lg {
height: 48px;
padding: 0 var(--space-lg, 1.5rem);
font-size: 1rem;
}
.app-button--primary {
background: var(--primary-color, #2f6e5d);
border-color: var(--primary-color, #2f6e5d);
color: var(--bg-primary, #fff);
&:hover:not(:disabled) {
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
}
}
.app-button--secondary {
background: var(--bg-secondary, #f4f4f5);
border-color: var(--border-color, #e4e4e7);
color: var(--text-primary, #1f322d);
&:hover:not(:disabled) {
border-color: var(--primary-color, #2f6e5d);
}
}
.app-button--ghost {
background: transparent;
border-color: transparent;
color: var(--text-primary, #1f322d);
&:hover:not(:disabled) {
background: var(--bg-secondary, #f4f4f5);
}
}
.app-button--danger {
background: var(--error-color, #c0392b);
border-color: var(--error-color, #c0392b);
color: var(--bg-primary, #fff);
&:hover:not(:disabled) {
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
}
}
.app-button__spinner {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: app-button-spin 0.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.app-button {
transition: none;
}
.app-button__spinner {
animation-duration: 1.2s;
}
}
@keyframes app-button-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
@Component({
selector: 'app-button',
standalone: true,
templateUrl: './button.component.html',
styleUrl: './button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.app-button-host--full-width]': 'fullWidth()'
}
})
export class ButtonComponent {
readonly variant = input<ButtonVariant>('primary');
readonly size = input<ButtonSize>('md');
readonly type = input<'button' | 'submit' | 'reset'>('button');
readonly disabled = input(false);
readonly loading = input(false);
readonly fullWidth = input(false);
}