diff --git a/src/lib/components/select-button/select-button.component.ts b/src/lib/components/select-button/select-button.component.ts new file mode 100644 index 00000000..ab8009d9 --- /dev/null +++ b/src/lib/components/select-button/select-button.component.ts @@ -0,0 +1,91 @@ +import { Component, EventEmitter, Input, Optional, Output, Self } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms'; +import { SelectButton } from 'primeng/selectbutton'; +import { SharedModule } from 'primeng/api'; + +export interface SelectButtonItem { + label: string; + value: string; + icon?: string; + disabled?: boolean; +} + +@Component({ + selector: 'select-button', + standalone: true, + imports: [SelectButton, SharedModule, FormsModule, NgClass], + template: ` + + + @if (item['icon']) { + + } + {{ item[optionLabel] }} + + + `, +}) +export class SelectButtonComponent implements ControlValueAccessor { + @Input() options: any[] = []; + @Input() optionLabel = 'label'; + @Input() optionValue = 'value'; + @Input() optionDisabled = 'disabled'; + @Input() size: 'default' | 'sm' | 'lg' | 'xlg' = 'default'; + @Input() multiple = false; + @Input() allowEmpty = true; + + @Output() valueChange = new EventEmitter(); + + value: string | string[] = ''; + + private _disabled = false; + private onChange = (_: string | string[]) => {}; + private onTouched = () => {}; + + constructor(@Optional() @Self() private ngControl: NgControl) { + if (ngControl) ngControl.valueAccessor = this; + } + + get isDisabled(): boolean { + return this._disabled; + } + + get sizeClass(): string { + return this.size === 'default' ? '' : `p-selectbutton-${this.size}`; + } + + writeValue(value: string | string[]): void { + this.value = value ?? ''; + } + + registerOnChange(fn: (value: string | string[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this._disabled = isDisabled; + } + + onValueChange(newValue: string | string[]): void { + this.value = newValue; + this.onChange(newValue); + this.onTouched(); + this.valueChange.emit(newValue); + } +} diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts index d0a44965..5d945f1e 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -13,6 +13,7 @@ import { tagCss } from './tokens/components/tag'; import { timelineCss } from './tokens/components/timeline'; import { tooltipCss } from './tokens/components/tooltip'; import { megamenuCss } from './tokens/components/megamenu'; +import { selectbuttonCss } from './tokens/components/selectbutton'; const presetTokens: Preset = { primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'], @@ -59,6 +60,10 @@ const presetTokens: Preset = { ...(tokens.components.megamenu as unknown as ComponentsDesignTokens['megamenu']), css: megamenuCss, }, + selectbutton: { + ...(tokens.components.selectbutton as unknown as ComponentsDesignTokens['selectbutton']), + css: selectbuttonCss, + }, } as ComponentsDesignTokens, }; diff --git a/src/prime-preset/tokens/components/selectbutton.ts b/src/prime-preset/tokens/components/selectbutton.ts new file mode 100644 index 00000000..33880591 --- /dev/null +++ b/src/prime-preset/tokens/components/selectbutton.ts @@ -0,0 +1,90 @@ +export const selectbuttonCss = ({ dt }: { dt: (token: string) => string }): string => ` +.p-selectbutton.p-component { + background: ${dt('selectbutton.colorScheme.light.extend.background')}; + padding: ${dt('selectbutton.extend.paddingY')} ${dt('selectbutton.extend.paddingX')}; + gap: ${dt('selectbutton.extend.gap')}; + font-family: ${dt('fonts.fontFamily.heading')}; + font-weight: ${dt('fonts.fontWeight.demibold')}; +} + +.p-selectbutton.p-component .p-togglebutton.p-component { + font-family: ${dt('fonts.fontFamily.heading')}; + font-weight: ${dt('fonts.fontWeight.demibold')}; + line-height: ${dt('fonts.lineHeight.500')}; + height: ${dt('controls.iconOnly.700')}; + border-radius: ${dt('selectbutton.extend.ext.borderRadius')}; +} + +.p-selectbutton.p-component .p-togglebutton .p-togglebutton-label, +.p-selectbutton.p-component .p-togglebutton .p-togglebutton-content > span { + line-height: ${dt('fonts.lineHeight.400')}; +} + +.p-selectbutton.p-component .p-togglebutton.p-togglebutton-checked.p-component, +.p-selectbutton.p-component .p-togglebutton.p-togglebutton-checked.p-component:hover { + background: ${dt('selectbutton.extend.checkedBackground')}; + border-radius: ${dt('selectbutton.extend.ext.borderRadius')}; + border-color: ${dt('selectbutton.extend.checkedBorderColor')}; + color: ${dt('selectbutton.extend.checkedColor')}; +} + +.p-selectbutton.p-component .p-togglebutton.p-component:not(:disabled):not(.p-togglebutton-checked):hover { + border-radius: ${dt('selectbutton.extend.ext.borderRadius')}; + border-color: ${dt('togglebutton.extend.hoverBorderColor')}; +} + +/* Size: sm */ +.p-selectbutton.p-selectbutton-sm.p-component .p-togglebutton.p-component { + line-height: ${dt('fonts.lineHeight.300')}; + height: ${dt('controls.iconOnly.600')}; +} + +.p-selectbutton.p-selectbutton-sm.p-component .p-togglebutton .p-togglebutton-label, +.p-selectbutton.p-selectbutton-sm.p-component .p-togglebutton .p-togglebutton-content > span { + line-height: ${dt('fonts.lineHeight.250')}; +} + +.p-selectbutton.p-selectbutton-sm.p-component .p-togglebutton .p-togglebutton-icon, +.p-selectbutton.p-selectbutton-sm.p-component .p-togglebutton i { + font-size: ${dt('selectbutton.extend.iconSize.sm')}; +} + +/* Size: md (base) */ +.p-selectbutton.p-component:not(.p-selectbutton-sm):not(.p-selectbutton-lg):not(.p-selectbutton-xlg) .p-togglebutton .p-togglebutton-icon, +.p-selectbutton.p-component:not(.p-selectbutton-sm):not(.p-selectbutton-lg):not(.p-selectbutton-xlg) .p-togglebutton i { + font-size: ${dt('selectbutton.extend.iconSize.md')}; +} + +/* Size: lg */ +.p-selectbutton.p-selectbutton-lg.p-component .p-togglebutton.p-component { + line-height: ${dt('fonts.lineHeight.550')}; + height: ${dt('controls.iconOnly.850')}; +} + +.p-selectbutton.p-selectbutton-lg.p-component .p-togglebutton .p-togglebutton-label, +.p-selectbutton.p-selectbutton-lg.p-component .p-togglebutton .p-togglebutton-content > span { + line-height: ${dt('fonts.lineHeight.550')}; +} + +.p-selectbutton.p-selectbutton-lg.p-component .p-togglebutton .p-togglebutton-icon, +.p-selectbutton.p-selectbutton-lg.p-component .p-togglebutton i { + font-size: ${dt('selectbutton.extend.iconSize.lg')}; +} + +/* Size: xlg */ +.p-selectbutton.p-selectbutton-xlg.p-component .p-togglebutton.p-component { + font-size: ${dt('fonts.fontSize.600')}; + line-height: ${dt('fonts.lineHeight.550')}; + height: ${dt('controls.iconOnly.900')}; +} + +.p-selectbutton.p-selectbutton-xlg.p-component .p-togglebutton .p-togglebutton-label, +.p-selectbutton.p-selectbutton-xlg.p-component .p-togglebutton .p-togglebutton-content > span { + line-height: ${dt('fonts.lineHeight.700')}; +} + +.p-selectbutton.p-selectbutton-xlg.p-component .p-togglebutton .p-togglebutton-icon, +.p-selectbutton.p-selectbutton-xlg.p-component .p-togglebutton i { + font-size: ${dt('selectbutton.extend.iconSize.xlg')}; +} +`; diff --git a/src/stories/components/select-button/examples/select-button-disabled.component.ts b/src/stories/components/select-button/examples/select-button-disabled.component.ts new file mode 100644 index 00000000..1e31d5c5 --- /dev/null +++ b/src/stories/components/select-button/examples/select-button-disabled.component.ts @@ -0,0 +1,65 @@ +import { Component } from '@angular/core'; +import { ReactiveFormsModule, FormControl } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { SelectButtonComponent, SelectButtonItem } from '../../../../lib/components/select-button/select-button.component'; + +const template = ` +
+ +
+`; +const styles = ''; + +@Component({ + selector: 'app-select-button-disabled', + standalone: true, + imports: [SelectButtonComponent, ReactiveFormsModule], + template, + styles, +}) +export class SelectButtonDisabledComponent { + control = new FormControl({ value: '1', disabled: true }); + options: SelectButtonItem[] = [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + ]; +} + +export const Disabled: StoryObj = { + name: 'Disabled', + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { story: 'Весь компонент отключён через `FormControl`.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { ReactiveFormsModule, FormControl } from '@angular/forms'; +import { SelectButtonComponent, SelectButtonItem } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-select-button-disabled', + standalone: true, + imports: [SelectButtonComponent, ReactiveFormsModule], + template: \` + + \`, +}) +export class SelectButtonDisabledComponent { + control = new FormControl({ value: '1', disabled: true }); + options: SelectButtonItem[] = [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + ]; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/select-button/examples/select-button-icons.component.ts b/src/stories/components/select-button/examples/select-button-icons.component.ts new file mode 100644 index 00000000..d3d94974 --- /dev/null +++ b/src/stories/components/select-button/examples/select-button-icons.component.ts @@ -0,0 +1,65 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { SelectButtonComponent, SelectButtonItem } from '../../../../lib/components/select-button/select-button.component'; + +const template = ` +
+ +
+`; +const styles = ''; + +@Component({ + selector: 'app-select-button-icons', + standalone: true, + imports: [SelectButtonComponent, FormsModule], + template, + styles, +}) +export class SelectButtonIconsComponent { + value = 'left'; + options: SelectButtonItem[] = [ + { label: 'Left', value: 'left', icon: 'ti ti-align-left' }, + { label: 'Center', value: 'center', icon: 'ti ti-align-center' }, + { label: 'Right', value: 'right', icon: 'ti ti-align-right' }, + ]; +} + +export const WithIcons: StoryObj = { + name: 'With Icons', + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { story: 'Опции с иконками — иконка отображается автоматически если задано поле `icon`.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SelectButtonComponent, SelectButtonItem } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-select-button-icons', + standalone: true, + imports: [SelectButtonComponent, FormsModule], + template: \` + + \`, +}) +export class SelectButtonIconsComponent { + value = 'left'; + options: SelectButtonItem[] = [ + { label: 'Left', value: 'left', icon: 'ti ti-align-left' }, + { label: 'Center', value: 'center', icon: 'ti ti-align-center' }, + { label: 'Right', value: 'right', icon: 'ti ti-align-right' }, + ]; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/select-button/examples/select-button-selected.component.ts b/src/stories/components/select-button/examples/select-button-selected.component.ts new file mode 100644 index 00000000..22ff962a --- /dev/null +++ b/src/stories/components/select-button/examples/select-button-selected.component.ts @@ -0,0 +1,65 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { SelectButtonComponent, SelectButtonItem } from '../../../../lib/components/select-button/select-button.component'; + +const template = ` +
+ +
+`; +const styles = ''; + +@Component({ + selector: 'app-select-button-selected', + standalone: true, + imports: [SelectButtonComponent, FormsModule], + template, + styles, +}) +export class SelectButtonSelectedComponent { + value = '2'; + options: SelectButtonItem[] = [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + ]; +} + +export const Selected: StoryObj = { + name: 'Selected', + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { story: 'Второй вариант выбран по умолчанию.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SelectButtonComponent, SelectButtonItem } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-select-button-selected', + standalone: true, + imports: [SelectButtonComponent, FormsModule], + template: \` + + \`, +}) +export class SelectButtonSelectedComponent { + value = '2'; + options: SelectButtonItem[] = [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + ]; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/select-button/examples/select-button-semi-disabled.component.ts b/src/stories/components/select-button/examples/select-button-semi-disabled.component.ts new file mode 100644 index 00000000..4d1f68dc --- /dev/null +++ b/src/stories/components/select-button/examples/select-button-semi-disabled.component.ts @@ -0,0 +1,65 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { SelectButtonComponent, SelectButtonItem } from '../../../../lib/components/select-button/select-button.component'; + +const template = ` +
+ +
+`; +const styles = ''; + +@Component({ + selector: 'app-select-button-semi-disabled', + standalone: true, + imports: [SelectButtonComponent, FormsModule], + template, + styles, +}) +export class SelectButtonSemiDisabledComponent { + value = '1'; + options: SelectButtonItem[] = [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2', disabled: true }, + { label: 'Option 3', value: '3' }, + ]; +} + +export const SemiDisabled: StoryObj = { + name: 'Semi-disabled', + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { story: 'Отдельная опция отключена через поле `disabled` в объекте опции.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SelectButtonComponent, SelectButtonItem } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-select-button-semi-disabled', + standalone: true, + imports: [SelectButtonComponent, FormsModule], + template: \` + + \`, +}) +export class SelectButtonSemiDisabledComponent { + value = '1'; + options: SelectButtonItem[] = [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2', disabled: true }, + { label: 'Option 3', value: '3' }, + ]; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/select-button/select-button.stories.ts b/src/stories/components/select-button/select-button.stories.ts new file mode 100644 index 00000000..39c89c03 --- /dev/null +++ b/src/stories/components/select-button/select-button.stories.ts @@ -0,0 +1,139 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { SelectButtonComponent } from '../../../lib/components/select-button/select-button.component'; +import { SelectButtonSelectedComponent, Selected as SelectedStory } from './examples/select-button-selected.component'; +import { SelectButtonDisabledComponent, Disabled as DisabledStory } from './examples/select-button-disabled.component'; +import { SelectButtonSemiDisabledComponent, SemiDisabled as SemiDisabledStory } from './examples/select-button-semi-disabled.component'; +import { SelectButtonIconsComponent, WithIcons as WithIconsStory } from './examples/select-button-icons.component'; + +type SelectButtonArgs = SelectButtonComponent; + +const meta: Meta = { + title: 'Components/Form/SelectButton', + component: SelectButtonComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + SelectButtonComponent, + SelectButtonSelectedComponent, + SelectButtonDisabledComponent, + SelectButtonSemiDisabledComponent, + SelectButtonIconsComponent, + ], + }), + ], + parameters: { + docs: { + description: { + component: `Группа кнопок-переключателей с поддержкой одиночного и множественного выбора. + +\`\`\`typescript +import { SelectButtonComponent, SelectButtonItem } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + designTokens: { prefix: '--p-selectbutton' }, + }, + argTypes: { + value: { + control: 'text', + description: 'Текущее выбранное значение', + table: { + category: 'Props', + type: { summary: 'string | string[]' }, + }, + }, + size: { + control: 'radio', + options: ['default', 'sm', 'lg', 'xlg'], + description: 'Размер компонента', + table: { + category: 'Props', + defaultValue: { summary: 'default' }, + type: { summary: "'default' | 'sm' | 'lg' | 'xlg'" }, + }, + }, + multiple: { + control: 'boolean', + description: 'Разрешает выбор нескольких опций', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + allowEmpty: { + control: 'boolean', + description: 'Разрешает снятие выбора', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + options: { + control: 'object', + description: 'Массив опций', + table: { + category: 'Props', + type: { summary: 'SelectButtonItem[]' }, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Default ─────────────────────────────────────────────────────────────────── + +export const Default: Story = { + name: 'Default', + render: (args) => { + const parts: string[] = []; + + parts.push(`[(value)]="value"`); + parts.push(`[options]="options"`); + if (args.size && args.size !== 'default') parts.push(`size="${args.size}"`); + if (args.multiple) parts.push(`[multiple]="true"`); + if (!args.allowEmpty) parts.push(`[allowEmpty]="false"`); + + const template = ``; + + return { props: args, template }; + }, + args: { + value: '1', + options: [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + ], + size: 'default', + multiple: false, + allowEmpty: true, + }, + parameters: { + docs: { + description: { + story: 'Базовый SelectButton. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Selected ────────────────────────────────────────────────────────────────── + +export const Selected: Story = SelectedStory; + +// ── Disabled ────────────────────────────────────────────────────────────────── + +export const Disabled: Story = DisabledStory; + +// ── Semi-disabled ───────────────────────────────────────────────────────────── + +export const SemiDisabled: Story = SemiDisabledStory; + +// ── With Icons ──────────────────────────────────────────────────────────────── + +export const WithIcons: Story = WithIconsStory;