diff --git a/src/lib/components/radiobutton/radiobutton.component.ts b/src/lib/components/radiobutton/radiobutton.component.ts new file mode 100644 index 00000000..e7d36073 --- /dev/null +++ b/src/lib/components/radiobutton/radiobutton.component.ts @@ -0,0 +1,92 @@ +import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'; +import { RadioButton, RadioButtonClickEvent } from 'primeng/radiobutton'; + +export type RadiobuttonVariant = 'outlined' | 'filled'; +export type RadiobuttonSize = 'small' | 'base' | 'large'; + +@Component({ + selector: 'radiobutton', + standalone: true, + imports: [RadioButton, FormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RadiobuttonComponent), + multi: true, + }, + ], + template: ` + + `, +}) +export class RadiobuttonComponent implements ControlValueAccessor { + @Input() value: any = null; + @Input() name: string | undefined = undefined; + @Input() disabled = false; + @Input() invalid = false; + @Input() variant: RadiobuttonVariant = 'outlined'; + @Input() size: RadiobuttonSize = '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; + + private _onChange: (value: any) => void = () => {}; + private _onTouched: () => void = () => {}; + + get primeSize(): 'small' | 'large' | undefined { + if (this.size === 'small') return 'small'; + if (this.size === 'large') return 'large'; + return undefined; + } + + get primeVariant(): 'filled' | undefined { + return this.variant === 'filled' ? 'filled' : undefined; + } + + onClickHandler(event: RadioButtonClickEvent): void { + this._onChange(event.value); + this._onTouched(); + this.onClick.emit(event); + } + + writeValue(value: any): void { + this.modelValue = value; + } + + registerOnChange(fn: (value: any) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts index 39627587..f7ec1c72 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -4,6 +4,7 @@ import type { AuraBaseDesignTokens } from '@primeuix/themes/aura/base'; import tokens from './tokens/tokens.json'; import { buttonCss } from './tokens/components/button'; +import { radiobuttonCss } from './tokens/components/radiobutton'; const presetTokens: Preset = { primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'], @@ -14,6 +15,10 @@ const presetTokens: Preset = { ...(tokens.components.button as unknown as ComponentsDesignTokens['button']), css: buttonCss, }, + radiobutton: { + ...(tokens.components.radiobutton as unknown as ComponentsDesignTokens['radiobutton']), + css: radiobuttonCss, + }, } as ComponentsDesignTokens, }; diff --git a/src/prime-preset/tokens/components/radiobutton.ts b/src/prime-preset/tokens/components/radiobutton.ts new file mode 100644 index 00000000..975dce6f --- /dev/null +++ b/src/prime-preset/tokens/components/radiobutton.ts @@ -0,0 +1,16 @@ +export const radiobuttonCss = ({ dt }: { dt: (token: string) => string }): string => ` +/* Focus ring с зеленым цветом для валидных состояний */ +.p-radiobutton:not(.p-disabled):not(.p-invalid):has(.p-radiobutton-input:focus-visible) .p-radiobutton-box, +.p-radiobutton-checked:not(.p-disabled):not(.p-invalid):has(.p-radiobutton-input:focus-visible) .p-radiobutton-box { + outline: none; + box-shadow: 0 0 0 ${dt('radiobutton.focusRing.width')} ${dt('focusRing.extend.success')}; +} + +/* Focus ring с красным цветом для состояний с ошибкой */ +.p-radiobutton.p-invalid .p-radiobutton-box, +.p-radiobutton.p-invalid:not(.p-disabled):has(.p-radiobutton-input:focus-visible) .p-radiobutton-box, +.p-radiobutton-checked.p-invalid .p-radiobutton-box, +.p-radiobutton-checked.p-invalid:not(.p-disabled):has(.p-radiobutton-input:focus-visible) .p-radiobutton-box { + box-shadow: 0 0 0 ${dt('radiobutton.focusRing.width')} ${dt('focusRing.extend.invalid')}; +} +`; diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index f9ea56a8..4b767ce7 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -4071,7 +4071,7 @@ "height": "{form.size.350}" }, "icon": { - "size": "{form.icon.200}", + "size": "0.7rem", "checkedColor": "{text.extend.colorInverted}", "checkedHoverColor": "{text.extend.colorInverted}", "disabledColor": "{text.mutedColor}", diff --git a/src/stories/components/radiobutton/examples/radiobutton-disabled.component.ts b/src/stories/components/radiobutton/examples/radiobutton-disabled.component.ts new file mode 100644 index 00000000..497053dd --- /dev/null +++ b/src/stories/components/radiobutton/examples/radiobutton-disabled.component.ts @@ -0,0 +1,59 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { RadiobuttonComponent } from '../../../../lib/components/radiobutton/radiobutton.component'; + +const template = ` +
+
+ + +
+
+ + +
+
+`; + +@Component({ + selector: 'app-radiobutton-disabled', + standalone: true, + imports: [RadiobuttonComponent, FormsModule], + template, +}) +export class RadiobuttonDisabledComponent { + selected = '2'; +} + +export const Disabled: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Заблокированное состояние радиокнопки.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { RadiobuttonComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-radiobutton-disabled', + standalone: true, + imports: [RadiobuttonComponent, FormsModule], + template: \` + + + \`, +}) +export class RadiobuttonDisabledComponent { + selected = '2'; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/radiobutton/examples/radiobutton-group.component.ts b/src/stories/components/radiobutton/examples/radiobutton-group.component.ts new file mode 100644 index 00000000..8bd36b0e --- /dev/null +++ b/src/stories/components/radiobutton/examples/radiobutton-group.component.ts @@ -0,0 +1,67 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { RadiobuttonComponent } from '../../../../lib/components/radiobutton/radiobutton.component'; + +const template = ` +
+
+ + +
+
+ + +
+
+ + +
+
+`; + +@Component({ + selector: 'app-radiobutton-group', + standalone: true, + imports: [RadiobuttonComponent, FormsModule], + template, +}) +export class RadiobuttonGroupComponent { + selected = '1'; +} + +export const Group: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Группа радиокнопок для выбора одного варианта из нескольких.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { RadiobuttonComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-radiobutton-group', + standalone: true, + imports: [RadiobuttonComponent, FormsModule], + template: \` + + + + + + + \`, +}) +export class RadiobuttonGroupComponent { + selected = '1'; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/radiobutton/examples/radiobutton-invalid.component.ts b/src/stories/components/radiobutton/examples/radiobutton-invalid.component.ts new file mode 100644 index 00000000..430a3b93 --- /dev/null +++ b/src/stories/components/radiobutton/examples/radiobutton-invalid.component.ts @@ -0,0 +1,59 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { RadiobuttonComponent } from '../../../../lib/components/radiobutton/radiobutton.component'; + +const template = ` +
+
+ + +
+
+ + +
+
+`; + +@Component({ + selector: 'app-radiobutton-invalid', + standalone: true, + imports: [RadiobuttonComponent, FormsModule], + template, +}) +export class RadiobuttonInvalidComponent { + selected = '2'; +} + +export const Invalid: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Невалидное состояние радиокнопки.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { RadiobuttonComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-radiobutton-invalid', + standalone: true, + imports: [RadiobuttonComponent, FormsModule], + template: \` + + + \`, +}) +export class RadiobuttonInvalidComponent { + selected = '2'; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/radiobutton/radiobutton.stories.ts b/src/stories/components/radiobutton/radiobutton.stories.ts new file mode 100644 index 00000000..760cd599 --- /dev/null +++ b/src/stories/components/radiobutton/radiobutton.stories.ts @@ -0,0 +1,122 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormsModule } from '@angular/forms'; +import { RadiobuttonComponent } from '../../../lib/components/radiobutton/radiobutton.component'; +import { RadiobuttonGroupComponent, Group } from './examples/radiobutton-group.component'; +import { RadiobuttonInvalidComponent, Invalid } from './examples/radiobutton-invalid.component'; +import { RadiobuttonDisabledComponent, Disabled } from './examples/radiobutton-disabled.component'; + +type RadiobuttonArgs = RadiobuttonComponent; + +const meta: Meta = { + title: 'Components/Form/RadioButton', + component: RadiobuttonComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + RadiobuttonComponent, + FormsModule, + RadiobuttonGroupComponent, + RadiobuttonInvalidComponent, + RadiobuttonDisabledComponent, + ] + }) + ], + parameters: { + designTokens: { prefix: '--p-radiobutton' }, + docs: { + description: { + component: `Компонент для выбора одного варианта из группы.`, + }, + }, + }, + argTypes: { + // ── Props ──────────────────────────────────────────────── + disabled: { + control: 'boolean', + description: 'Отключает возможность взаимодействия', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + invalid: { + control: 'boolean', + description: 'Подсвечивает поле как невалидное', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + variant: { table: { disable: true } }, + // Hidden props + value: { table: { disable: true } }, + name: { table: { disable: true } }, + size: { table: { disable: true } }, + inputId: { table: { disable: true } }, + tabindex: { table: { disable: true } }, + ariaLabel: { table: { disable: true } }, + ariaLabelledBy: { table: { disable: true } }, + autofocus: { table: { disable: true } }, + + // ── Events ─────────────────────────────────────────────── + onClick: { + control: false, + description: 'Событие выбора радиокнопки', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + onFocus: { + control: false, + description: 'Событие фокуса', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + onBlur: { + control: false, + description: 'Событие потери фокуса', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + }, + args: { + disabled: false, + invalid: false, + variant: 'outlined', + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Default ────────────────────────────────────────────────────────────────── +export const Default: Story = { + name: 'Default', + render: (args) => { + const parts: string[] = [`value="option1"`, `name="demo"`, `[(ngModel)]="selected"`]; + if (args.disabled) parts.push(`[disabled]="true"`); + if (args.invalid) parts.push(`[invalid]="true"`); + if (args.variant && args.variant !== 'outlined') parts.push(`variant="${args.variant}"`); + + const template = ``; + return { props: { ...args, selected: 'option1' }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { Group, Invalid, Disabled };