diff --git a/docs/components-api/radiobutton.md b/docs/components-api/radiobutton.md index d5ae7740..7592b9b3 100644 --- a/docs/components-api/radiobutton.md +++ b/docs/components-api/radiobutton.md @@ -2,18 +2,22 @@ # ExtraRadioButton -| Свойство | Описание | Типизация | -| ---------------- | --------------------------------- | ------------------ | -| `label` | текст названия | `string` | -| `label-position` | положение лейбла | `default \| left` | -| `caption` | текст пояснения под лейблом | `string` | -| `value` | значение опции | `any` | +> ✅ **Реализован**: `ExtraRadiobuttonComponent` (`@cdek-it/angular-ui-kit`) соответствует спецификации. + +| Свойство | Описание | Типизация | По умолчанию | +| ---------------- | --------------------------------- | ------------------ | ------------ | +| `label` | текст названия | `string` | `''` | +| `label-position` | положение лейбла | `right \| left` | `right` | +| `caption` | текст пояснения под лейблом | `string` | `''` | +| `value` | значение опции | `any` | `null` | # События | Событие | Описание | Типизация | | ---------- | ----------------------------- | ---------------------------------------------- | | `onClick` | срабатывает при выборе опции | `(event: ExtraRadioButtonClickEvent) => void` | +| `onFocus` | срабатывает при получении фокуса | `(event: Event) => void` | +| `onBlur` | срабатывает при потере фокуса | `(event: Event) => void` | # ExtraRadioButtonClickEvent diff --git a/src/lib/components/radiobutton/radiobutton.component.ts b/src/lib/components/radiobutton/radiobutton.component.ts index c0f5062a..941e14c0 100644 --- a/src/lib/components/radiobutton/radiobutton.component.ts +++ b/src/lib/components/radiobutton/radiobutton.component.ts @@ -1,16 +1,36 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, forwardRef, Input, Output } from '@angular/core'; -import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; +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 { RadioButton, RadioButtonClickEvent } from 'primeng/radiobutton'; +import { Subscription } from 'rxjs'; -export type ExtraRadiobuttonVariant = 'outlined' | 'filled'; -export type ExtraRadiobuttonSize = 'small' | 'base' | 'large'; -export type ExtraRadioButtonClickEvent = RadioButtonClickEvent; +export type ExtraRadiobuttonLabelPosition = 'right' | 'left'; + +export interface ExtraRadioButtonClickEvent { + value: any; + originalEvent: Event; +} + +let nextInputId = 0; @Component({ selector: 'extra-radiobutton', standalone: true, + imports: [RadioButton, FormsModule, NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RadioButton, FormsModule], + host: { style: 'display: contents' }, providers: [ { provide: NG_VALUE_ACCESSOR, @@ -19,65 +39,103 @@ export type ExtraRadioButtonClickEvent = RadioButtonClickEvent; } ], template: ` - + @if (label || caption) { +
+ +
+ @if (label) { + + } + @if (caption) { +
{{ caption }}
+ } +
+
+ } @else { + + } + + + + ` }) -export class ExtraRadiobuttonComponent implements ControlValueAccessor { +export class ExtraRadiobuttonComponent implements ControlValueAccessor, OnInit, OnDestroy { + private readonly _injector = inject(Injector); + private readonly _cdr = inject(ChangeDetectorRef); + private _ngControl: NgControl | null = null; + private _statusSub?: Subscription; + private _valueSub?: Subscription; + + ngOnInit(): void { + this._ngControl = this._injector.get(NgControl, null, { self: true, optional: true }); + /** + * invalid — геттер поверх NgControl.invalid; на OnPush не пересчитывается сам по себе, + * когда валидность меняется извне (Validators/updateValueAndValidity без локального события). + */ + this._statusSub = this._ngControl?.statusChanges?.subscribe(() => this._cdr.markForCheck()); + /** + * Несколько radiobutton могут сидеть на ОДНОМ общем FormControl (радио-группа). Angular Forms + * при смене значения "изнутри" (клик по одной из них) вызывает control.setValue(value, + * { emitModelToViewChange: false }) — этот флаг намеренно НЕ вызывает writeValue() у соседних + * директив, привязанных к тому же control (см. updateControl() в @angular/forms). Из-за этого + * соседние radiobutton в группе никогда не узнавали, что должны снять выбор. valueChanges + * эмитится всегда, независимо от этого флага — используем его для синхронизации между соседями. + */ + this._valueSub = this._ngControl?.valueChanges?.subscribe((value) => { + this.modelValue = value; + this._cdr.markForCheck(); + }); + } + + ngOnDestroy(): void { + this._statusSub?.unsubscribe(); + this._valueSub?.unsubscribe(); + } + + @Input() label = ''; + @Input() labelPosition: ExtraRadiobuttonLabelPosition = 'right'; + @Input() caption = ''; @Input() value: any = null; + /** Имя группы; форм-обвязка, вне спеки — нужна для нативной семантики радио-группы. */ @Input() name: string | undefined = undefined; - @Input() disabled = false; - @Input() invalid = false; - @Input() variant: ExtraRadiobuttonVariant = 'outlined'; - @Input() size: ExtraRadiobuttonSize = 'base'; - @Input() inputId: string | undefined = undefined; - @Input() tabindex: number | undefined = undefined; - @Input() ariaLabel: string | undefined = undefined; - @Input() ariaLabelledBy: string | undefined = undefined; - @Input() autofocus = false; @Output() onClick = new EventEmitter(); @Output() onFocus = new EventEmitter(); @Output() onBlur = new EventEmitter(); - modelValue: any = null; + /** Уникальный id поля для связи label ↔ input. */ + readonly inputId = `extra-radiobutton-${nextInputId++}`; - private _onChange: (value: any) => void = () => {}; - private _onTouched: () => void = () => {}; + disabled = false; + modelValue: any = null; - get primeSize(): 'small' | 'large' | undefined { - if (this.size === 'small') return 'small'; - if (this.size === 'large') return 'large'; - return undefined; + get invalid(): boolean { + return this._ngControl?.invalid ?? false; } - get primeVariant(): 'filled' | undefined { - return this.variant === 'filled' ? 'filled' : undefined; - } + private _onChange: (value: any) => void = () => {}; + private _onTouched: () => void = () => {}; - onClickHandler(event: ExtraRadioButtonClickEvent): void { + onClickHandler(event: RadioButtonClickEvent): void { this._onChange(event.value); this._onTouched(); - this.onClick.emit(event); + this.onClick.emit({ value: event.value, originalEvent: event.originalEvent as Event }); } writeValue(value: any): void { this.modelValue = value; + this._cdr.markForCheck(); } registerOnChange(fn: (value: any) => void): void { @@ -90,5 +148,6 @@ export class ExtraRadiobuttonComponent implements ControlValueAccessor { setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; + this._cdr.markForCheck(); } } diff --git a/src/lib/components/radiobutton/radiobutton.figma.md b/src/lib/components/radiobutton/radiobutton.figma.md index 8c1d2e4c..1d2d49ab 100644 --- a/src/lib/components/radiobutton/radiobutton.figma.md +++ b/src/lib/components/radiobutton/radiobutton.figma.md @@ -10,32 +10,26 @@ figma: componentKey: 'b2f1d57bdcaefad98286b9316272c0b64bb268d8' name: '' status: stable -updated: '2026-06-22' +updated: '2026-09-04' --- ## Overview -`ExtraRadiobutton` — радиокнопка для выбора ровно одного варианта из взаимоисключающей группы. Внутри группы радиокнопки объединяются общим `name`, выбор одиночный, снять выбор нельзя. Оборачивает PrimeNG `p-radiobutton` и реализует `ControlValueAccessor`, поэтому работает с `[(ngModel)]` и реактивными формами (`formControl` / `formControlName`). +`ExtraRadiobutton` — радиокнопка для выбора ровно одного варианта из взаимоисключающей группы. Внутри группы радиокнопки объединяются общим `name` и одной моделью, выбор одиночный, снять выбор нельзя. Компонент сам рендерит подпись (`label`) и пояснение (`caption`) рядом с контролом — не требует внешней обёртки `