feat(shared-ui): add toggle, select, color-picker, section-card, locale-tabs, key-value-editor primitives

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-16 01:59:45 +04:00
parent b8d89ca8e7
commit e18f542357
18 changed files with 679 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
<div class="app-color-picker">
<input
type="color"
class="app-color-picker-swatch"
[value]="value"
[disabled]="isDisabled || disabled()"
[attr.aria-label]="ariaLabel()"
(input)="handleSwatchChange($event)"
/>
<input
type="text"
class="app-color-picker-text"
[class.app-color-picker-text--invalid]="invalid"
[value]="textValue"
[disabled]="isDisabled || disabled()"
[attr.id]="formField?.fieldId ?? null"
[attr.aria-invalid]="invalid || null"
[attr.aria-describedby]="formField?.describedBy() ?? null"
(input)="handleTextChange($event)"
(blur)="handleBlur()"
/>
</div>

View File

@@ -0,0 +1,59 @@
:host {
display: block;
}
.app-color-picker {
display: flex;
gap: 8px;
align-items: center;
}
.app-color-picker-swatch {
flex: 0 0 44px;
height: 40px;
padding: 4px;
border: 1px solid var(--border-color, #e4e4e7);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.app-color-picker-text {
flex: 1;
height: 40px;
padding: 0 var(--space-md, 1rem);
border: 1px solid var(--border-color, #e4e4e7);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #1f322d);
font-family: inherit;
font-size: 0.9375rem;
transition: border-color var(--transition-fast, 120ms ease),
box-shadow var(--transition-fast, 120ms ease);
&:focus-visible {
outline: none;
border-color: var(--primary-color, #2f6e5d);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.app-color-picker-text--invalid {
border-color: var(--error-color, #c0392b);
}
@media (prefers-reduced-motion: reduce) {
.app-color-picker-text {
transition: none;
}
}

View File

@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, forwardRef, inject, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { FormFieldContext } from '../form-field/form-field.component';
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
@Component({
selector: 'app-color-picker',
standalone: true,
templateUrl: './color-picker.component.html',
styleUrl: './color-picker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ColorPickerComponent),
multi: true
}
]
})
export class ColorPickerComponent implements ControlValueAccessor {
protected readonly formField = inject(FormFieldContext, { optional: true });
readonly disabled = input(false);
readonly ariaLabel = input<string | null>(null);
protected value = '#000000';
protected textValue = '#000000';
protected isDisabled = false;
protected invalid = false;
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: string): void {
this.value = HEX_COLOR.test(value ?? '') ? value : '#000000';
this.textValue = value ?? '';
this.invalid = !HEX_COLOR.test(this.textValue);
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled;
}
protected handleSwatchChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.value = target.value;
this.textValue = target.value;
this.invalid = false;
this.onChange(this.value);
}
protected handleTextChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.textValue = target.value;
this.invalid = !HEX_COLOR.test(this.textValue);
if (!this.invalid) {
this.value = this.textValue;
this.onChange(this.value);
}
}
protected handleBlur(): void {
this.onTouched();
}
}

View File

@@ -0,0 +1,11 @@
<div class="app-key-value-editor">
@for (row of rows(); track $index) {
<div class="app-key-value-editor-row">
<div class="app-key-value-editor-row-content">
<ng-container [ngTemplateOutlet]="rowTemplate" [ngTemplateOutletContext]="{ $implicit: row, index: $index }" />
</div>
<app-button variant="danger" size="sm" (click)="removeRow($index)">{{ removeLabel() }}</app-button>
</div>
}
<app-button variant="secondary" size="sm" (click)="addRow()">{{ addLabel() }}</app-button>
</div>

View File

