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:
22
src/app/shared/ui/color-picker/color-picker.component.html
Normal file
22
src/app/shared/ui/color-picker/color-picker.component.html
Normal 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>
|
||||||
59
src/app/shared/ui/color-picker/color-picker.component.scss
Normal file
59
src/app/shared/ui/color-picker/color-picker.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/shared/ui/color-picker/color-picker.component.ts
Normal file
74
src/app/shared/ui/color-picker/color-picker.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/shared/ui/locale-tabs/locale-tabs.component.html
Normal file
14
src/app/shared/ui/locale-tabs/locale-tabs.component.html
Normal 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>
|
||||||
43
src/app/shared/ui/locale-tabs/locale-tabs.component.scss
Normal file
43
src/app/shared/ui/locale-tabs/locale-tabs.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/shared/ui/locale-tabs/locale-tabs.component.ts
Normal file
27
src/app/shared/ui/locale-tabs/locale-tabs.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/shared/ui/section-card/section-card.component.html
Normal file
14
src/app/shared/ui/section-card/section-card.component.html
Normal 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>
|
||||||
34
src/app/shared/ui/section-card/section-card.component.scss
Normal file
34
src/app/shared/ui/section-card/section-card.component.scss
Normal 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;
|
||||||
|
}
|
||||||
13
src/app/shared/ui/section-card/section-card.component.ts
Normal file
13
src/app/shared/ui/section-card/section-card.component.ts
Normal 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);
|
||||||
|
}
|
||||||
20
src/app/shared/ui/select/select.component.html
Normal file
20
src/app/shared/ui/select/select.component.html
Normal 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>
|
||||||
|
}
|
||||||
68
src/app/shared/ui/select/select.component.scss
Normal file
68
src/app/shared/ui/select/select.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/shared/ui/select/select.component.ts
Normal file
74
src/app/shared/ui/select/select.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/shared/ui/toggle/toggle.component.html
Normal file
15
src/app/shared/ui/toggle/toggle.component.html
Normal 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>
|
||||||
72
src/app/shared/ui/toggle/toggle.component.scss
Normal file
72
src/app/shared/ui/toggle/toggle.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/shared/ui/toggle/toggle.component.ts
Normal file
60
src/app/shared/ui/toggle/toggle.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user