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;