diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..b8a63e4b --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,600 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Angular component library (`@cdek-it/angular-ui-kit`) based on PrimeNG for CDEK logistics storefronts. + +## Commands + +```bash +npm run storybook # Dev server on port 6006 +npm run build # Build library +``` + +## Architecture + +### Component System + +- Компоненты живут в `src/lib/components//` +- Stories и примеры — в `src/stories/components//` +- Токены темы — в `src/prime-preset/tokens/tokens.json` +- CSS-переопределения компонентов — в `src/prime-preset/tokens/components/.ts`, подключаются через `src/prime-preset/map-tokens.ts` + +### CSS-переменные из токенов темы + +PrimeNG генерирует CSS-переменные из `tokens.json` по правилу: +- Путь токена → kebab-case, с префиксом `--p-` +- Ключ `extend` **исключён** из имени переменной + +Примеры: +``` +primitive.fonts.fontFamily.base → --p-fonts-font-family-base +primitive.fonts.fontSize.300 → --p-fonts-font-size-300 +semantic.colorScheme.light.text.color → --p-text-color +button.extend.disabledBackground → --p-button-extend-disabled-background +``` + +### Глобальные CSS-классы компонентов + +Типографика и кастомные стили — через `dt()`-функцию в `src/prime-preset/tokens/components/.ts`: + +```typescript +export const checkboxCss = ({ dt }: { dt: (token: string) => string }): string => ` + .checkbox-label { + color: ${dt('text.color')}; + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.300')}; + } +`; +``` + +Подключить в `map-tokens.ts`: +```typescript +import { checkboxCss } from './tokens/components/checkbox'; +// ... +checkbox: { ...tokens.components.checkbox, css: checkboxCss } +``` + +## Storybook — паттерны + +### Стилизация + +Вёрстка в story-примерах (`src/stories/`) — **только через Tailwind-классы**, без inline `style="..."`. + +```html + +
+ + +
+``` + +Не использовать `styles: [...]` в декораторе Angular-компонента — webpack кодирует CSS в base64 и вставляет в путь файла, что приводит к ошибке `File name too long`. Использовать `const styles = ''`. + +### Паттерн example-компонентов + +Каждый файл в `examples/` экспортирует **и компонент, и `StoryObj`**. `StoryObj` рендерит компонент по его selector, а `source.code` содержит полный TypeScript-код для копирования. + +```typescript +import { Component } from '@angular/core'; +import { StoryObj } from '@storybook/angular'; +import { XxxComponent } from '../../../../lib/components/xxx/xxx.component'; + +const template = ` +
+ +
+`; +const styles = ''; + +@Component({ + selector: 'app-xxx-variant', + standalone: true, + imports: [XxxComponent], + template, + styles, +}) +export class XxxVariantComponent {} + +export const Variant: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { story: 'Описание на русском.' }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { XxxComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-xxx-variant', + standalone: true, + imports: [XxxComponent], + template: \` + + \`, +}) +export class XxxVariantComponent {} + `, + }, + }, + }, +}; +``` + +В `{name}.stories.ts` вариационные сторисы **не дублируются** — только ре-экспортируются из example-файлов: + +```typescript +import { XxxVariantComponent, Variant } from './examples/xxx-variant.component'; +// ... +moduleMetadata({ imports: [XxxComponent, XxxVariantComponent] }) +// ... +export { Variant }; // ← вместо inline StoryObj +``` + +`Default` остаётся в `{name}.stories.ts` как динамический render с `commonTemplate + args`. + +### Структура stories + +Каждая story — **один экземпляр компонента**, вариации через Controls: + +```typescript +export const Invalid: StoryObj = { + render: (args) => ({ + props: { ...args, checked: false }, + template: ``, + }), + args: { invalid: true }, +}; +``` + +`render: (args) => ({ props: args, template: '...' })` — обязательно для работы Controls. Wrapper-компоненты (`app-*`) допустимы только в `source.code` или со `parameters: { controls: { disable: true } }`. + +## Angular Form Components (ControlValueAccessor) + +### Паттерн: NgControl вместо NG_VALUE_ACCESSOR + +Компоненты, реализующие `ControlValueAccessor`, **не должны** иметь `@Input() invalid` и `@Input() disabled`. Состояние читается из `FormControl` через инъекцию `NgControl`. + +```typescript +// providers: [] — NG_VALUE_ACCESSOR не нужен + +constructor(@Optional() @Self() private ngControl: NgControl) { + if (ngControl) ngControl.valueAccessor = this; +} + +get isInvalid(): boolean { + return !!this.ngControl?.invalid; // реагирует на Validators +} + +get isDisabled(): boolean { + return this._disabled; // обновляется через setDisabledState +} + +private _disabled = false; + +setDisabledState(isDisabled: boolean): void { + this._disabled = isDisabled; +} +``` + +Поддерживает автоматически: `control.disable()`, `Validators.*`, `control.setErrors({...})`, `new FormControl({ value: false, disabled: true })`. + +### Альтернатива: NG_VALUE_ACCESSOR + полный ControlValueAccessor + +Если NgControl недоступен или нужна встроенная валидация через `Validator` интерфейс — использовать классический подход с регистрацией через DI: + +```typescript +@Component({ + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MyComponent), multi: true }, + { provide: NG_VALIDATORS, useExisting: forwardRef(() => MyComponent), multi: true }, + ] +}) +export class MyComponent implements ControlValueAccessor, Validator { + private onChange = (_: any) => {}; // инициализировать пустыми функциями! + private onTouched = () => {}; + + writeValue(value: any): void { /* обновить внутреннее состояние */ } + registerOnChange(fn: (v: any) => void): void { this.onChange = fn; } + registerOnTouched(fn: () => void): void { this.onTouched = fn; } + setDisabledState(isDisabled: boolean): void { /* enable/disable внутренние контролы */ } + + // Встроенная валидация — вызывается Angular Forms автоматически + validate(control: AbstractControl): ValidationErrors | null { + return /* null если ок, объект ошибок если нет */; + } + + // При изменении значения пользователем: + onUserChange(newValue: any): void { + this.onChange(newValue); // уведомить Forms + this.onTouched(); // пометить как touched + } +} +``` + +**Когда использовать NG_VALUE_ACCESSOR вместо NgControl:** +- Компонент содержит вложенный `FormGroup` (sub-form) и делегирует `writeValue` / `setDisabledState` вниз +- Нужна встроенная валидация через `validate()` без внешних валидаторов + +### Кастомные валидаторы + +#### Валидатор поля (ValidatorFn) + +```typescript +export function myValidator(config?: any): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (/* ок */) return null; + return { myError: true }; // или { myError: { details: '...' } } + }; +} + +// Применение: +new FormControl('', [Validators.required, myValidator()]) + +// Или с updateOn: +new FormControl('', { + validators: [Validators.required, myValidator()], + asyncValidators: [asyncCheck(service)], + updateOn: 'blur', // 'change' | 'blur' | 'submit' +}) +``` + +#### Асинхронный валидатор (AsyncValidatorFn) + +```typescript +export function asyncCheck(service: MyService): AsyncValidatorFn { + return (control: AbstractControl): Observable => + service.check(control.value).pipe( + map(exists => exists ? { alreadyExists: true } : null) + ); +} +``` + +Пока async-валидатор выполняется Angular добавляет класс `ng-pending`. Использовать `updateOn: 'blur'` чтобы не делать HTTP-запрос на каждый символ. + +#### Валидатор группы (form-level) + +```typescript +export function dateRangeValidator(): ValidatorFn { + return (group: AbstractControl): ValidationErrors | null => { + const start = group.get('startDate')?.value; + const end = group.get('endDate')?.value; + return start && end && start > end ? { dateRange: true } : null; + }; +} + +// Применение на уровне FormGroup: +this.fb.group({ startDate: [''], endDate: [''] }, { validators: dateRangeValidator() }) +``` + +#### Директива-валидатор для template-driven форм + +```typescript +@Directive({ + selector: '[myValidator]', + providers: [{ provide: NG_VALIDATORS, useExisting: MyValidatorDirective, multi: true }] +}) +export class MyValidatorDirective implements Validator { + validate(control: AbstractControl): ValidationErrors | null { + return myValidator()(control); + } +} +``` + +Без `NG_VALIDATORS` в providers метод `validate()` не будет вызван. + +#### Отображение ошибок в шаблоне + +```html +
Сообщение об ошибке
+``` + +### Вложенный sub-form компонент + +```typescript +export class AddressFormComponent implements ControlValueAccessor, Validator, OnDestroy { + form = new FormGroup({ street: new FormControl(''), city: new FormControl('') }); + private sub = this.form.valueChanges.subscribe(v => this.onChange(v)); + + writeValue(v: any): void { if (v) this.form.setValue(v, { emitEvent: false }); } + setDisabledState(d: boolean) { d ? this.form.disable() : this.form.enable(); } + validate(): ValidationErrors | null { return this.form.valid ? null : { address: this.form.errors }; } + ngOnDestroy(): void { this.sub.unsubscribe(); } +} +``` + +### Stories для form-компонентов + +```typescript +// Disabled +control = new FormControl({ value: false, disabled: true }); + +// Invalid (через валидатор) +control = new FormControl(false, [Validators.requiredTrue]); +``` + +Импортировать `ReactiveFormsModule` (не `FormsModule`) в `imports` примера-компонента. + +### Storybook argTypes для CVA-компонентов + +CVA-компоненты, использующие NgControl-паттерн, **не имеют** `@Input() disabled` и `@Input() invalid`. Эти поля отсутствуют в публичном API компонента, поэтому их **нельзя включать** в `argTypes` и `args` в `{name}.stories.ts` — TypeScript выдаст ошибку `TS2561: Object literal may only specify known properties`. + +```typescript +// ❌ disabled не является @Input() — TS-ошибка +argTypes: { + disabled: { control: 'boolean' }, +} +args: { disabled: false } + +// ✅ disabled управляется только через FormControl — выделить в отдельный пример-стори +``` + +Для демонстрации disabled-состояния использовать отдельный example-компонент с `[formControl]="control"`, где `control = new FormControl({ value: ..., disabled: true })`. + +## PrimeNG Angular vs PrimeVue: p-focus и активное состояние + +В PrimeNG Angular класс `p-focus` на элементах меню управляется через `focusedItem` signal. При потере фокуса (`blur`) PrimeNG вызывает `focusedItem.set(null)` — класс `p-focus` сбрасывается. В PrimeVue этого не происходит, поэтому CSS из Vue-версии с `p-focus` нельзя переносить 1-в-1. + +### Решение: кастомный класс + ngAfterViewChecked + +Для компонентов меню, где нужно сохранять визуальное выделение кликнутого пункта: + +1. Сохранять `id` кликнутого `
  • ` в поле компонента через `@HostListener('click')` +2. Игнорировать клики по заголовкам панелей (`.p-panelmenu-header`) +3. Проставлять кастомный CSS-класс (например `p-panelmenu-item-active`) по `id` в `ngAfterViewChecked` — это гарантирует восстановление класса после перерисовки DOM PrimeNG (анимации раскрытия подменю) + +```typescript +export class PanelMenuComponent implements AfterViewChecked { + private activeItemId: string | null = null; + + constructor(private readonly el: ElementRef) {} + + @HostListener('click', ['$event']) + onItemClick(event: MouseEvent): void { + if ((event.target as Element).closest('.p-panelmenu-header')) return; + const item = (event.target as Element).closest('.p-panelmenu-item'); + if (!item) return; + this.activeItemId = item.id || null; + this.applyActiveClass(); + } + + ngAfterViewChecked(): void { + if (this.activeItemId) this.applyActiveClass(); + } + + private applyActiveClass(): void { + const root = this.el.nativeElement; + root.querySelectorAll('.p-panelmenu-item-active') + .forEach(el => el.classList.remove('p-panelmenu-item-active')); + if (this.activeItemId) { + root.querySelector(`#${CSS.escape(this.activeItemId)}`) + ?.classList.add('p-panelmenu-item-active'); + } + } +} +``` + +В CSS: стили для кастомного класса `p-panelmenu-item-active` + оставлять `p-focus` для клавиатурной навигации. + +## PrimeNG 20: кастомные шаблоны через pTemplate + +В PrimeNG 20 передача кастомного шаблона в компонент (Listbox, Select и др.) требует директивы `pTemplate`, а не hash-reference (`#option`): + +```html + + + ... + + + + + ... + +``` + +Имена шаблонов (`"item"`, `"group"`, `"header"` и др.) — см. в `node_modules/primeng//index.d.ts`, поле `_itemTemplate`, `_groupTemplate` и т.д. + +Для использования `pTemplate` нужно импортировать `SharedModule` из `primeng/api`: + +```typescript +import { SharedModule } from 'primeng/api'; + +@Component({ + imports: [Listbox, SharedModule, ReactiveFormsModule], +}) +``` + +### Кастомный шаблон элемента списка (Listbox Custom) + +Иконка и текстовый блок — **прямые потомки слота** (`ng-template`). Flex-раскладку обеспечивает `.p-listbox-option` из CSS: + +```html + + +
    + {{ item.name }} + {{ item.description }} +
    +
    +``` + +CSS для `.p-listbox-option`: +- По умолчанию `align-items: center` — для однострочных элементов (checkmark, обычный список) +- Только при наличии `.p-listbox-option-label-group` → `align-items: flex-start` (многострочный контент: иконка + заголовок + подпись) + +```css +.p-listbox-option { align-items: center; } +.p-listbox-option:has(.p-listbox-option-label-group) { align-items: flex-start; } +``` + +Подпись (`.p-listbox-option-caption`) использует `fonts.fontFamily.heading`. + +## Высота превью для компонентов с выпадающими списками + +Компоненты с выпадающими подменю (Menubar, TieredMenu и др.) требуют минимальной высоты превью, чтобы раскрытое подменю не обрезалось. Оборачивать в `
    ` в **template компонента-примера**, но **не включать** эту обёртку в `source.code`: + +```typescript +// template компонента (рендерится в Storybook) +const template = ` +
    + +
    +`; + +// source.code (копируется пользователем) — без обёртки +code: ` + template: \\\` + + \\\`, +`, +``` + +## Vue-референс: структура и маппинг + +При создании компонента искать Vue-референс по формуле: +- Ветка: `menu.` (или `misc.`, `panel.`) +- Stories: `src/plugins/prime/stories/Menu//` +- CSS-токены: `src/plugins/prime/theme3.0/components/css/.ts` +- Файлы: `.stories.js`, `.template.js`, `.mdx` + +Дерево файлов ветки можно получить через GitHub API: +``` +https://api.github.com/repos/cdek-it/vue-ui-kit/git/trees/?recursive=1 +``` + +Raw-содержимое файла: +``` +https://raw.githubusercontent.com/cdek-it/vue-ui-kit// +``` + +## Кастомный шаблон Menubar (pTemplate="item") + +Для кастомного отображения пунктов меню с description и badge используется `pTemplate="item"` с контекстными переменными `hasSubmenu` и `root`: + +```html + + + + + + + + + + + +``` + +- `description` — произвольное поле `MenuItem` (через `[key: string]: any`), **не** `caption` (caption — имя CSS-класса) +- `hasSubmenu` / `root` — контекстные переменные PrimeNG, определяют направление стрелки подменю +- Импорты: `Menubar` из `primeng/menubar`, `Badge` из `primeng/badge`, `SharedModule` из `primeng/api`, `NgIf` из `@angular/common` + +## PrimeNG: составные компоненты и ng-content (DI-ограничение) + +PrimeNG-субкомпоненты (например `Tab`, `TabList`, `TabPanel`) инжектят родительский компонент (`Tabs`) через Angular DI. При использовании `ng-content` в обёртке Angular ищет провайдер в **объявляющем** компоненте (story/example), а не в **проецирующем** (`p-tabs`) — это приводит к ошибке `NG0201: No provider found`. + +### Решение: data-driven обёртка + +Вместо `ng-content` принимать данные через `@Input()` и рендерить все PrimeNG-субкомпоненты **внутри шаблона** обёртки: + +```typescript +// ❌ ng-content — субкомпоненты не видят провайдер Tabs +template: ` + + + +` + +// ✅ data-driven — все субкомпоненты внутри view +template: ` + + + + + {{ tab.label }} + + + + +

    {{ tab.content }}

    +
    +
    +
    +` +``` + +Это касается **любых** PrimeNG-компонентов, где субкомпоненты инжектят родителя: `Tabs`, `Stepper`, `Splitter` и др. + +## Storybook: динамическая генерация template в Default story + +Default story **обязательно** строит template динамически из `args`, чтобы: +1. Изменение Controls обновляло рендер компонента +2. Code-snippet в Docs автоматически отражал текущие пропсы + +```typescript +export const Default: Story = { + render: (args) => { + const parts: string[] = []; + + parts.push(`value="${args.value}"`); + parts.push(`[tabs]="tabs"`); + if (args.scrollable) parts.push(`[scrollable]="true"`); + if (args.lazy) parts.push(`[lazy]="true"`); + + const template = ``; + + return { props: args, template }; + }, + args: { value: '0', tabs: [...], scrollable: false, lazy: false }, +}; +``` + +**Не** использовать статический template с биндингами — Controls будут работать, но code-snippet не обновится. + +## PrimeNG: styleClass vs ngClass для CSS-классов размеров + +В некоторых PrimeNG-компонентах (например `SelectButton`) проп `[styleClass]` **передаётся на дочерние элементы** (внутренние `p-togglebutton`), а **не на корневой DOM-элемент** компонента. Это означает, что CSS-классы размеров (`.p-selectbutton-sm`, `.p-selectbutton-lg` и т.д.), которые должны влиять на корневой элемент через CSS-каскад, не работают при передаче через `styleClass`. + +### Решение: использовать `[ngClass]` вместо `[styleClass]` + +```html + + + + + +``` + +Это правило применяется ко всем компонентам-обёрткам, где классы размеров описаны в CSS как: + +```css +/* Стили привязаны к корневому элементу */ +.p-selectbutton-sm .p-togglebutton { height: ...; } +.p-selectbutton-lg .p-togglebutton { height: ...; } +``` + +В таких случаях класс **должен быть на корневом элементе** — добавляй его через `[ngClass]`, а не `[styleClass]`. + +> **Правило:** если CSS-класс используется как модификатор (размер, вариант) и задан на уровне корневого PrimeNG-компонента в стилях — всегда используй `[ngClass]`. `[styleClass]` используй только тогда, когда PrimeNG явно документирует его применение к корню. + +## Git + +- Коммиты без `Co-Authored-By: Claude ...` +- Сообщение коммита — до 100 символов, по одному на каждый комментарий в PR +- **Пуш не делать без явной команды пользователя** +- **Файлы `docs/superpowers/` (спеки, планы) не коммитить** diff --git a/.gitignore b/.gitignore index 875d7dc9..863dd859 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,4 @@ src/assets/components/themes .claude/* -.playwright-mcp/* \ No newline at end of file +.playwright-mcp/* diff --git a/docs/superpowers/specs/2026-04-16-togglebutton-design.md b/docs/superpowers/specs/2026-04-16-togglebutton-design.md new file mode 100644 index 00000000..7c5323c7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-togglebutton-design.md @@ -0,0 +1,134 @@ +# ToggleButton — Дизайн-документ + +**Дата:** 2026-04-16 +**Ветка:** `form.togglebutton` + +## Цель + +Добавить компонент `ToggleButton` в Angular UI Kit: обёртку над PrimeNG `p-togglebutton` с кастомной стилизацией и Storybook-историями по образцу [Vue-референса](https://github.com/cdek-it/vue-ui-kit/tree/togglebutton). + +--- + +## Файлы + +| Файл | Назначение | +|---|---| +| `src/lib/components/togglebutton/togglebutton.component.ts` | Компонент-обёртка | +| `src/prime-preset/tokens/components/togglebutton.ts` | CSS через `dt()` | +| `src/prime-preset/map-tokens.ts` | Подключение CSS (изменить) | +| `src/stories/components/togglebutton/togglebutton.stories.ts` | Default + ре-экспорты | +| `src/stories/components/togglebutton/examples/togglebutton-sizes.component.ts` | Сетка размеров | +| `src/stories/components/togglebutton/examples/togglebutton-icons.component.ts` | С иконками | +| `src/stories/components/togglebutton/examples/togglebutton-icon-only.component.ts` | Icon-only | +| `src/stories/components/togglebutton/examples/togglebutton-disabled.component.ts` | Disabled | + +--- + +## Компонент + +**Селектор:** `toggle-button` +**Паттерн:** `NG_VALUE_ACCESSOR` (аналог `CheckboxComponent`) + +### Типы + +```typescript +export type ToggleButtonSize = 'sm' | 'base' | 'lg' | 'xlg'; +``` + +### Inputs + +| Prop | Тип | Default | Описание | +|---|---|---|---| +| `onLabel` | `string` | `'Вкл'` | Текст в активном состоянии | +| `offLabel` | `string` | `'Выкл'` | Текст в неактивном состоянии | +| `onIcon` | `string \| undefined` | `undefined` | Иконка в активном состоянии | +| `offIcon` | `string \| undefined` | `undefined` | Иконка в неактивном состоянии | +| `iconPos` | `'left' \| 'right'` | `'left'` | Позиция иконки | +| `size` | `ToggleButtonSize` | `'base'` | Размер кнопки | +| `disabled` | `boolean` | `false` | Отключённое состояние | +| `iconOnly` | `boolean` | `false` | Скрывает label, делает кнопку квадратной | +| `allowEmpty` | `boolean \| undefined` | `undefined` | Разрешить снять выбор | +| `fluid` | `boolean` | `false` | Растянуть на всю ширину | +| `ariaLabel` | `string \| undefined` | `undefined` | Aria-label | +| `ariaLabelledBy` | `string \| undefined` | `undefined` | Aria-labelledby | +| `inputId` | `string \| undefined` | `undefined` | ID input-элемента | +| `tabindex` | `number \| undefined` | `undefined` | Tab index | +| `autofocus` | `boolean \| undefined` | `undefined` | Автофокус | + +### Outputs + +| Event | Тип | Описание | +|---|---|---| +| `onChange` | `EventEmitter` | Изменение значения | + +### Маппинг размеров в PrimeNG + +| Наш `size` | PrimeNG `size` | Дополнительный класс | +|---|---|---| +| `'sm'` | `'small'` | — | +| `'base'` | `undefined` | — | +| `'lg'` | `'large'` | — | +| `'xlg'` | `'large'` | `p-togglebutton-xlg` | + +- `iconOnly=true` → добавляет класс `p-togglebutton-icon-only` через `[ngClass]` +- Все дополнительные классы применяются через `[ngClass]` на `` + +--- + +## CSS-стилизация (`togglebutton.ts`) + +Переносим Vue-референс (`togglebutton.ts`) с адаптацией синтаксиса на Angular: + +1. **Типографика:** `fonts.fontFamily.heading`, `fonts.fontWeight.demibold`, `fonts.lineHeight.50` +2. **Hover unchecked:** `togglebutton.extend.hoverBorderColor` +3. **Hover checked:** `togglebutton.extend.checkedHoverBackground/BorderColor/Color` +4. **Размер sm:** `line-height.30`, icon `iconSize.sm` +5. **Размер base:** icon `iconSize.md` +6. **Размер lg:** `line-height.55`, gap, icon `iconSize.lg` +7. **Размер xlg:** кастомный padding, font-size, align-items, icon `iconSize.lg` +8. **Icon-only base:** `iconOnlyWidth` px width/height, `border-radius`, скрытый label +9. **Icon-only sm/lg/xlg:** отдельные `iconOnlyWidth` из `extSm`/`extLg`/`extXlg` + +Все токены уже присутствуют в `tokens.json` → доработки JSON не требуются. + +--- + +## Stories + +### Default + +Динамический template из `args`. Controls: + +| Control | Тип | +|---|---| +| `size` | select: `'sm' \| 'base' \| 'lg' \| 'xlg'` | +| `onLabel` / `offLabel` | text | +| `onIcon` / `offIcon` | text | +| `iconPos` | select: `'left' \| 'right'` | +| `disabled` | boolean | +| `iconOnly` | boolean | +| `fluid` | boolean | + +### Sizes + +CSS Grid (4 строки × 3 колонки). Строки = sm, base, lg, xlg. Колонки = unchecked, checked, disabled. Используются несколько `` с `[(ngModel)]` и `[disabled]`. + +### Icons + +Примеры: `onIcon`/`offIcon` с разными иконками, `iconPos="right"`. + +### IconOnly + +Сетка из 4 размеров, icon-only, unchecked/checked. + +### Disabled + +Отдельный компонент с `[disabled]="true"` и `[disabled]="true"` + `modelValue=true`. + +--- + +## Ограничения + +- `styleClass` на `p-togglebutton` задепрекейчен в PrimeNG v20 → используем `[ngClass]` +- Класс `p-togglebutton-xlg` не нативный PrimeNG — только наш кастомный CSS +- В stories нет `styles: [...]` — используем `const styles = ''` согласно CLAUDE.md diff --git a/src/lib/components/inputtext/inputtext.component.ts b/src/lib/components/inputtext/inputtext.component.ts index 3904b924..d820eec1 100644 --- a/src/lib/components/inputtext/inputtext.component.ts +++ b/src/lib/components/inputtext/inputtext.component.ts @@ -25,7 +25,7 @@ export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge'; ToggleButtonComponent), + multi: true, + }, + ], + template: ` + + `, +}) +export class ToggleButtonComponent implements ControlValueAccessor { + @Input() onLabel = 'Вкл'; + @Input() offLabel = 'Выкл'; + @Input() onIcon: string | undefined = undefined; + @Input() offIcon: string | undefined = undefined; + @Input() iconPos: 'left' | 'right' = 'left'; + @Input() size: ToggleButtonSize = 'base'; + @Input() disabled = false; + @Input() iconOnly = false; + @Input() allowEmpty: boolean | undefined = undefined; + @Input() fluid = false; + @Input() ariaLabel: string | undefined = undefined; + @Input() ariaLabelledBy: string | undefined = undefined; + @Input() inputId: string | undefined = undefined; + @Input() tabindex: number | undefined = undefined; + @Input() autofocus: boolean | undefined = undefined; + + @Output() onChange = new EventEmitter(); + + modelValue = false; + + private _onChange: (value: any) => void = () => {}; + private _onTouched: () => void = () => {}; + + get primeSize(): 'small' | 'large' | undefined { + if (this.size === 'sm') return 'small'; + if (this.size === 'lg' || this.size === 'xlg') return 'large'; + return undefined; + } + + get extraClasses(): Record { + return { + 'p-togglebutton-xlg': this.size === 'xlg', + 'p-togglebutton-icon-only': this.iconOnly, + }; + } + + onChangeHandler(event: ToggleButtonChangeEvent): void { + this._onChange(event.checked); + this._onTouched(); + this.onChange.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 16a3568d..7ed12738 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -14,6 +14,8 @@ import { timelineCss } from './tokens/components/timeline'; import { tooltipCss } from './tokens/components/tooltip'; import { megamenuCss } from './tokens/components/megamenu'; import { selectCss } from './tokens/components/select'; +import { messageCss } from './tokens/components/message'; +import { togglebuttonCss } from './tokens/components/togglebutton'; const presetTokens: Preset = { primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'], @@ -68,6 +70,10 @@ const presetTokens: Preset = { ...(tokens.components.select as unknown as ComponentsDesignTokens['select']), css: selectCss, }, + togglebutton: { + ...(tokens.components.togglebutton as unknown as ComponentsDesignTokens['togglebutton']), + css: togglebuttonCss, + }, } as ComponentsDesignTokens, }; diff --git a/src/prime-preset/tokens/components/togglebutton.ts b/src/prime-preset/tokens/components/togglebutton.ts new file mode 100644 index 00000000..7bd17d6b --- /dev/null +++ b/src/prime-preset/tokens/components/togglebutton.ts @@ -0,0 +1,95 @@ +export const togglebuttonCss = ({ dt }: { dt: (token: string) => string }): string => ` + +/* ─── Типографика ─── */ +.p-togglebutton.p-component { + font-family: ${dt('fonts.fontFamily.heading')}; + font-weight: ${dt('fonts.fontWeight.demibold')}; + line-height: ${dt('fonts.lineHeight.500')}; +} + +/* ─── Hover unchecked ─── */ +.p-togglebutton:not(:disabled):not(.p-togglebutton-checked):hover { + border-color: ${dt('togglebutton.extend.hoverBorderColor')}; +} + +/* ─── Hover checked ─── */ +.p-togglebutton.p-togglebutton-checked:hover { + background: ${dt('togglebutton.extend.checkedHoverBackground')}; + border-color: ${dt('togglebutton.extend.checkedHoverBorderColor')}; + color: ${dt('togglebutton.extend.checkedHoverColor')}; +} + +/* ─── Small ─── */ +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-sm { + line-height: ${dt('fonts.lineHeight.300')}; +} +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-sm .p-togglebutton-label { + line-height: ${dt('fonts.lineHeight.250')}; +} +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-sm .p-togglebutton-icon { + font-size: ${dt('togglebutton.extend.iconSize.sm')}; +} + +/* ─── Base (default) ─── */ +.p-togglebutton.p-component:not(.p-togglebutton-sm):not(.p-togglebutton-lg):not(.p-togglebutton-xlg) .p-togglebutton-icon { + font-size: ${dt('togglebutton.extend.iconSize.md')}; +} + +/* ─── Large ─── */ +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-lg { + line-height: ${dt('fonts.lineHeight.550')}; + gap: ${dt('togglebutton.root.gap')}; +} +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-lg .p-togglebutton-content { + gap: ${dt('togglebutton.extend.ext.gap')}; +} +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-lg .p-togglebutton-icon { + font-size: ${dt('togglebutton.extend.iconSize.lg')}; +} + +/* ─── Extra Large ─── */ +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-xlg { + padding: ${dt('togglebutton.extend.extXlg.padding')}; + font-size: ${dt('fonts.fontSize.500')}; + line-height: ${dt('fonts.lineHeight.550')}; + display: inline-flex; + align-items: center; + justify-content: center; + gap: ${dt('togglebutton.root.gap')}; +} +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-xlg .p-togglebutton-content { + gap: ${dt('togglebutton.extend.ext.gap')}; +} +.p-togglebutton.p-togglebutton.p-component.p-togglebutton-xlg .p-togglebutton-icon { + font-size: ${dt('togglebutton.extend.iconSize.lg')}; +} + +/* ─── Icon-only base ─── */ +.p-togglebutton.p-togglebutton-icon-only { + padding: 0; + width: ${dt('togglebutton.extend.iconOnlyWidth')}; + height: ${dt('togglebutton.extend.iconOnlyWidth')}; + border-radius: ${dt('togglebutton.root.borderRadius')}; +} +.p-togglebutton.p-togglebutton-icon-only .p-togglebutton-label { + display: none; +} + +/* ─── Icon-only sm ─── */ +.p-togglebutton.p-togglebutton-sm.p-togglebutton-icon-only { + width: ${dt('togglebutton.extend.extSm.iconOnlyWidth')}; + height: ${dt('togglebutton.extend.extSm.iconOnlyWidth')}; +} + +/* ─── Icon-only lg ─── */ +.p-togglebutton.p-togglebutton-lg.p-togglebutton-icon-only { + width: ${dt('togglebutton.extend.extLg.iconOnlyWidth')}; + height: ${dt('togglebutton.extend.extLg.iconOnlyWidth')}; +} + +/* ─── Icon-only xlg ─── */ +.p-togglebutton.p-togglebutton-xlg.p-togglebutton-icon-only { + width: ${dt('togglebutton.extend.extXlg.iconOnlyWidth')}; + height: ${dt('togglebutton.extend.extXlg.iconOnlyWidth')}; +} +`; diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index bcae6d3c..359594ae 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -4867,7 +4867,7 @@ "checkedHoverBackground": "{surface.800}", "checkedHoverBorderColor": "{surface.800}", "extXlg": { - "padding": "{form.padding.500} {form.padding.500}", + "padding": "{form.padding.500} {form.padding.600}", "iconOnlyWidth": "4.0714rem" }, "extSm": { diff --git a/src/stories/components/togglebutton/examples/togglebutton-disabled.component.ts b/src/stories/components/togglebutton/examples/togglebutton-disabled.component.ts new file mode 100644 index 00000000..b274b5e0 --- /dev/null +++ b/src/stories/components/togglebutton/examples/togglebutton-disabled.component.ts @@ -0,0 +1,59 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { ToggleButtonComponent } from '../../../../lib/components/togglebutton/togglebutton.component'; + +const template = ` + +`; +const styles = ''; + +@Component({ + selector: 'app-togglebutton-disabled', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template, + styles, +}) +export class ToggleButtonDisabledComponent { + checked = false; +} + +export const Disabled: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Отключённое состояние через `[disabled]="true"`.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { ToggleButtonComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-togglebutton-disabled', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template: \` + + \`, +}) +export class ToggleButtonDisabledComponent { + checked = false; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/togglebutton/examples/togglebutton-icon-only.component.ts b/src/stories/components/togglebutton/examples/togglebutton-icon-only.component.ts new file mode 100644 index 00000000..0d3ef258 --- /dev/null +++ b/src/stories/components/togglebutton/examples/togglebutton-icon-only.component.ts @@ -0,0 +1,64 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { ToggleButtonComponent } from '../../../../lib/components/togglebutton/togglebutton.component'; + +const template = ` + +`; +const styles = ''; + +@Component({ + selector: 'app-togglebutton-icon-only', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template, + styles, +}) +export class ToggleButtonIconOnlyComponent { + checked = false; +} + +export const IconOnly: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Icon-only вариант: квадратная кнопка без текста. Размер регулируется через `size`.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { ToggleButtonComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-togglebutton-icon-only', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template: \` + + \`, +}) +export class ToggleButtonIconOnlyComponent { + checked = false; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/togglebutton/examples/togglebutton-icons.component.ts b/src/stories/components/togglebutton/examples/togglebutton-icons.component.ts new file mode 100644 index 00000000..28faa9e5 --- /dev/null +++ b/src/stories/components/togglebutton/examples/togglebutton-icons.component.ts @@ -0,0 +1,66 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { ToggleButtonComponent } from '../../../../lib/components/togglebutton/togglebutton.component'; + +const template = ` + +`; +const styles = ''; + +@Component({ + selector: 'app-togglebutton-icons', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template, + styles, +}) +export class ToggleButtonIconsComponent { + checked = false; +} + +export const Icons: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Кнопка с иконками через `onIcon`/`offIcon`. Позиция иконки управляется `iconPos`.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { ToggleButtonComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-togglebutton-icons', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template: \` + + \`, +}) +export class ToggleButtonIconsComponent { + checked = false; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/togglebutton/examples/togglebutton-sizes.component.ts b/src/stories/components/togglebutton/examples/togglebutton-sizes.component.ts new file mode 100644 index 00000000..56804215 --- /dev/null +++ b/src/stories/components/togglebutton/examples/togglebutton-sizes.component.ts @@ -0,0 +1,54 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { StoryObj } from '@storybook/angular'; +import { ToggleButtonComponent } from '../../../../lib/components/togglebutton/togglebutton.component'; + +const template = ` + +`; +const styles = ''; + +@Component({ + selector: 'app-togglebutton-sizes', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template, + styles, +}) +export class ToggleButtonSizesComponent { + checked = false; +} + +export const Sizes: StoryObj = { + render: () => ({ + template: ``, + }), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: 'Размер компонента задаётся через проп `size`: `sm`, `base`, `lg`, `xlg`.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { ToggleButtonComponent } from '@cdek-it/angular-ui-kit'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-togglebutton-sizes', + standalone: true, + imports: [ToggleButtonComponent, FormsModule], + template: \` + + \`, +}) +export class ToggleButtonSizesComponent { + checked = false; +} + `, + }, + }, + }, +}; diff --git a/src/stories/components/togglebutton/togglebutton.stories.ts b/src/stories/components/togglebutton/togglebutton.stories.ts new file mode 100644 index 00000000..89c1edbe --- /dev/null +++ b/src/stories/components/togglebutton/togglebutton.stories.ts @@ -0,0 +1,236 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { FormsModule } from '@angular/forms'; +import { ToggleButtonComponent } from '../../../lib/components/togglebutton/togglebutton.component'; + +type ToggleButtonArgs = ToggleButtonComponent; + +const meta: Meta = { + title: 'Components/Form/ToggleButton', + component: ToggleButtonComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [ToggleButtonComponent, FormsModule], + }), + ], + parameters: { + designTokens: { prefix: '--p-togglebutton' }, + docs: { + description: { + component: `Кнопка-переключатель для выбора булевого значения. Поддерживает размеры \`sm\`, \`base\`, \`lg\`, \`xlg\`, иконки и icon-only вариант.`, + }, + }, + }, + argTypes: { + // ── Props ──────────────────────────────────────────────── + onLabel: { + control: 'text', + description: 'Текст в активном состоянии', + table: { + category: 'Props', + defaultValue: { summary: "'Вкл'" }, + type: { summary: 'string' }, + }, + }, + offLabel: { + control: 'text', + description: 'Текст в неактивном состоянии', + table: { + category: 'Props', + defaultValue: { summary: "'Выкл'" }, + type: { summary: 'string' }, + }, + }, + onIcon: { + control: 'text', + description: 'CSS-класс иконки в активном состоянии', + table: { + category: 'Props', + type: { summary: 'string' }, + }, + }, + offIcon: { + control: 'text', + description: 'CSS-класс иконки в неактивном состоянии', + table: { + category: 'Props', + type: { summary: 'string' }, + }, + }, + iconPos: { + control: 'select', + options: ['left', 'right'], + description: 'Позиция иконки', + table: { + category: 'Props', + defaultValue: { summary: "'left'" }, + type: { summary: "'left' | 'right'" }, + }, + }, + size: { + control: 'select', + options: ['sm', 'base', 'lg', 'xlg'], + description: 'Размер кнопки', + table: { + category: 'Props', + defaultValue: { summary: "'base'" }, + type: { summary: "'sm' | 'base' | 'lg' | 'xlg'" }, + }, + }, + disabled: { + control: 'boolean', + description: 'Отключает взаимодействие', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + iconOnly: { + control: 'boolean', + description: 'Скрывает label, делает кнопку квадратной', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + fluid: { + control: 'boolean', + description: 'Растягивает кнопку на всю ширину контейнера', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + // Hidden props + allowEmpty: { table: { disable: true } }, + ariaLabel: { table: { disable: true } }, + ariaLabelledBy: { table: { disable: true } }, + inputId: { table: { disable: true } }, + tabindex: { table: { disable: true } }, + autofocus: { table: { disable: true } }, + modelValue: { table: { disable: true } }, + primeSize: { table: { disable: true } }, + extraClasses: { table: { disable: true } }, + + // ── Events ─────────────────────────────────────────────── + onChange: { + control: false, + description: 'Событие изменения значения', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + }, + args: { + onLabel: 'Вкл', + offLabel: 'Выкл', + size: 'base', + disabled: false, + iconOnly: false, + fluid: false, + }, +}; + +export default meta; +type Story = StoryObj; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function buildTemplate(args: any): string { + const parts: string[] = []; + + parts.push(`onLabel="${args.onLabel}"`); + parts.push(`offLabel="${args.offLabel}"`); + if (args.size && args.size !== 'base') parts.push(`size="${args.size}"`); + if (args.onIcon) parts.push(`onIcon="${args.onIcon}"`); + if (args.offIcon) parts.push(`offIcon="${args.offIcon}"`); + if (args.iconPos && args.iconPos !== 'left') parts.push(`iconPos="${args.iconPos}"`); + if (args.disabled) parts.push(`[disabled]="true"`); + if (args.iconOnly) parts.push(`[iconOnly]="true"`); + if (args.fluid) parts.push(`[fluid]="true"`); + parts.push(`[(ngModel)]="checked"`); + + return ``; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function renderStory(args: any) { + return { props: { ...args, checked: false }, template: buildTemplate(args) }; +} + +// ── Default ────────────────────────────────────────────────────────────────── +export const Default: Story = { + name: 'Default', + render: (args) => renderStory(args), + parameters: { + docs: { + description: { + story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.', + }, + }, + }, +}; + +// ── Sizes ──────────────────────────────────────────────────────────────────── +export const Sizes: Story = { + render: (args) => renderStory(args), + args: { size: 'xlg' }, + parameters: { + docs: { + description: { + story: 'Размер компонента задаётся через проп `size`: `sm`, `base`, `lg`, `xlg`.', + }, + }, + }, +}; + +// ── Icons ──────────────────────────────────────────────────────────────────── +export const Icons: Story = { + render: (args) => renderStory(args), + args: { + onLabel: 'Включено', + offLabel: 'Выключено', + onIcon: 'ti ti-check', + offIcon: 'ti ti-x', + }, + parameters: { + docs: { + description: { + story: 'Кнопка с иконками через `onIcon`/`offIcon`. Позиция иконки управляется `iconPos`.', + }, + }, + }, +}; + +// ── IconOnly ───────────────────────────────────────────────────────────────── +export const IconOnly: Story = { + render: (args) => renderStory(args), + args: { + onIcon: 'ti ti-star-filled', + offIcon: 'ti ti-star', + iconOnly: true, + }, + parameters: { + docs: { + description: { + story: 'Icon-only вариант: квадратная кнопка без текста. Размер регулируется через `size`.', + }, + }, + }, +}; + +// ── Disabled ───────────────────────────────────────────────────────────────── +export const Disabled: Story = { + render: (args) => renderStory(args), + args: { disabled: true }, + parameters: { + docs: { + description: { + story: 'Отключённое состояние через `[disabled]="true"`.', + }, + }, + }, +};