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:
19
src/app/shared/ui/button/button.component.html
Normal file
19
src/app/shared/ui/button/button.component.html
Normal 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>
|
||||||
119
src/app/shared/ui/button/button.component.scss
Normal file
119
src/app/shared/ui/button/button.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/shared/ui/button/button.component.ts
Normal file
23
src/app/shared/ui/button/button.component.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user