diff --git a/src/lib/components/multiselect/multiselect.component.ts b/src/lib/components/multiselect/multiselect.component.ts
new file mode 100644
index 00000000..73153604
--- /dev/null
+++ b/src/lib/components/multiselect/multiselect.component.ts
@@ -0,0 +1,177 @@
+import { Component, EventEmitter, forwardRef, inject, Injector, Input, OnInit, Output, TemplateRef } from '@angular/core';
+import { NgClass, NgTemplateOutlet } from '@angular/common';
+import { ControlValueAccessor, NgControl, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { FormsModule } from '@angular/forms';
+import { MultiSelect } from 'primeng/multiselect';
+import { PrimeTemplate } from 'primeng/api';
+
+export type MultiSelectSize = 'small' | 'base' | 'large' | 'xlarge';
+export type MultiSelectDisplay = 'comma' | 'chip';
+
+@Component({
+ selector: 'multiselect-field',
+ standalone: true,
+ imports: [MultiSelect, NgClass, NgTemplateOutlet, PrimeTemplate, FormsModule],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => MultiSelectComponent),
+ multi: true,
+ },
+ ],
+ template: `
+
+ @if (optionTemplate) {
+
+
+
+ }
+ @if (selectedItemsTemplate) {
+
+
+
+ }
+ @if (optionGroupTemplate) {
+
+
+
+ }
+ @if (headerTemplate) {
+
+
+
+ }
+ @if (footerTemplate) {
+
+
+
+ }
+
+ `,
+})
+export class MultiSelectComponent implements ControlValueAccessor, OnInit {
+ private readonly _injector = inject(Injector);
+ private _ngControl: NgControl | null = null;
+
+ ngOnInit(): void {
+ this._ngControl = this._injector.get(NgControl, null, { self: true, optional: true });
+ }
+
+ @Input() options: any[] | null | undefined;
+ @Input() optionLabel: string | undefined;
+ @Input() optionValue: string | undefined;
+ @Input() optionGroupLabel: string | undefined;
+ @Input() optionGroupChildren = 'items';
+ @Input() group = false;
+ @Input() placeholder = '';
+ @Input() size: MultiSelectSize = 'base';
+ @Input() display: MultiSelectDisplay = 'comma';
+ @Input() filter = false;
+ @Input() showClear = false;
+ @Input() readonly = false;
+ @Input() loading = false;
+ @Input() inputId: string | undefined;
+ @Input() appendTo: any = 'body';
+ @Input() maxSelectedLabels = 3;
+ @Input() selectedItemsLabel = 'Выбрано {0}';
+ @Input() emptyMessage = 'Нет данных';
+ @Input() emptyFilterMessage = 'Результаты не найдены';
+ @Input() optionTemplate: TemplateRef | null = null;
+ @Input() selectedItemsTemplate: TemplateRef | null = null;
+ @Input() optionGroupTemplate: TemplateRef | null = null;
+ @Input() headerTemplate: TemplateRef | null = null;
+ @Input() footerTemplate: TemplateRef | null = null;
+
+ disabled = false;
+ modelValue: any[] = [];
+
+ @Output() onClear = new EventEmitter();
+ @Output() onFilter = new EventEmitter();
+ @Output() onShow = new EventEmitter();
+ @Output() onHide = new EventEmitter();
+ @Output() onFocus = new EventEmitter();
+ @Output() onBlur = new EventEmitter();
+
+ readonly filterPt = {
+ pcFilterContainer: { root: { class: 'p-iconfield p-multiselect-filter-container' } },
+ pcFilter: { root: { class: 'p-inputtext-sm' } },
+ pcFilterIconContainer: { root: { class: 'p-inputicon' } },
+ };
+
+ get invalid(): boolean {
+ return !!(this._ngControl?.invalid && this._ngControl?.touched);
+ }
+
+ get primeSize(): 'small' | 'large' | undefined {
+ if (this.size === 'small') return 'small';
+ if (this.size === 'large') return 'large';
+ return undefined;
+ }
+
+ get multiSelectClasses(): Record {
+ return {
+ 'p-multiselect-xlg': this.size === 'xlarge',
+ 'p-invalid': this.invalid,
+ };
+ }
+
+ private _onChange: (value: any[]) => void = () => {};
+ private _onTouched: () => void = () => {};
+
+ onMultiSelectChange(event: { value: any[] }): void {
+ this.modelValue = event.value;
+ this._onChange(event.value);
+ }
+
+ handleBlur(event: Event): void {
+ this._onTouched();
+ this.onBlur.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 51acda5a..a144a215 100644
--- a/src/prime-preset/map-tokens.ts
+++ b/src/prime-preset/map-tokens.ts
@@ -12,6 +12,7 @@ import { tagCss } from './tokens/components/tag';
import { tooltipCss } from './tokens/components/tooltip';
import { megamenuCss } from './tokens/components/megamenu';
import { selectCss } from './tokens/components/select';
+import { multiselectCss } from './tokens/components/multiselect';
const presetTokens: Preset = {
primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'],
@@ -54,6 +55,10 @@ const presetTokens: Preset = {
...(tokens.components.select as unknown as ComponentsDesignTokens['select']),
css: selectCss,
},
+ multiselect: {
+ ...(tokens.components.multiselect as unknown as ComponentsDesignTokens['multiselect']),
+ css: multiselectCss,
+ },
} as ComponentsDesignTokens,
};
diff --git a/src/prime-preset/tokens/components/multiselect.ts b/src/prime-preset/tokens/components/multiselect.ts
new file mode 100644
index 00000000..a06e7230
--- /dev/null
+++ b/src/prime-preset/tokens/components/multiselect.ts
@@ -0,0 +1,66 @@
+export const multiselectCss = ({ dt }: { dt: (token: string) => string }): string => `
+ /* ─── Базовые стили ─── */
+ .p-multiselect.p-component {
+ width: 100%;
+ border-width: ${dt('multiselect.extend.borderWidth')};
+ line-height: ${dt('fonts.lineHeight.250')};
+ }
+
+ /* ─── Focus ─── */
+ .p-multiselect.p-component:not(.p-disabled).p-focus {
+ box-shadow: 0 0 0 ${dt('multiselect.root.focusRing.width')} ${dt('form.focusRing.color')};
+ }
+
+ /* ─── Invalid + Focus ─── */
+ .p-multiselect.p-component.p-invalid.p-focus {
+ border-color: ${dt('form.invalidBorderColor')};
+ box-shadow: 0 0 0 ${dt('multiselect.root.focusRing.width')} ${dt('focusRing.extend.invalid')};
+ }
+
+ /* ─── Readonly ─── */
+ .p-multiselect.p-component[readonly] {
+ background: ${dt('multiselect.extend.readonlyBackground')};
+ border-color: ${dt('form.borderColor')};
+ cursor: default;
+ pointer-events: none;
+ }
+
+ /* ─── XLarge ─── */
+ .p-multiselect.p-component.p-multiselect-xlg .p-multiselect-label {
+ font-size: ${dt('inputtext.extend.extXlg.fontSize')};
+ padding-block: ${dt('inputtext.extend.extXlg.paddingY')};
+ padding-inline: ${dt('inputtext.extend.extXlg.paddingX')};
+ }
+
+ /* ─── Chips: базовые отступы ─── */
+ .p-multiselect.p-component.p-multiselect-display-chip .p-multiselect-label:has(.p-chip) {
+ padding-block: calc(${dt('multiselect.root.paddingY')} - 7px);
+ padding-inline: calc(${dt('multiselect.root.paddingX')} - 7px);
+ }
+
+ .p-multiselect.p-component.p-multiselect-sm.p-multiselect-display-chip .p-multiselect-label:has(.p-chip) {
+ padding-block: calc(${dt('multiselect.root.sm.paddingY')} - 7px);
+ padding-inline: calc(${dt('multiselect.root.sm.paddingX')} - 7px);
+ }
+
+ .p-multiselect.p-component.p-multiselect-lg.p-multiselect-display-chip .p-multiselect-label:has(.p-chip) {
+ padding-block: calc(${dt('multiselect.root.lg.paddingY')} - 7px);
+ padding-inline: calc(${dt('multiselect.root.lg.paddingX')} - 7px);
+ }
+
+ .p-multiselect.p-component.p-multiselect-xlg.p-multiselect-display-chip .p-multiselect-label:has(.p-chip) {
+ padding-block: calc(${dt('inputtext.extend.extXlg.paddingY')} - 7px);
+ padding-inline: calc(${dt('inputtext.extend.extXlg.paddingX')} - 7px);
+ }
+
+ /* ─── Chip: отступ при наличии иконки удаления ─── */
+ .p-multiselect .p-chip:has(.p-chip-remove-icon) {
+ padding-inline-end: ${dt('chip.root.paddingX')};
+ }
+
+ /* ─── FloatLabel variant="in" ─── */
+ .p-floatlabel-in .p-multiselect.p-component .p-multiselect-label {
+ padding-block-start: ${dt('floatlabel.in.input.paddingTop')};
+ padding-block-end: ${dt('floatlabel.in.input.paddingBottom')};
+ }
+`;
diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json
index 14eeb718..eb827836 100644
--- a/src/prime-preset/tokens/tokens.json
+++ b/src/prime-preset/tokens/tokens.json
@@ -570,8 +570,6 @@
"paddingY": "{spacing.3x}"
},
"fontSize": "{fonts.fontSize.300}",
- "paddingX": "{spacing.4x}",
- "paddingY": "{spacing.4x}",
"lg": {
"width": "{sizing.76x}",
"fontSize": "{fonts.fontSize.300}",
@@ -3811,8 +3809,8 @@
},
"root": {
"shadow": "0",
- "paddingX": "{form.paddingX}",
- "paddingY": "{form.paddingY}",
+ "paddingX": "{form.padding.300}",
+ "paddingY": "{form.padding.300}",
"borderRadius": "{form.borderRadius.200}",
"transitionDuration": "{form.transitionDuration}",
"sm": {
@@ -4729,8 +4727,8 @@
"placeholderColor": "{form.placeholderColor}",
"invalidPlaceholderColor": "{form.invalidPlaceholderColor}",
"shadow": "0",
- "paddingX": "{form.paddingX}",
- "paddingY": "{form.paddingY}",
+ "paddingX": "{form.padding.300}",
+ "paddingY": "{form.padding.300}",
"borderRadius": "{form.borderRadius.200}",
"transitionDuration": "{form.transitionDuration}",
"focusRing": {
diff --git a/src/stories/components/multiselect/examples/multiselect-chips.component.ts b/src/stories/components/multiselect/examples/multiselect-chips.component.ts
new file mode 100644
index 00000000..02b69204
--- /dev/null
+++ b/src/stories/components/multiselect/examples/multiselect-chips.component.ts
@@ -0,0 +1,90 @@
+import { Component, Input } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { MultiSelectComponent, MultiSelectSize } from '../../../../lib/components/multiselect/multiselect.component';
+
+const OPTIONS = [
+ { name: 'Новосибирск', code: 'NSK' },
+ { name: 'Москва', code: 'MSK' },
+ { name: 'Санкт-Петербург', code: 'SPB' },
+ { name: 'Екатеринбург', code: 'EKB' },
+ { name: 'Казань', code: 'KZN' },
+];
+
+const template = `
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-multiselect-chips',
+ standalone: true,
+ imports: [MultiSelectComponent, ReactiveFormsModule],
+ template,
+ styles,
+})
+export class MultiSelectChipsComponent {
+ @Input() size: MultiSelectSize = 'base';
+ @Input() showClear = false;
+ @Input() filter = true;
+ control = new FormControl(null);
+ options = OPTIONS;
+}
+
+export const Chips = {
+ name: 'Chips',
+ render: (args: any) => ({
+ props: { size: args['size'], showClear: args['showClear'], filter: args['filter'] },
+ template: ``,
+ }),
+ argTypes: {
+ display: { table: { disable: true } },
+ readonly: { table: { disable: true } },
+ disabled: { table: { disable: true } },
+ invalid: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: { story: 'Выбранные значения отображаются в виде чипов (`display="chip"`).' },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { MultiSelectComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [MultiSelectComponent, ReactiveFormsModule],
+ template: \`
+
+ \`,
+})
+export class MultiSelectChipsExample {
+ control = new FormControl(null);
+ options = [
+ { name: 'Новосибирск', code: 'NSK' },
+ { name: 'Москва', code: 'MSK' },
+ { name: 'Санкт-Петербург', code: 'SPB' },
+ ];
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/multiselect/examples/multiselect-disabled.component.ts b/src/stories/components/multiselect/examples/multiselect-disabled.component.ts
new file mode 100644
index 00000000..0a47d46a
--- /dev/null
+++ b/src/stories/components/multiselect/examples/multiselect-disabled.component.ts
@@ -0,0 +1,69 @@
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { StoryObj } from '@storybook/angular';
+import { MultiSelectComponent } from '../../../../lib/components/multiselect/multiselect.component';
+
+const OPTIONS = [
+ { name: 'Новосибирск', code: 'NSK' },
+ { name: 'Москва', code: 'MSK' },
+ { name: 'Санкт-Петербург', code: 'SPB' },
+];
+
+export const Disabled: StoryObj = {
+ name: 'Disabled',
+ render: () => {
+ const control = new FormControl({ value: null, disabled: true });
+ return {
+ props: { control, options: OPTIONS },
+ template: `
+
+ `,
+ };
+ },
+ decorators: [
+ (story: any) => ({
+ ...story(),
+ moduleMetadata: {
+ imports: [MultiSelectComponent, ReactiveFormsModule],
+ },
+ }),
+ ],
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: { story: 'Отключённое состояние — управляется через FormControl.' },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { MultiSelectComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [MultiSelectComponent, ReactiveFormsModule],
+ template: \`
+
+ \`,
+})
+export class MultiSelectDisabledExample {
+ control = new FormControl({ value: null, disabled: true });
+ options = [
+ { name: 'Новосибирск', code: 'NSK' },
+ { name: 'Москва', code: 'MSK' },
+ ];
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/multiselect/examples/multiselect-float-label.component.ts b/src/stories/components/multiselect/examples/multiselect-float-label.component.ts
new file mode 100644
index 00000000..c5781794
--- /dev/null
+++ b/src/stories/components/multiselect/examples/multiselect-float-label.component.ts
@@ -0,0 +1,100 @@
+import { Component, Input } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { FloatLabel } from 'primeng/floatlabel';
+import { MultiSelectComponent } from '../../../../lib/components/multiselect/multiselect.component';
+
+const OPTIONS = [
+ { name: 'Новосибирск', code: 'NSK' },
+ { name: 'Москва', code: 'MSK' },
+ { name: 'Санкт-Петербург', code: 'SPB' },
+ { name: 'Екатеринбург', code: 'EKB' },
+];
+
+const template = `
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-multiselect-float-label',
+ standalone: true,
+ imports: [MultiSelectComponent, FloatLabel, ReactiveFormsModule],
+ template,
+ styles,
+})
+export class MultiSelectFloatLabelComponent {
+ @Input() showClear = false;
+ @Input() filter = true;
+ control = new FormControl(null);
+ options = OPTIONS;
+}
+
+export const FloatLabelStory = {
+ name: 'FloatLabel',
+ render: (args: any) => ({
+ props: { showClear: args['showClear'], filter: args['filter'] },
+ template: ``,
+ }),
+ argTypes: {
+ size: { table: { disable: true } },
+ display: { table: { disable: true } },
+ readonly: { table: { disable: true } },
+ disabled: { table: { disable: true } },
+ invalid: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Интеграция с `p-floatlabel` — плавающая метка внутри поля.',
+ },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { FloatLabel } from 'primeng/floatlabel';
+import { MultiSelectComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ standalone: true,
+ imports: [MultiSelectComponent, FloatLabel, ReactiveFormsModule],
+ template: \`
+
+ \`,
+})
+export class MultiSelectFloatLabelExample {
+ control = new FormControl(null);
+ options = [
+ { name: 'Новосибирск', code: 'NSK' },
+ { name: 'Москва', code: 'MSK' },
+ { name: 'Санкт-Петербург', code: 'SPB' },
+ ];
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/multiselect/multiselect.stories.ts b/src/stories/components/multiselect/multiselect.stories.ts
new file mode 100644
index 00000000..349afae7
--- /dev/null
+++ b/src/stories/components/multiselect/multiselect.stories.ts
@@ -0,0 +1,187 @@
+import { Meta, StoryObj, moduleMetadata } from '@storybook/angular';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+import { FloatLabel as PrimeFloatLabel } from 'primeng/floatlabel';
+import { MultiSelectComponent } from '../../../lib/components/multiselect/multiselect.component';
+import { MultiSelectChipsComponent, Chips as ChipsStory } from './examples/multiselect-chips.component';
+import { Disabled as DisabledStory } from './examples/multiselect-disabled.component';
+import { MultiSelectFloatLabelComponent, FloatLabelStory } from './examples/multiselect-float-label.component';
+
+const BASIC_OPTIONS = [
+ { name: 'Новосибирск', code: 'NSK' },
+ { name: 'Москва', code: 'MSK' },
+ { name: 'Санкт-Петербург', code: 'SPB' },
+ { name: 'Екатеринбург', code: 'EKB' },
+ { name: 'Казань', code: 'KZN' },
+];
+
+type MultiSelectArgs = Pick & {
+ disabled: boolean;
+ invalid: boolean;
+};
+
+const meta: Meta = {
+ title: 'Components/Form/MultiSelect',
+ component: MultiSelectComponent,
+ tags: ['autodocs'],
+ decorators: [
+ moduleMetadata({
+ imports: [
+ MultiSelectComponent,
+ ReactiveFormsModule,
+ PrimeFloatLabel,
+ MultiSelectChipsComponent,
+ MultiSelectFloatLabelComponent,
+ ],
+ }),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component: `Выпадающий список для выбора нескольких значений из набора опций. Поддерживает отображение выбранных значений через запятую или в виде чипов.
+
+\`\`\`typescript
+import { MultiSelectComponent } from '@cdek-it/angular-ui-kit';
+\`\`\``,
+ },
+ },
+ designTokens: { prefix: '--p-multiselect' },
+ },
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['small', 'base', 'large', 'xlarge'],
+ description: 'Размер поля',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'base'" },
+ type: { summary: "'small' | 'base' | 'large' | 'xlarge'" },
+ },
+ },
+ display: {
+ control: 'select',
+ options: ['comma', 'chip'],
+ description: 'Способ отображения выбранных значений',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "'comma'" },
+ type: { summary: "'comma' | 'chip'" },
+ },
+ },
+ placeholder: {
+ control: 'text',
+ description: 'Текст подсказки при пустом поле',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: "''" },
+ type: { summary: 'string' },
+ },
+ },
+ showClear: {
+ control: 'boolean',
+ description: 'Отображает иконку очистки выбранных значений',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ filter: {
+ control: 'boolean',
+ description: 'Включает строку поиска в выпадающем списке',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ readonly: {
+ control: 'boolean',
+ description: 'Режим только для чтения',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Отключает взаимодействие — управляется через FormControl',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ invalid: {
+ control: 'boolean',
+ description: 'Невалидное состояние — управляется через FormControl',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ },
+ args: {
+ size: 'base',
+ display: 'comma',
+ placeholder: 'Выберите города...',
+ showClear: true,
+ filter: true,
+ readonly: false,
+ disabled: false,
+ invalid: false,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// ── Default ───────────────────────────────────────────────────────────────────
+
+export const Default: Story = {
+ name: 'Default',
+ render: (args) => {
+ const control = new FormControl(
+ { value: null, disabled: !!args['disabled'] },
+ args['invalid'] ? [Validators.required] : []
+ );
+ if (args['invalid']) control.markAsTouched();
+
+ return {
+ props: { ...args, control, options: BASIC_OPTIONS },
+ template: `
+
+ `,
+ };
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.',
+ },
+ },
+ },
+};
+
+// ── Chips ─────────────────────────────────────────────────────────────────────
+
+export const Chips: Story = ChipsStory;
+
+// ── Disabled ──────────────────────────────────────────────────────────────────
+
+export const Disabled: Story = DisabledStory;
+
+// ── FloatLabel ────────────────────────────────────────────────────────────────
+
+export const FloatLabel: Story = FloatLabelStory;