diff --git a/docs/components-api/toggleswitch.md b/docs/components-api/toggleswitch.md index e95cd40c..ac0e347a 100644 --- a/docs/components-api/toggleswitch.md +++ b/docs/components-api/toggleswitch.md @@ -2,6 +2,8 @@ # ExtraToggleSwitch +> ✅ **Реализован**: `ExtraToggleSwitchComponent` (`@cdek-it/angular-ui-kit`) соответствует спецификации. + | Свойство | Описание | Типизация | | ---------------- | --------------------------- | ------------------ | | `label` | текст названия | `string` | @@ -13,6 +15,8 @@ | Событие | Описание | Типизация | | ----------- | ----------------------------- | -------------------------------------------------- | | `change` | срабатывает при переключении | `(event: ExtraToggleSwitchChangeEvent) => void` | +| `focus` | срабатывает при получении фокуса | `(event: Event) => void` | +| `blur` | срабатывает при потере фокуса | `(event: Event) => void` | # ExtraToggleSwitchChangeEvent diff --git a/src/lib/components/toggleswitch/toggleswitch.component.ts b/src/lib/components/toggleswitch/toggleswitch.component.ts index 55e0cc60..d5d7e537 100644 --- a/src/lib/components/toggleswitch/toggleswitch.component.ts +++ b/src/lib/components/toggleswitch/toggleswitch.component.ts @@ -1,57 +1,122 @@ -import { Component, EventEmitter, Optional, Output, Self } from '@angular/core'; -import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms'; -import { ToggleSwitch } from 'primeng/toggleswitch'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + forwardRef, + inject, + Injector, + Input, + OnDestroy, + OnInit, + Output +} from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; +import { NgTemplateOutlet } from '@angular/common'; +import { ToggleSwitch, ToggleSwitchChangeEvent } from 'primeng/toggleswitch'; +import { Subscription } from 'rxjs'; + +export type ExtraToggleSwitchLabelPosition = 'right' | 'left'; + +export interface ExtraToggleSwitchChangeEvent { + checked: boolean; + originalEvent: Event; +} + +let nextInputId = 0; @Component({ selector: 'extra-toggleswitch', standalone: true, - imports: [ToggleSwitch, FormsModule], + imports: [ToggleSwitch, FormsModule, NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { style: 'display: contents' }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ExtraToggleSwitchComponent), + multi: true + } + ], template: ` - + @if (label || caption) { +
+ +
+ @if (label) { + + } + @if (caption) { +
{{ caption }}
+ } +
+
+ } @else { + + } + + + + ` }) -export class ExtraToggleSwitchComponent implements ControlValueAccessor { - @Output() onChange = new EventEmitter(); +export class ExtraToggleSwitchComponent implements ControlValueAccessor, OnInit, OnDestroy { + private readonly _injector = inject(Injector); + private readonly _cdr = inject(ChangeDetectorRef); + private _ngControl: NgControl | null = null; + private _statusSub?: Subscription; + + @Input() label = ''; + @Input() labelPosition: ExtraToggleSwitchLabelPosition = 'right'; + @Input() caption = ''; + + @Output() onChange = new EventEmitter(); @Output() onFocus = new EventEmitter(); @Output() onBlur = new EventEmitter(); + /** Уникальный id поля для связи label ↔ input. */ + readonly inputId = `extra-toggleswitch-${nextInputId++}`; + + disabled = false; modelValue = false; - private _disabled = false; + get invalid(): boolean { + return this._ngControl?.invalid ?? false; + } private _onChange: (value: boolean) => void = () => {}; private _onTouched: () => void = () => {}; - constructor(@Optional() @Self() private ngControl: NgControl) { - if (ngControl) { - ngControl.valueAccessor = this; - } - } - - get isDisabled(): boolean { - return this._disabled; + ngOnInit(): void { + this._ngControl = this._injector.get(NgControl, null, { self: true, optional: true }); + this._statusSub = this._ngControl?.statusChanges?.subscribe(() => this._cdr.markForCheck()); } - get isInvalid(): boolean { - return !!this.ngControl?.invalid; + ngOnDestroy(): void { + this._statusSub?.unsubscribe(); } - handleChange(value: boolean): void { - this.modelValue = value; - this._onChange(value); + onChangeHandler(event: ToggleSwitchChangeEvent): void { + const checked = !!event.checked; + this.modelValue = checked; + this._onChange(checked); this._onTouched(); + this.onChange.emit({ checked, originalEvent: event.originalEvent as Event }); } writeValue(value: boolean): void { - this.modelValue = value ?? false; + this.modelValue = !!value; + this._cdr.markForCheck(); } registerOnChange(fn: (value: boolean) => void): void { @@ -63,6 +128,7 @@ export class ExtraToggleSwitchComponent implements ControlValueAccessor { } setDisabledState(isDisabled: boolean): void { - this._disabled = isDisabled; + this.disabled = isDisabled; + this._cdr.markForCheck(); } } diff --git a/src/lib/components/toggleswitch/toggleswitch.figma.md b/src/lib/components/toggleswitch/toggleswitch.figma.md index 9d1c1749..c372ab44 100644 --- a/src/lib/components/toggleswitch/toggleswitch.figma.md +++ b/src/lib/components/toggleswitch/toggleswitch.figma.md @@ -15,20 +15,24 @@ updated: '2026-06-22' ## Overview -`ExtraToggleSwitch` — переключатель-свитч (`role=switch`) для мгновенного включения/выключения одной бинарной настройки: эффект применяется сразу, без отдельного сохранения. Оборачивает PrimeNG `p-toggleswitch` и реализует `ControlValueAccessor`, поэтому интегрируется с `[(ngModel)]` и реактивными формами через `formControl` / `formControlName`. +`ExtraToggleSwitch` — переключатель-свитч (`role=switch`) для мгновенного включения/выключения одной бинарной настройки: эффект применяется сразу, без отдельного сохранения. Оборачивает PrimeNG `p-toggleswitch`, сам рисует `label`/`caption` рядом с собой (как `ExtraCheckbox`) и реализует `ControlValueAccessor`, поэтому интегрируется с `[(ngModel)]` и реактивными формами через `formControl` / `formControlName`. Компонент соответствует Figma-компоненту `` (nodeId `19:13673`). Figma-свойства `state` и `checked` маппируются на состояние компонента и модель значения (см. раздел Props mapping). ## Props mapping -У компонента нет собственных `@Input()` для конфигурации внешнего вида: состояние задаётся моделью и формой через `ControlValueAccessor`. Доступны три события вывода (`@Output()`): - | Свойство | Тип | По умолчанию | Описание | |----------|-----|--------------|---------| -| `onChange` | `EventEmitter` | — | Событие при изменении значения переключателя | +| `label` | `string` | `''` | Текст названия рядом с переключателем | +| `labelPosition` | `'right' \| 'left'` | `'right'` | Положение лейбла относительно свитча | +| `caption` | `string` | `''` | Пояснение под лейблом | +| `onChange` | `EventEmitter` | — | Событие при изменении значения переключателя | | `onFocus` | `EventEmitter` | — | Событие при получении фокуса | | `onBlur` | `EventEmitter` | — | Событие при потере фокуса | +Если ни `label`, ни `caption` не заданы — рендерится голый свитч без обёртки (тот же приём, что у +`ExtraCheckbox`/полей ввода, важно для `p-inputgroup` и плотных layout'ов). + Состояние «включён» (Figma-свойство `checked`) задаётся не отдельным инпутом, а моделью через `ControlValueAccessor`: используйте `[(ngModel)]`, `formControl` или `formControlName`. Значение `true` / `false` соответствует `checked=true` / `checked=false` в Figma. Отключённое состояние (Figma `state=disabled`) задаётся не отдельным инпутом, а через форму: атрибут `[disabled]` на `formControl` или `disabled`-состояние реактивной формы передаётся в компонент методом `setDisabledState` из `ControlValueAccessor`. @@ -53,6 +57,19 @@ Figma: state=default, checked=true ``` +### С лейблом и пояснением + +```html + + + + +``` + В реактивных формах эквивалентно: ```html @@ -81,7 +98,59 @@ Figma: state=danger, checked=false ## Slots -Не используются. Подпись к переключателю размещается рядом во внешнем шаблоне. +Не используются. `label`/`caption` — не слоты, а обычные `@Input()` (см. Props mapping); компонент +сам рисует обёртку по тому же принципу, что `ExtraCheckbox`/`ExtraInputText` (см. +`docs/components-api/common-info.md` и чеклист в корневом `CLAUDE.md`, раздел «Label / caption / +info»). + +⚠️ Для классов `.extra-toggleswitch`/`.toggleswitch-label`/`.toggleswitch-caption` в +`tokens/components/toggleswitch.ts` пока нет стилей (там только focus-ring для `.p-toggleswitch`) — +это зона дизайнера, агент её не трогает. Сниппет-заготовка 1:1 с уже рабочим `checkbox.ts` +(`.extra-checkbox`/`.checkbox-label`/`.checkbox-caption`), для дизайнера: + +```ts +.extra-toggleswitch { + display: flex; + align-items: center; + gap: ${dt('dimension.space.200')}; +} + +.extra-toggleswitch--left { + flex-direction: row-reverse; + justify-content: flex-end; +} + +.toggleswitch-label { + display: flex; + align-items: center; + color: ${dt('color.fg.default')}; + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.300')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: ${dt('fonts.lineHeight.300')}; + cursor: pointer; +} + +.toggleswitch-label--disabled { + color: ${dt('color.fg.muted')}; + cursor: default; +} + +.toggleswitch-caption { + color: ${dt('color.fg.subtle')}; + font-family: ${dt('fonts.fontFamily.heading')}; + font-size: ${dt('fonts.fontSize.200')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: ${dt('fonts.lineHeight.250')}; +} + +.toggleswitch-caption--disabled { + color: ${dt('color.fg.muted')}; +} +``` + +До добавления этого CSS лейбл/caption рендерятся функционально верно, но без типографики токенов +(голый браузерный шрифт). ## Related @@ -95,7 +164,7 @@ Figma: state=danger, checked=false - Используйте `[(ngModel)]` или `formControl` для управления состоянием вкл/выкл - Применяйте свитч для мгновенного эффекта (тёмная тема, уведомления), без отдельной кнопки Save - Для отключения и невалидного состояния управляйте состоянием формы (`disabled` / валидаторы `formControl`) -- Размещайте подпись рядом с переключателем во внешнем шаблоне для доступности +- Задавайте подпись через `label`/`caption` — так `