diff --git a/src/lib/components/checkbox/checkbox.component.ts b/src/lib/components/checkbox/checkbox.component.ts new file mode 100644 index 00000000..8f0fc04a --- /dev/null +++ b/src/lib/components/checkbox/checkbox.component.ts @@ -0,0 +1,106 @@ +import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Checkbox, CheckboxChangeEvent } from 'primeng/checkbox'; + +export type CheckboxSize = 'small' | 'base' | 'large'; +export type CheckboxVariant = 'outlined' | 'filled'; + +@Component({ + selector: 'checkbox', + standalone: true, + imports: [Checkbox], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CheckboxComponent), + multi: true, + }, + ], + template: ` + + `, +}) +export class CheckboxComponent implements ControlValueAccessor { + @Input() value: any = null; + @Input() binary = false; + @Input() disabled = false; + @Input() readonly = false; + @Input() indeterminate = false; + @Input() invalid = false; + @Input() size: CheckboxSize = 'base'; + @Input() variant: CheckboxVariant = 'outlined'; + @Input() checkboxIcon: string | undefined = undefined; + @Input() ariaLabel: string | undefined = undefined; + @Input() ariaLabelledBy: string | undefined = undefined; + @Input() tabindex: number | undefined = undefined; + @Input() inputId: string | undefined = undefined; + @Input() trueValue: any = true; + @Input() falseValue: any = false; + @Input() autofocus = false; + + @Output() onChange = new EventEmitter(); + @Output() onFocus = new EventEmitter(); + @Output() onBlur = new EventEmitter(); + + modelValue: any = false; + + private _onChange: (value: any) => void = () => {}; + private _onTouched: () => void = () => {}; + + // Геттеры — маппинг в PrimeNG API + get primeSize(): 'small' | 'large' | undefined { + if (this.size === 'small') return 'small'; + if (this.size === 'large') return 'large'; + return undefined; + } + + get primeVariant(): 'filled' | 'outlined' | undefined { + if (this.variant === 'filled') return 'filled'; + return undefined; + } + + onChangeHandler(event: CheckboxChangeEvent): void { + this._onChange(event.checked); + this._onTouched(); + this.onChange.emit(event); + } + + // ControlValueAccessor + 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 a5dd6347..c7b08f5a 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -5,6 +5,7 @@ import type { AuraBaseDesignTokens } from '@primeuix/themes/aura/base'; import tokens from './tokens/tokens.json'; import { avatarCss } from './tokens/components/avatar'; import { buttonCss } from './tokens/components/button'; +import { checkboxCss } from './tokens/components/checkbox'; import { tooltipCss } from './tokens/components/tooltip'; const presetTokens: Preset = { @@ -16,6 +17,10 @@ const presetTokens: Preset = { ...(tokens.components.avatar as unknown as ComponentsDesignTokens['avatar']), css: avatarCss, }, + checkbox: { + ...(tokens.components.checkbox as unknown as ComponentsDesignTokens['checkbox']), + css: checkboxCss, + }, button: { ...(tokens.components.button as unknown as ComponentsDesignTokens['button']), css: buttonCss, diff --git a/src/prime-preset/tokens/components/checkbox.ts b/src/prime-preset/tokens/components/checkbox.ts new file mode 100644 index 00000000..a1e3d355 --- /dev/null +++ b/src/prime-preset/tokens/components/checkbox.ts @@ -0,0 +1,74 @@ +export const checkboxCss = ({ dt }: { dt: (token: string) => string }): string => ` +/* ─── Label типографика ─── */ +.checkbox-label { + display: flex; + align-items: center; + color: ${dt('text.color')}; + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.300')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: normal; + cursor: pointer; +} + +.checkbox-label--hover { + color: ${dt('text.primaryColor')}; +} + +.checkbox-label--disabled { + color: ${dt('text.mutedColor')}; + cursor: default; +} + +.checkbox-caption { + color: ${dt('text.secondaryColor')}; + font-family: ${dt('fonts.fontFamily.heading')}; + font-size: ${dt('fonts.fontSize.200')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: normal; +} + +.checkbox-caption--hover { + color: ${dt('text.primaryColor')}; +} + +.checkbox-caption--disabled { + color: ${dt('text.disabledColor')}; +} + +/* Переопределение ширины border для checkbox */ +.p-checkbox-box { + border-width: ${dt('checkbox.root.extend.borderWidth')}; +} + +/* Состояние indeterminate - фон и border как у checked */ +.p-checkbox-indeterminate .p-checkbox-box { + background: ${dt('checkbox.root.checkedBackground')}; + border-color: ${dt('checkbox.root.checkedBorderColor')}; +} + +/* Состояние indeterminate - цвет иконки как у checked */ +.p-checkbox-indeterminate .p-checkbox-icon { + color: ${dt('checkbox.icon.checkedColor')}; +} + +/* Состояние hover для indeterminate */ +.p-checkbox-indeterminate:not(.p-disabled):has(.p-checkbox-input:hover) .p-checkbox-box { + background: ${dt('checkbox.root.checkedHoverBackground')}; + border-color: ${dt('checkbox.root.checkedHoverBorderColor')}; +} + +/* Focus ring с зеленым цветом для валидных состояний */ +.p-checkbox:not(.p-disabled):not(.p-checkbox-checked):not(.p-invalid):has(.p-checkbox-input:focus-visible) .p-checkbox-box, +.p-checkbox-checked:not(.p-disabled):not(.p-invalid):has(.p-checkbox-input:focus-visible) .p-checkbox-box, +.p-checkbox-indeterminate:not(.p-disabled):not(.p-invalid):has(.p-checkbox-input:focus-visible) .p-checkbox-box { + box-shadow: 0 0 0 ${dt('checkbox.root.focusRing.focusRing')} ${dt('focusRing.extend.success')}; +} + +/* Focus ring с красным цветом для состояний с ошибкой */ +.p-checkbox.p-invalid .p-checkbox-box, +.p-checkbox-checked.p-invalid .p-checkbox-box, +.p-checkbox-indeterminate.p-invalid .p-checkbox-box { + box-shadow: 0 0 0 ${dt('focusRing.width')} ${dt('focusRing.extend.invalid')}; +} +`; diff --git a/src/stories/components/checkbox/checkbox.stories.ts b/src/stories/components/checkbox/checkbox.stories.ts new file mode 100644 index 00000000..2e4423e4 --- /dev/null +++ b/src/stories/components/checkbox/checkbox.stories.ts @@ -0,0 +1,146 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { CheckboxComponent } from '../../../lib/components/checkbox/checkbox.component'; +import { FormsModule } from '@angular/forms'; +import { CheckboxGroupComponent, Group } from './examples/checkbox-group.component'; +import { CheckboxIndeterminateComponent, Indeterminate } from './examples/checkbox-indeterminate.component'; +import { CheckboxDisabledComponent, Disabled } from './examples/checkbox-disabled.component'; +import { CheckboxInvalidComponent, Invalid } from './examples/checkbox-invalid.component'; +import { CheckboxLabelComponent, Label } from './examples/checkbox-label.component'; +import { CheckboxCustomLabelComponent, CustomLabel } from './examples/checkbox-custom-label.component'; + +type CheckboxArgs = CheckboxComponent & { label?: string }; + +const meta: Meta = { + title: 'Components/Form/Checkbox', + component: CheckboxComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ + CheckboxComponent, + FormsModule, + CheckboxGroupComponent, + CheckboxIndeterminateComponent, + CheckboxDisabledComponent, + CheckboxInvalidComponent, + CheckboxLabelComponent, + CheckboxCustomLabelComponent, + ] + }) + ], + parameters: { + designTokens: { prefix: '--p-checkbox' }, + docs: { + description: { + component: `Компонент для выбора одного или нескольких вариантов.`, + }, + }, + }, + argTypes: { + // ── Props ──────────────────────────────────────────────── + binary: { table: { disable: true } }, + invalid: { + control: 'boolean', + description: 'Подсвечивает поле как невалидное', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + disabled: { + control: 'boolean', + description: 'Отключает возможность взаимодействия', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + indeterminate: { + control: 'boolean', + description: 'Устанавливает неопределенное состояние', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + // Hidden props + size: { table: { disable: true } }, + readonly: { table: { disable: true } }, + checkboxIcon: { table: { disable: true } }, + ariaLabel: { table: { disable: true } }, + ariaLabelledBy: { table: { disable: true } }, + tabindex: { table: { disable: true } }, + inputId: { table: { disable: true } }, + trueValue: { table: { disable: true } }, + falseValue: { table: { disable: true } }, + autofocus: { table: { disable: true } }, + variant: { table: { disable: true } }, + value: { table: { disable: true } }, + label: { table: { disable: true } }, + + // ── Events ─────────────────────────────────────────────── + onChange: { + 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: { + binary: true, + disabled: false, + invalid: false, + indeterminate: false, + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Default ────────────────────────────────────────────────────────────────── +export const Default: Story = { + name: 'Default', + render: (args) => { + const parts: string[] = []; + if (args.binary) parts.push(`[binary]="true"`); + if (args.disabled) parts.push(`[disabled]="true"`); + if (args.invalid) parts.push(`[invalid]="true"`); + if (args.indeterminate) parts.push(`[indeterminate]="true"`); + parts.push(`[(ngModel)]="checked"`); + + const template = ``; + + return { props: { ...args, checked: false }, template }; + }, + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Re-exports from example components ──────────────────────────────────── +export { Invalid, Disabled, Indeterminate, Group, Label, CustomLabel }; diff --git a/src/stories/components/checkbox/examples/checkbox-custom-label.component.ts b/src/stories/components/checkbox/examples/checkbox-custom-label.component.ts new file mode 100644 index 00000000..750bb55b --- /dev/null +++ b/src/stories/components/checkbox/examples/checkbox-custom-label.component.ts @@ -0,0 +1,159 @@ +import { Component, Input } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { CheckboxComponent } from '../../../../lib/components/checkbox/checkbox.component'; + +const styles = ''; + +@Component({ + selector: 'app-checkbox-custom-label', + standalone: true, + imports: [CheckboxComponent, ReactiveFormsModule], + styles, + template: ` +
+ @if (labelPosition === 'left') { + + } +
+ + @if (caption) { + {{ caption }} + } +
+ @if (labelPosition === 'right') { + + } +
+ `, +}) +export class CheckboxCustomLabelComponent { + @Input() label = 'Checkbox'; + @Input() caption = 'caption'; + @Input() labelPosition: 'left' | 'right' = 'left'; + @Input() invalid = false; + @Input() disabled = false; + @Input() inputId = 'custom-checkbox'; + + formControl = new FormControl(false); + + get labelClass(): string { + return this.disabled ? 'checkbox-label checkbox-label--disabled' : 'checkbox-label'; + } + + get captionClass(): string { + return this.disabled ? 'checkbox-caption checkbox-caption--disabled' : 'checkbox-caption'; + } + + ngOnChanges(): void { + if (this.disabled) { + this.formControl.disable(); + } else { + this.formControl.enable(); + } + } +} + +export const CustomLabel: StoryObj = { + render: (args) => ({ + props: { ...args, checked: false }, + template: ` + + `, + }), + args: { + label: 'Checkbox', + caption: 'caption', + labelPosition: 'left', + invalid: false, + disabled: false, + }, + argTypes: { + label: { + control: 'text', + description: 'Текст метки', + table: { category: 'Props' }, + }, + caption: { + control: 'text', + description: 'Подпись под меткой', + table: { category: 'Props' }, + }, + labelPosition: { + control: 'select', + options: ['left', 'right'], + description: 'Позиция чекбокса относительно метки', + table: { category: 'Props', defaultValue: { summary: 'left' } }, + }, + }, + parameters: { + docs: { + description: { + story: 'Чекбокс с label и caption. Управляйте состоянием через Controls.', + }, + source: { + language: 'ts', + code: ` +import { Component, Input, OnChanges } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { CheckboxComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-checkbox-custom-label', + standalone: true, + imports: [CheckboxComponent, ReactiveFormsModule], + template: \` +
+ @if (labelPosition === 'left') { + + } +
+ + @if (caption) { + {{ caption }} + } +
+ @if (labelPosition === 'right') { + + } +
+ \`, +}) +export class CheckboxCustomLabelComponent implements OnChanges { + @Input() label = 'Checkbox'; + @Input() caption = 'caption'; + @Input() labelPosition: 'left' | 'right' = 'left'; + @Input() invalid = false; + @Input() disabled = false; + @Input() inputId = 'custom-checkbox'; + + formControl = new FormControl(false); + + get labelClass(): string { + return this.disabled ? 'checkbox-label checkbox-label--disabled' : 'checkbox-label'; + } + + get captionClass(): string { + return this.disabled ? 'checkbox-caption checkbox-caption--disabled' : 'checkbox-caption'; + } + + ngOnChanges(): void { + if (this.disabled) { + this.formControl.disable(); + } else { + this.formControl.enable(); + } + } +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/checkbox/examples/checkbox-disabled.component.ts b/src/stories/components/checkbox/examples/checkbox-disabled.component.ts new file mode 100644 index 00000000..6467a14c --- /dev/null +++ b/src/stories/components/checkbox/examples/checkbox-disabled.component.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { CheckboxComponent } from '../../../../lib/components/checkbox/checkbox.component'; + +const styles = ''; + +@Component({ + selector: 'app-checkbox-disabled', + standalone: true, + imports: [CheckboxComponent, FormsModule], + styles, + template: ` + + `, +}) +export class CheckboxDisabledComponent { + checked = true; +} + +export const Disabled: StoryObj = { + render: (args) => ({ + props: { ...args, checked: true }, + template: ``, + }), + args: { disabled: true }, + parameters: { + docs: { + description: { story: 'Заблокированное состояние чекбокса.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { CheckboxComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-checkbox-disabled', + standalone: true, + imports: [CheckboxComponent, ReactiveFormsModule], + template: \` + + \`, +}) +export class CheckboxDisabledComponent { + control = new FormControl({ value: true, disabled: true }); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/checkbox/examples/checkbox-group.component.ts b/src/stories/components/checkbox/examples/checkbox-group.component.ts new file mode 100644 index 00000000..1564a48f --- /dev/null +++ b/src/stories/components/checkbox/examples/checkbox-group.component.ts @@ -0,0 +1,57 @@ +import { Component } from '@angular/core'; +import { JsonPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { CheckboxComponent } from '../../../../lib/components/checkbox/checkbox.component'; + +const template = ` +
+ + + +
+`; + +@Component({ + selector: 'app-checkbox-group', + standalone: true, + imports: [CheckboxComponent, FormsModule, JsonPipe], + template, +}) +export class CheckboxGroupComponent { + selectedItems: string[] = ['Pizza']; +} + +export const Group: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { story: 'Использование нескольких чекбоксов для выбора нескольких значений из массива.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { CheckboxComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-checkbox-group', + standalone: true, + imports: [CheckboxComponent, FormsModule], + template: \` + + + + \`, +}) +export class CheckboxGroupComponent { + selectedItems: string[] = ['Pizza']; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/checkbox/examples/checkbox-indeterminate.component.ts b/src/stories/components/checkbox/examples/checkbox-indeterminate.component.ts new file mode 100644 index 00000000..8caae69a --- /dev/null +++ b/src/stories/components/checkbox/examples/checkbox-indeterminate.component.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { CheckboxComponent } from '../../../../lib/components/checkbox/checkbox.component'; + +const styles = ''; + +@Component({ + selector: 'app-checkbox-indeterminate', + standalone: true, + imports: [CheckboxComponent, FormsModule], + styles, + template: ` + + `, +}) +export class CheckboxIndeterminateComponent { + checked = false; +} + +export const Indeterminate: StoryObj = { + render: (args) => ({ + props: { ...args, checked: false }, + template: ``, + }), + args: { indeterminate: true }, + parameters: { + docs: { + description: { story: 'Неопределённое состояние чекбокса.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { CheckboxComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-checkbox-indeterminate', + standalone: true, + imports: [CheckboxComponent, FormsModule], + template: \` + + \`, +}) +export class CheckboxIndeterminateComponent { + checked = false; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/checkbox/examples/checkbox-invalid.component.ts b/src/stories/components/checkbox/examples/checkbox-invalid.component.ts new file mode 100644 index 00000000..6bf93652 --- /dev/null +++ b/src/stories/components/checkbox/examples/checkbox-invalid.component.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { CheckboxComponent } from '../../../../lib/components/checkbox/checkbox.component'; + +const styles = ''; + +@Component({ + selector: 'app-checkbox-invalid', + standalone: true, + imports: [CheckboxComponent, FormsModule], + styles, + template: ` + + `, +}) +export class CheckboxInvalidComponent { + checked = false; +} + +export const Invalid: StoryObj = { + render: (args) => ({ + props: { ...args, checked: false }, + template: ``, + }), + args: { invalid: true }, + parameters: { + docs: { + description: { story: 'Невалидное состояние чекбокса.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { CheckboxComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-checkbox-invalid', + standalone: true, + imports: [CheckboxComponent, ReactiveFormsModule], + template: \` + + \`, +}) +export class CheckboxInvalidComponent { + control = new FormControl(false, [Validators.requiredTrue]); +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/checkbox/examples/checkbox-label.component.ts b/src/stories/components/checkbox/examples/checkbox-label.component.ts new file mode 100644 index 00000000..8d555ef1 --- /dev/null +++ b/src/stories/components/checkbox/examples/checkbox-label.component.ts @@ -0,0 +1,62 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { CheckboxComponent } from '../../../../lib/components/checkbox/checkbox.component'; + +const styles = ''; + +@Component({ + selector: 'app-checkbox-label', + standalone: true, + imports: [CheckboxComponent, FormsModule], + styles, + template: ` +
+ + +
+ `, +}) +export class CheckboxLabelComponent { + checked = false; +} + +export const Label: StoryObj = { + render: (args) => ({ + props: { ...args, checked: false }, + template: ` +
+ + +
+ `, + }), + parameters: { + docs: { + description: { story: 'Чекбокс с привязанным label через inputId.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { CheckboxComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-checkbox-label', + standalone: true, + imports: [CheckboxComponent, ReactiveFormsModule], + template: \` +
+ + +
+ \`, +}) +export class CheckboxLabelComponent { + control = new FormControl(false); +} + `, + }, + }, + }, +};