+
+
+
-
-
-
-
-
`;
@@ -24,45 +20,50 @@ const template = `
selector: 'app-radiobutton-group',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [ExtraRadiobuttonComponent, FormsModule],
- template,
+ imports: [ExtraRadiobuttonComponent, ReactiveFormsModule],
+ template
})
export class RadiobuttonGroupComponent {
- selected = '1';
+ delivery = new FormControl('pickup');
+ payment = new FormControl('card');
}
export const Group: StoryObj = {
render: () => ({
- template: `
`,
+ template: `
`
}),
parameters: {
+ controls: { disable: true },
docs: {
- description: { story: 'Группа радиокнопок для выбора одного варианта из нескольких.' },
+ description: {
+ story:
+ 'Две независимые группы радиокнопок на одной странице: у каждой группы свой `name` и своя модель (`[formControl]` или `[(ngModel)]`) — выбор в одной группе не влияет на другую.'
+ },
source: {
language: 'ts',
code: `
import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ExtraRadiobuttonComponent } from '@cdek-it/angular-ui-kit';
@Component({
selector: 'app-radiobutton-group',
standalone: true,
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [ExtraRadiobuttonComponent, FormsModule],
+ imports: [ExtraRadiobuttonComponent, ReactiveFormsModule],
template: \`
-
-
-
-
-
-
+
+
+
+
+
\`,
})
export class RadiobuttonGroupComponent {
- selected = '1';
+ delivery = new FormControl('pickup');
+ payment = new FormControl('card');
}
- `,
- },
- },
- },
+ `
+ }
+ }
+ }
};
diff --git a/src/stories/components/radiobutton/examples/radiobutton-invalid.component.ts b/src/stories/components/radiobutton/examples/radiobutton-invalid.component.ts
index f9e15103..21b487e7 100644
--- a/src/stories/components/radiobutton/examples/radiobutton-invalid.component.ts
+++ b/src/stories/components/radiobutton/examples/radiobutton-invalid.component.ts
@@ -1,18 +1,15 @@
-import { Component, ChangeDetectionStrategy} from '@angular/core';
-import { FormsModule } from '@angular/forms';
+import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { FormControl, ReactiveFormsModule, ValidatorFn } from '@angular/forms';
import { StoryObj } from '@storybook/angular';
import { ExtraRadiobuttonComponent } from '../../../../lib/components/radiobutton/radiobutton.component';
+/** Форсированная ошибка для демонстрации invalid-стиля независимо от того, выбран пункт или нет. */
+const alwaysInvalid: ValidatorFn = () => ({ invalid: true });
+
const template = `
-
-
-
-
-
-
-
-
+
+
`;
@@ -20,41 +17,82 @@ const template = `
selector: 'app-radiobutton-invalid',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
- template,
- imports: [ExtraRadiobuttonComponent, FormsModule],
+ imports: [ExtraRadiobuttonComponent, ReactiveFormsModule],
+ template
})
-export class RadiobuttonInvalidComponent {
- selected = '2';
+export class RadiobuttonInvalidComponent implements OnChanges {
+ @Input() label = 'Курьером';
+ @Input() invalid = true;
+
+ /** Один FormControl на всю группу; ошибка форсирована, чтобы invalid не зависел от выбора. */
+ control = new FormControl('pickup', this.invalid ? [alwaysInvalid] : []);
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['invalid']) {
+ this.control.setValidators(this.invalid ? [alwaysInvalid] : []);
+ this.control.updateValueAndValidity();
+ }
+ }
}
-export const Invalid: StoryObj = {
- render: () => ({
- template: `
`,
+export const Invalid: StoryObj<{ label: string; invalid: boolean }> = {
+ name: 'Invalid',
+ argTypes: {
+ label: {
+ control: 'text',
+ description: 'Текст названия',
+ table: { category: 'Свойства', type: { summary: 'string' } }
+ },
+ invalid: {
+ control: 'boolean',
+ description: 'Невалидное состояние — вычисляется из NgControl (Validators.required)',
+ table: { category: 'Состояния', type: { summary: 'boolean' } }
+ }
+ },
+ args: { label: 'Курьером', invalid: true },
+ render: (args) => ({
+ props: args,
+ template: `
`
}),
parameters: {
docs: {
- description: { story: 'Невалидное состояние радиокнопки.' },
+ description: {
+ story:
+ 'Невалидная группа — вычисляется из `NgControl` автоматически, переключите control «invalid» в панели Controls. Ошибка форсирована искусственным валидатором, поэтому не зависит от того, выбран пункт или нет — оба пункта на одном FormControl.'
+ },
source: {
language: 'ts',
code: `
-import { Component } from '@angular/core';
+import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { FormControl, ReactiveFormsModule, ValidatorFn } from '@angular/forms';
import { ExtraRadiobuttonComponent } from '@cdek-it/angular-ui-kit';
+const alwaysInvalid: ValidatorFn = () => ({ invalid: true });
+
@Component({
selector: 'app-radiobutton-invalid',
standalone: true,
- imports: [ExtraRadiobuttonComponent, FormsModule],
- changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ExtraRadiobuttonComponent, ReactiveFormsModule],
template: \`
-
-
+
+
\`,
})
-export class RadiobuttonInvalidComponent {
- selected = '2';
+export class RadiobuttonInvalidComponent implements OnChanges {
+ @Input() label = 'Курьером';
+ @Input() invalid = true;
+
+ control = new FormControl('pickup', this.invalid ? [alwaysInvalid] : []);
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['invalid']) {
+ this.control.setValidators(this.invalid ? [alwaysInvalid] : []);
+ this.control.updateValueAndValidity();
+ }
+ }
}
- `,
- },
- },
- },
+ `
+ }
+ }
+ }
};
diff --git a/src/stories/components/radiobutton/examples/radiobutton-labelposition.component.ts b/src/stories/components/radiobutton/examples/radiobutton-labelposition.component.ts
new file mode 100644
index 00000000..3c6c4648
--- /dev/null
+++ b/src/stories/components/radiobutton/examples/radiobutton-labelposition.component.ts
@@ -0,0 +1,57 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { ExtraRadiobuttonComponent } from '../../../../lib/components/radiobutton/radiobutton.component';
+
+const template = `
+
+
+
+
+`;
+
+@Component({
+ selector: 'app-radiobutton-labelposition',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ExtraRadiobuttonComponent, ReactiveFormsModule],
+ template
+})
+export class RadiobuttonLabelPositionComponent {
+ control = new FormControl('right');
+}
+
+export const LabelPosition: StoryObj = {
+ render: () => ({
+ template: `
`
+ }),
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story: 'Положения лейбла (`label-position`): `right` (по умолчанию) — справа от индикатора, `left` — слева.'
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { ExtraRadiobuttonComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ selector: 'app-radiobutton-labelposition',
+ standalone: true,
+ imports: [ExtraRadiobuttonComponent, ReactiveFormsModule],
+ template: \`
+
+
+ \`,
+})
+export class RadiobuttonLabelPositionComponent {
+ control = new FormControl('right');
+}
+ `
+ }
+ }
+ }
+};
diff --git a/src/stories/components/radiobutton/radiobutton.stories.ts b/src/stories/components/radiobutton/radiobutton.stories.ts
index c942f6b2..8a048fa1 100644
--- a/src/stories/components/radiobutton/radiobutton.stories.ts
+++ b/src/stories/components/radiobutton/radiobutton.stories.ts
@@ -1,24 +1,28 @@
-import { Meta, StoryObj, moduleMetadata } from '@storybook/angular';
-import { FormsModule } from '@angular/forms';
-import { ExtraRadiobuttonComponent as RadiobuttonComponent } from '../../../lib/components/radiobutton/radiobutton.component';
+import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+import { ExtraRadiobuttonComponent } 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';
+import { RadiobuttonInvalidComponent, Invalid } from './examples/radiobutton-invalid.component';
+import { RadiobuttonLabelPositionComponent, LabelPosition } from './examples/radiobutton-labelposition.component';
+import { RadiobuttonCaptionComponent, Caption } from './examples/radiobutton-caption.component';
-type RadiobuttonArgs = RadiobuttonComponent;
+type RadiobuttonArgs = ExtraRadiobuttonComponent & { disabled: boolean; invalid: boolean };
const meta: Meta
= {
title: 'Components/Form/RadioButton',
- component: RadiobuttonComponent,
+ component: ExtraRadiobuttonComponent,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
- RadiobuttonComponent,
- FormsModule,
+ ExtraRadiobuttonComponent,
+ ReactiveFormsModule,
RadiobuttonGroupComponent,
- RadiobuttonInvalidComponent,
RadiobuttonDisabledComponent,
+ RadiobuttonInvalidComponent,
+ RadiobuttonLabelPositionComponent,
+ RadiobuttonCaptionComponent
]
})
],
@@ -26,97 +30,141 @@ const meta: Meta = {
designTokens: { prefix: '--p-radiobutton' },
docs: {
description: {
- component: `Компонент для выбора одного варианта из группы.`,
- },
- },
+ component: `Радиокнопка для выбора одного варианта из взаимоисключающей группы.
+
+Реализовано по спецификации [radiobutton.md](https://github.com/cdek-it/angular-ui-kit/blob/main/docs/components-api/radiobutton.md).
+
+\`\`\`typescript
+import { ExtraRadiobuttonComponent } from '@cdek-it/angular-ui-kit';
+\`\`\`
+
+Значение подключается через \`[(ngModel)]\` или \`[formControl]\` (ControlValueAccessor). Внутри группы радиокнопки объединяются общим \`name\` и одной моделью. Состояния disabled и invalid управляются через FormControl.`
+ }
+ }
},
argTypes: {
- // ── Props ────────────────────────────────────────────────
+ // ── Свойства (docs/components-api/radiobutton.md) ──────────────
+ label: {
+ control: 'text',
+ description: 'Текст названия',
+ table: {
+ category: 'Свойства',
+ defaultValue: { summary: "''" },
+ type: { summary: 'string' }
+ }
+ },
+ labelPosition: {
+ control: 'select',
+ options: ['right', 'left'],
+ description: 'Положение лейбла',
+ table: {
+ category: 'Свойства',
+ defaultValue: { summary: 'right' },
+ type: { summary: "'right' | 'left'" }
+ }
+ },
+ caption: {
+ control: 'text',
+ description: 'Текст пояснения под лейблом',
+ table: {
+ category: 'Свойства',
+ defaultValue: { summary: "''" },
+ type: { summary: 'string' }
+ }
+ },
+ // ── Состояния (управляются через FormControl) ───────────────────
disabled: {
control: 'boolean',
- description: 'Отключает возможность взаимодействия',
+ description: 'Отключённое состояние — управляется через FormControl',
table: {
- category: 'Props',
+ category: 'Состояния',
defaultValue: { summary: 'false' },
- type: { summary: 'boolean' },
- },
+ type: { summary: 'boolean' }
+ }
},
invalid: {
control: 'boolean',
- description: 'Подсвечивает поле как невалидное',
+ description: 'Невалидное состояние — вычисляется из NgControl (Validators)',
table: {
- category: 'Props',
+ category: 'Состояния',
defaultValue: { summary: 'false' },
- type: { summary: 'boolean' },
- },
+ 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: 'Событие выбора радиокнопки',
+ description: 'Срабатывает при выборе опции',
table: {
- category: 'Events',
- type: { summary: 'EventEmitter' },
- },
+ category: 'События',
+ type: { summary: 'EventEmitter' }
+ }
},
onFocus: {
control: false,
- description: 'Событие фокуса',
+ description: 'Срабатывает при получении фокуса',
table: {
- category: 'Events',
- type: { summary: 'EventEmitter' },
- },
+ category: 'События',
+ type: { summary: 'EventEmitter' }
+ }
},
onBlur: {
control: false,
- description: 'Событие потери фокуса',
+ description: 'Срабатывает при потере фокуса',
table: {
- category: 'Events',
- type: { summary: 'EventEmitter' },
- },
+ category: 'События',
+ type: { summary: 'EventEmitter' }
+ }
},
+ // Hidden props
+ value: { table: { disable: true } },
+ name: { table: { disable: true } },
+ modelValue: { table: { disable: true } },
+ inputId: { table: { disable: true } }
},
args: {
+ label: 'Radio button',
+ labelPosition: 'right',
+ caption: '',
disabled: false,
- invalid: false,
- variant: 'outlined',
- },
+ invalid: false
+ }
};
export default meta;
type Story = StoryObj;
-// ── Default ──────────────────────────────────────────────────────────────────
+// ── 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 parts: string[] = [`name="delivery-default"`, `value="option1"`];
+
+ if (args.label) parts.push(`label="${args.label}"`);
+ if (args.labelPosition && args.labelPosition !== 'right') parts.push(`labelPosition="${args.labelPosition}"`);
+ if (args.caption) parts.push(`caption="${args.caption}"`);
+
+ const validators = args.invalid ? [Validators.required] : [];
+ // Один FormControl на оба пункта — взаимоисключающий выбор, как у настоящей radio-группы.
+ const control = new FormControl({ value: args.invalid ? null : 'pickup', disabled: args.disabled }, validators);
- const template = ``;
- return { props: { ...args, selected: 'option1' }, template };
+ const template = `
+
+
+
+
`;
+
+ return { props: { ...args, control }, template };
},
parameters: {
docs: {
description: {
- story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.',
- },
- },
- },
+ story:
+ 'Интерактивная радиокнопка со всеми свойствами спецификации, показана в контексте соседних пунктов группы (общий FormControl — выбор взаимоисключающий). Используйте Controls для изменения пропсов среднего пункта; disabled и invalid управляются через FormControl и относятся ко всей группе.'
+ }
+ }
+ }
};
-// ── Re-exports from example components ────────────────────────────────────
-export { Group, Invalid, Disabled };
+// ── Комбинаторные истории ──────────────────────────────────────────────────
+export { Group, Disabled, Invalid, LabelPosition, Caption };