Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/components-api/toggleswitch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# ExtraToggleSwitch

> ✅ **Реализован**: `ExtraToggleSwitchComponent` (`@cdek-it/angular-ui-kit`) соответствует спецификации.

| Свойство | Описание | Типизация |
| ---------------- | --------------------------- | ------------------ |
| `label` | текст названия | `string` |
Expand All @@ -13,6 +15,8 @@
| Событие | Описание | Типизация |
| ----------- | ----------------------------- | -------------------------------------------------- |
| `change` | срабатывает при переключении | `(event: ExtraToggleSwitchChangeEvent) => void` |
| `focus` | срабатывает при получении фокуса | `(event: Event) => void` |
| `blur` | срабатывает при потере фокуса | `(event: Event) => void` |

# ExtraToggleSwitchChangeEvent

Expand Down
128 changes: 97 additions & 31 deletions src/lib/components/toggleswitch/toggleswitch.component.ts
Original file line number Diff line number Diff line change
@@ -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: `
<p-toggleswitch
[ngModel]="modelValue"
(ngModelChange)="handleChange($event)"
[invalid]="isInvalid"
[disabled]="isDisabled"
(onChange)="onChange.emit($event)"
(onFocus)="onFocus.emit($event)"
(onBlur)="onBlur.emit($event)"
></p-toggleswitch>
@if (label || caption) {
<div class="extra-toggleswitch" [class.extra-toggleswitch--left]="labelPosition === 'left'">
<ng-container [ngTemplateOutlet]="fieldTpl" />
<div class="extra-toggleswitch-body">
@if (label) {
<label class="toggleswitch-label" [class.toggleswitch-label--disabled]="disabled" [for]="inputId">{{
label
}}</label>
}
@if (caption) {
<div class="toggleswitch-caption" [class.toggleswitch-caption--disabled]="disabled">{{ caption }}</div>
}
</div>
</div>
} @else {
<ng-container [ngTemplateOutlet]="fieldTpl" />
}

<ng-template #fieldTpl>
<p-toggleswitch
[(ngModel)]="modelValue"
[disabled]="disabled"
[invalid]="invalid"
[inputId]="inputId"
(onChange)="onChangeHandler($event)"
(onFocus)="onFocus.emit($event)"
(onBlur)="onBlur.emit($event)"
></p-toggleswitch>
</ng-template>
`
})
export class ExtraToggleSwitchComponent implements ControlValueAccessor {
@Output() onChange = new EventEmitter<unknown>();
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<ExtraToggleSwitchChangeEvent>();
@Output() onFocus = new EventEmitter<Event>();
@Output() onBlur = new EventEmitter<Event>();

/** Уникальный 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 {
Expand All @@ -63,6 +128,7 @@ export class ExtraToggleSwitchComponent implements ControlValueAccessor {
}

setDisabledState(isDisabled: boolean): void {
this._disabled = isDisabled;
this.disabled = isDisabled;
this._cdr.markForCheck();
}
}
81 changes: 75 additions & 6 deletions src/lib/components/toggleswitch/toggleswitch.figma.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-компоненту `<ToggleSwitch>` (nodeId `19:13673`). Figma-свойства `state` и `checked` маппируются на состояние компонента и модель значения (см. раздел Props mapping).

## Props mapping

У компонента нет собственных `@Input()` для конфигурации внешнего вида: состояние задаётся моделью и формой через `ControlValueAccessor`. Доступны три события вывода (`@Output()`):

| Свойство | Тип | По умолчанию | Описание |
|----------|-----|--------------|---------|
| `onChange` | `EventEmitter<unknown>` | — | Событие при изменении значения переключателя |
| `label` | `string` | `''` | Текст названия рядом с переключателем |
| `labelPosition` | `'right' \| 'left'` | `'right'` | Положение лейбла относительно свитча |
| `caption` | `string` | `''` | Пояснение под лейблом |
| `onChange` | `EventEmitter<ExtraToggleSwitchChangeEvent>` | — | Событие при изменении значения переключателя |
| `onFocus` | `EventEmitter<Event>` | — | Событие при получении фокуса |
| `onBlur` | `EventEmitter<Event>` | — | Событие при потере фокуса |

Если ни `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`.
Expand All @@ -53,6 +57,19 @@ Figma: state=default, checked=true
<extra-toggleswitch [(ngModel)]="isEnabled"></extra-toggleswitch>
```

### С лейблом и пояснением

```html
<extra-toggleswitch
[(ngModel)]="isEnabled"
label="Тёмная тема"
caption="Применяется сразу для всех устройств"
></extra-toggleswitch>

<!-- лейбл слева от свитча -->
<extra-toggleswitch [(ngModel)]="isEnabled" label="Тёмная тема" labelPosition="left"></extra-toggleswitch>
```

В реактивных формах эквивалентно:

```html
Expand Down Expand Up @@ -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

Expand All @@ -95,7 +164,7 @@ Figma: state=danger, checked=false
- Используйте `[(ngModel)]` или `formControl` для управления состоянием вкл/выкл
- Применяйте свитч для мгновенного эффекта (тёмная тема, уведомления), без отдельной кнопки Save
- Для отключения и невалидного состояния управляйте состоянием формы (`disabled` / валидаторы `formControl`)
- Размещайте подпись рядом с переключателем во внешнем шаблоне для доступности
- Задавайте подпись через `label`/`caption` — так `<label [for]>` связывается с полем автоматически

**Don't:**
- Не задавайте «включён» через отдельный атрибут — состояние идёт только через модель (`[(ngModel)]` / `formControl`)
Expand Down

This file was deleted.

This file was deleted.

Loading
Loading