@@ -0,0 +1,24 @@
:host {
display: block;
}
.app-key-value-editor {
display: grid;
gap: 10px;
}
.app-key-value-editor-row {
display: flex;
gap: 8px;
align-items: flex-start;
border: 1px solid var(--border-color, #d3dad9);
border-radius: 12px;
padding: 12px;
background: var(--bg-secondary, #fbfcfc);
}
.app-key-value-editor-row-content {
flex: 1;
display: grid;
gap: 8px;
}

View File

@@ -0,0 +1,35 @@
import { ChangeDetectionStrategy, Component, ContentChild, TemplateRef, input, output } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '../button/button.component';
export interface KeyValueRowContext<T> {
$implicit: T;
index: number;
}
@Component({
selector: 'app-key-value-editor',
standalone: true,
imports: [NgTemplateOutlet, ButtonComponent],
templateUrl: './key-value-editor.component.html',
styleUrl: './key-value-editor.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class KeyValueEditorComponent<T> {
readonly rows = input.required<T[]>();
readonly addLabel = input('Add');
readonly removeLabel = input('Remove');
readonly createRow = input.required<() => T>();
readonly rowsChange = output<T[]>();
@ContentChild(TemplateRef) rowTemplate: TemplateRef<KeyValueRowContext<T>> | null = null;
protected addRow(): void {
this.rowsChange.emit([...this.rows(), this.createRow()()]);
}
protected removeRow(index: number): void {
this.rowsChange.emit(this.rows().filter((_, i) => i !== index));
}
}

View File

@@ -0,0 +1,14 @@
<div class="app-locale-tabs" role="tablist">
@for (locale of locales(); track locale) {
<button
type="button"
role="tab"
class="app-locale-tab"
[class.app-locale-tab--active]="currentLocale() === locale"
[attr.aria-selected]="currentLocale() === locale"
(click)="select(locale)"
>
{{ locale }}
</button>
}
</div>

View File

@@ -0,0 +1,43 @@
:host {
display: block;
}
.app-locale-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border-color, #d3dad9);
}
.app-locale-tab {
padding: 8px 16px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-secondary, #5f6e6a);
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
cursor: pointer;
transition: color var(--transition-fast, 120ms ease),
border-color var(--transition-fast, 120ms ease);
&:hover {
color: var(--text-primary, #1e3c38);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
}
}
.app-locale-tab--active {
color: var(--primary-color, #2f6e5d);
border-bottom-color: var(--primary-color, #2f6e5d);
}
@media (prefers-reduced-motion: reduce) {
.app-locale-tab {
transition: none;
}
}

View File

@@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core';
@Component({
selector: 'app-locale-tabs',
standalone: true,
templateUrl: './locale-tabs.component.html',
styleUrl: './locale-tabs.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LocaleTabsComponent {
readonly locales = input.required<string[]>();
readonly defaultLocale = input<string | null>(null);
readonly activeLocale = output<string>();
private readonly selected = signal<string | null>(null);
protected currentLocale(): string {
const explicit = this.selected();
if (explicit && this.locales().includes(explicit)) return explicit;
return this.defaultLocale() ?? this.locales()[0] ?? '';
}
protected select(locale: string): void {
this.selected.set(locale);
this.activeLocale.emit(locale);
}
}

View File

@@ -0,0 +1,14 @@
<section class="app-section-card">
@if (title(); as title) {
<header class="app-section-card-header">
<h2>{{ title }}</h2>
@if (description(); as description) {
<p class="app-section-card-description">{{ description }}</p>
}
<ng-content select="[sectionActions]" />
</header>
}
<div class="app-section-card-body">
<ng-content />
</div>
</section>

View File

@@ -0,0 +1,34 @@
:host {
display: block;
}
.app-section-card {
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #d3dad9);
border-radius: 16px;
padding: 18px;
display: grid;
gap: 14px;
}
.app-section-card-header {
display: grid;
gap: 4px;
}
.app-section-card-header h2 {
margin: 0;
font-size: 1.125rem;
color: var(--text-primary, #1e3c38);
}
.app-section-card-description {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary, #5f6e6a);
}
.app-section-card-body {
display: grid;
gap: 14px;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-section-card',
standalone: true,
templateUrl: './section-card.component.html',
styleUrl: './section-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SectionCardComponent {
readonly title = input<string | null>(null);
readonly description = input<string | null>(null);
}

View File

@@ -0,0 +1,20 @@
<select
class="app-select"
[class.app-select--sm]="size() === 'sm'"
[class.app-select--md]="size() === 'md'"
[class.app-select--lg]="size() === 'lg'"
[disabled]="isDisabled || disabled()"
[attr.id]="formField?.fieldId ?? null"
[attr.aria-label]="ariaLabel()"
[attr.aria-describedby]="formField?.describedBy() ?? null"
[value]="value"
(change)="handleChange($event)"
(blur)="handleBlur()"
>
@for (option of options(); track option.value) {
<option [value]="option.value" [title]="option.description ?? ''">{{ option.label }}</option>
}
</select>
@if (activeDescription(); as description) {
<small class="app-select-description">{{ description }}</small>
}

View File

@@ -0,0 +1,68 @@
:host {
display: inline-block;
}
:host.app-select-host--full-width {
display: block;
width: 100%;
}
.app-select {
width: 100%;
border: 1px solid var(--border-color, #e4e4e7);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #1f322d);
font-family: inherit;
cursor: pointer;
transition: border-color var(--transition-fast, 120ms ease),
box-shadow var(--transition-fast, 120ms ease);
&:hover:not(:disabled) {
border-color: var(--primary-color, #2f6e5d);
}
&:focus-visible {
outline: none;
border-color: var(--primary-color, #2f6e5d);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.app-select--sm {
height: 32px;
padding: 0 var(--space-sm, 0.5rem);
font-size: 0.8125rem;
}
.app-select--md {
height: 40px;
padding: 0 var(--space-md, 1rem);
font-size: 0.9375rem;
}
.app-select--lg {
height: 48px;
padding: 0 var(--space-lg, 1.5rem);
font-size: 1rem;
}
.app-select-description {
display: block;
font-weight: 400;
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary, #6b7280);
margin-top: 4px;
}
@media (prefers-reduced-motion: reduce) {
.app-select {
transition: none;
}
}

View File

@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, forwardRef, inject, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { FormFieldContext } from '../form-field/form-field.component';
export type SelectSize = 'sm' | 'md' | 'lg';
export interface SelectOption {
value: string;
label: string;
description?: string;
}
@Component({
selector: 'app-select',
standalone: true,
templateUrl: './select.component.html',
styleUrl: './select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectComponent),
multi: true
}
],
host: {
'[class.app-select-host--full-width]': 'fullWidth()'
}
})
export class SelectComponent implements ControlValueAccessor {
protected readonly formField = inject(FormFieldContext, { optional: true });
readonly options = input.required<SelectOption[]>();
readonly size = input<SelectSize>('md');
readonly disabled = input(false);
readonly fullWidth = input(true);
readonly ariaLabel = input<string | null>(null);
protected value = '';
protected isDisabled = false;
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: string): void {
this.value = value ?? '';
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled;
}
protected handleChange(event: Event): void {
const target = event.target as HTMLSelectElement;
this.value = target.value;
this.onChange(this.value);
}
protected handleBlur(): void {
this.onTouched();
}
protected activeDescription(): string | null {
return this.options().find(option => option.value === this.value)?.description ?? null;
}
}

View File

@@ -0,0 +1,15 @@
<button
type="button"
class="app-toggle"
[class.app-toggle--sm]="size() === 'sm'"
[class.app-toggle--md]="size() === 'md'"
[class.app-toggle--checked]="checked"
role="switch"
[attr.aria-checked]="checked"
[attr.aria-label]="ariaLabel()"
[disabled]="isDisabled || disabled()"
(click)="handleClick()"
(keydown)="handleKeydown($event)"
>
<span class="app-toggle-thumb"></span>
</button>

View File

@@ -0,0 +1,72 @@
:host {
display: inline-block;
}
.app-toggle {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--border-color, #d3dad9);
background: var(--bg-secondary, #e4e4e7);
cursor: pointer;
padding: 0;
transition: background-color var(--transition-fast, 120ms ease),
border-color var(--transition-fast, 120ms ease);
&:hover:not(:disabled) {
border-color: var(--primary-color, #2f6e5d);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.app-toggle--sm {
width: 34px;
height: 20px;
}
.app-toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: transform var(--transition-fast, 120ms ease);
}
.app-toggle--sm .app-toggle-thumb {
width: 14px;
height: 14px;
}
.app-toggle--checked {
background: var(--primary-color, #2f6e5d);
border-color: var(--primary-color, #2f6e5d);
}
.app-toggle--checked .app-toggle-thumb {
transform: translateX(18px);
}
.app-toggle--sm.app-toggle--checked .app-toggle-thumb {
transform: translateX(14px);
}
@media (prefers-reduced-motion: reduce) {
.app-toggle,
.app-toggle-thumb {
transition: none;
}
}

View File

@@ -0,0 +1,60 @@
import { ChangeDetectionStrategy, Component, forwardRef, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
export type ToggleSize = 'sm' | 'md';
@Component({
selector: 'app-toggle',
standalone: true,
templateUrl: './toggle.component.html',
styleUrl: './toggle.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ToggleComponent),
multi: true
}
]
})
export class ToggleComponent implements ControlValueAccessor {
readonly size = input<ToggleSize>('md');
readonly disabled = input(false);
readonly ariaLabel = input<string | null>(null);
protected checked = false;
protected isDisabled = false;
private onChange: (value: boolean) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: boolean): void {
this.checked = !!value;
}
registerOnChange(fn: (value: boolean) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled;
}
protected handleClick(): void {
if (this.isDisabled || this.disabled()) return;
this.checked = !this.checked;
this.onChange(this.checked);
this.onTouched();
}
protected handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
this.handleClick();
}
}
}