From e18f5423578c4e8c7596e3444b65f6c086bedefc Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Thu, 16 Jul 2026 01:59:45 +0400 Subject: [PATCH] feat(shared-ui): add toggle, select, color-picker, section-card, locale-tabs, key-value-editor primitives Co-Authored-By: Claude Sonnet 5 --- .../color-picker/color-picker.component.html | 22 ++++++ .../color-picker/color-picker.component.scss | 59 +++++++++++++++ .../ui/color-picker/color-picker.component.ts | 74 +++++++++++++++++++ .../key-value-editor.component.html | 11 +++ .../key-value-editor.component.scss | 24 ++++++ .../key-value-editor.component.ts | 35 +++++++++ .../ui/locale-tabs/locale-tabs.component.html | 14 ++++ .../ui/locale-tabs/locale-tabs.component.scss | 43 +++++++++++ .../ui/locale-tabs/locale-tabs.component.ts | 27 +++++++ .../section-card/section-card.component.html | 14 ++++ .../section-card/section-card.component.scss | 34 +++++++++ .../ui/section-card/section-card.component.ts | 13 ++++ .../shared/ui/select/select.component.html | 20 +++++ .../shared/ui/select/select.component.scss | 68 +++++++++++++++++ src/app/shared/ui/select/select.component.ts | 74 +++++++++++++++++++ .../shared/ui/toggle/toggle.component.html | 15 ++++ .../shared/ui/toggle/toggle.component.scss | 72 ++++++++++++++++++ src/app/shared/ui/toggle/toggle.component.ts | 60 +++++++++++++++ 18 files changed, 679 insertions(+) create mode 100644 src/app/shared/ui/color-picker/color-picker.component.html create mode 100644 src/app/shared/ui/color-picker/color-picker.component.scss create mode 100644 src/app/shared/ui/color-picker/color-picker.component.ts create mode 100644 src/app/shared/ui/key-value-editor/key-value-editor.component.html create mode 100644 src/app/shared/ui/key-value-editor/key-value-editor.component.scss create mode 100644 src/app/shared/ui/key-value-editor/key-value-editor.component.ts create mode 100644 src/app/shared/ui/locale-tabs/locale-tabs.component.html create mode 100644 src/app/shared/ui/locale-tabs/locale-tabs.component.scss create mode 100644 src/app/shared/ui/locale-tabs/locale-tabs.component.ts create mode 100644 src/app/shared/ui/section-card/section-card.component.html create mode 100644 src/app/shared/ui/section-card/section-card.component.scss create mode 100644 src/app/shared/ui/section-card/section-card.component.ts create mode 100644 src/app/shared/ui/select/select.component.html create mode 100644 src/app/shared/ui/select/select.component.scss create mode 100644 src/app/shared/ui/select/select.component.ts create mode 100644 src/app/shared/ui/toggle/toggle.component.html create mode 100644 src/app/shared/ui/toggle/toggle.component.scss create mode 100644 src/app/shared/ui/toggle/toggle.component.ts diff --git a/src/app/shared/ui/color-picker/color-picker.component.html b/src/app/shared/ui/color-picker/color-picker.component.html new file mode 100644 index 0000000..def8a71 --- /dev/null +++ b/src/app/shared/ui/color-picker/color-picker.component.html @@ -0,0 +1,22 @@ +
+ + +
diff --git a/src/app/shared/ui/color-picker/color-picker.component.scss b/src/app/shared/ui/color-picker/color-picker.component.scss new file mode 100644 index 0000000..be73ac7 --- /dev/null +++ b/src/app/shared/ui/color-picker/color-picker.component.scss @@ -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; + } +} diff --git a/src/app/shared/ui/color-picker/color-picker.component.ts b/src/app/shared/ui/color-picker/color-picker.component.ts new file mode 100644 index 0000000..ae9920c --- /dev/null +++ b/src/app/shared/ui/color-picker/color-picker.component.ts @@ -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(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(); + } +} diff --git a/src/app/shared/ui/key-value-editor/key-value-editor.component.html b/src/app/shared/ui/key-value-editor/key-value-editor.component.html new file mode 100644 index 0000000..aae721a --- /dev/null +++ b/src/app/shared/ui/key-value-editor/key-value-editor.component.html @@ -0,0 +1,11 @@ +
+ @for (row of rows(); track $index) { +
+
+ +
+ {{ removeLabel() }} +
+ } + {{ addLabel() }} +
diff --git a/src/app/shared/ui/key-value-editor/key-value-editor.component.scss b/src/app/shared/ui/key-value-editor/key-value-editor.component.scss new file mode 100644 index 0000000..7f5bcc6 --- /dev/null +++ b/src/app/shared/ui/key-value-editor/key-value-editor.component.scss @@ -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; +} diff --git a/src/app/shared/ui/key-value-editor/key-value-editor.component.ts b/src/app/shared/ui/key-value-editor/key-value-editor.component.ts new file mode 100644 index 0000000..aef0e26 --- /dev/null +++ b/src/app/shared/ui/key-value-editor/key-value-editor.component.ts @@ -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 { + $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 { + readonly rows = input.required(); + readonly addLabel = input('Add'); + readonly removeLabel = input('Remove'); + readonly createRow = input.required<() => T>(); + + readonly rowsChange = output(); + + @ContentChild(TemplateRef) rowTemplate: TemplateRef> | 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)); + } +} diff --git a/src/app/shared/ui/locale-tabs/locale-tabs.component.html b/src/app/shared/ui/locale-tabs/locale-tabs.component.html new file mode 100644 index 0000000..190ba77 --- /dev/null +++ b/src/app/shared/ui/locale-tabs/locale-tabs.component.html @@ -0,0 +1,14 @@ +
+ @for (locale of locales(); track locale) { + + } +
diff --git a/src/app/shared/ui/locale-tabs/locale-tabs.component.scss b/src/app/shared/ui/locale-tabs/locale-tabs.component.scss new file mode 100644 index 0000000..2fd9289 --- /dev/null +++ b/src/app/shared/ui/locale-tabs/locale-tabs.component.scss @@ -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; + } +} diff --git a/src/app/shared/ui/locale-tabs/locale-tabs.component.ts b/src/app/shared/ui/locale-tabs/locale-tabs.component.ts new file mode 100644 index 0000000..3fa7592 --- /dev/null +++ b/src/app/shared/ui/locale-tabs/locale-tabs.component.ts @@ -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(); + readonly defaultLocale = input(null); + readonly activeLocale = output(); + + private readonly selected = signal(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); + } +} diff --git a/src/app/shared/ui/section-card/section-card.component.html b/src/app/shared/ui/section-card/section-card.component.html new file mode 100644 index 0000000..fd0b086 --- /dev/null +++ b/src/app/shared/ui/section-card/section-card.component.html @@ -0,0 +1,14 @@ +
+ @if (title(); as title) { +
+

{{ title }}

+ @if (description(); as description) { +

{{ description }}

+ } + +
+ } +
+ +
+
diff --git a/src/app/shared/ui/section-card/section-card.component.scss b/src/app/shared/ui/section-card/section-card.component.scss new file mode 100644 index 0000000..ff5cd93 --- /dev/null +++ b/src/app/shared/ui/section-card/section-card.component.scss @@ -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; +} diff --git a/src/app/shared/ui/section-card/section-card.component.ts b/src/app/shared/ui/section-card/section-card.component.ts new file mode 100644 index 0000000..311f676 --- /dev/null +++ b/src/app/shared/ui/section-card/section-card.component.ts @@ -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(null); + readonly description = input(null); +} diff --git a/src/app/shared/ui/select/select.component.html b/src/app/shared/ui/select/select.component.html new file mode 100644 index 0000000..c87e1b0 --- /dev/null +++ b/src/app/shared/ui/select/select.component.html @@ -0,0 +1,20 @@ + +@if (activeDescription(); as description) { + {{ description }} +} diff --git a/src/app/shared/ui/select/select.component.scss b/src/app/shared/ui/select/select.component.scss new file mode 100644 index 0000000..451d9c2 --- /dev/null +++ b/src/app/shared/ui/select/select.component.scss @@ -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; + } +} diff --git a/src/app/shared/ui/select/select.component.ts b/src/app/shared/ui/select/select.component.ts new file mode 100644 index 0000000..672bff0 --- /dev/null +++ b/src/app/shared/ui/select/select.component.ts @@ -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(); + readonly size = input('md'); + readonly disabled = input(false); + readonly fullWidth = input(true); + readonly ariaLabel = input(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; + } +} diff --git a/src/app/shared/ui/toggle/toggle.component.html b/src/app/shared/ui/toggle/toggle.component.html new file mode 100644 index 0000000..d9c67a4 --- /dev/null +++ b/src/app/shared/ui/toggle/toggle.component.html @@ -0,0 +1,15 @@ + diff --git a/src/app/shared/ui/toggle/toggle.component.scss b/src/app/shared/ui/toggle/toggle.component.scss new file mode 100644 index 0000000..77042b3 --- /dev/null +++ b/src/app/shared/ui/toggle/toggle.component.scss @@ -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; + } +} diff --git a/src/app/shared/ui/toggle/toggle.component.ts b/src/app/shared/ui/toggle/toggle.component.ts new file mode 100644 index 0000000..a626852 --- /dev/null +++ b/src/app/shared/ui/toggle/toggle.component.ts @@ -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('md'); + readonly disabled = input(false); + readonly ariaLabel = input(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(); + } + } +}