Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
600 changes: 600 additions & 0 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ src/assets/components/themes

.claude/*

.playwright-mcp/*
.playwright-mcp/*
134 changes: 134 additions & 0 deletions docs/superpowers/specs/2026-04-16-togglebutton-design.md
Original file line number Diff line number Diff line change
@@ -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<ToggleButtonChangeEvent>` | Изменение значения |

### Маппинг размеров в PrimeNG

| Наш `size` | PrimeNG `size` | Дополнительный класс |
|---|---|---|
| `'sm'` | `'small'` | — |
| `'base'` | `undefined` | — |
| `'lg'` | `'large'` | — |
| `'xlg'` | `'large'` | `p-togglebutton-xlg` |

- `iconOnly=true` → добавляет класс `p-togglebutton-icon-only` через `[ngClass]`
- Все дополнительные классы применяются через `[ngClass]` на `<p-togglebutton>`

---

## 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. Используются несколько `<toggle-button>` с `[(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
4 changes: 2 additions & 2 deletions src/lib/components/inputtext/inputtext.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge';
<input
pInputText
[ngClass]="sizeClass"
[pSize]="primeSize"
[pSize]="primeSize!"
[disabled]="disabled"
[readOnly]="readonly"
[invalid]="invalid"
Expand All @@ -49,7 +49,7 @@ export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge';
<input
pInputText
[ngClass]="sizeClass"
[pSize]="primeSize"
[pSize]="primeSize!"
[disabled]="disabled"
[readOnly]="readonly"
[invalid]="invalid"
Expand Down
100 changes: 100 additions & 0 deletions src/lib/components/togglebutton/togglebutton.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, forwardRef } from '@angular/core';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgClass } from '@angular/common';
import { ToggleButton, ToggleButtonChangeEvent } from 'primeng/togglebutton';

export type ToggleButtonSize = 'sm' | 'base' | 'lg' | 'xlg';

@Component({
selector: 'toggle-button',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change detection: OnPush

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Готово — f7ac940

standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ToggleButton, NgClass, FormsModule],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ToggleButtonComponent),
multi: true,
},
],
template: `
<p-togglebutton
[ngClass]="extraClasses"
[onLabel]="onLabel"
[offLabel]="offLabel"
[onIcon]="onIcon"
[offIcon]="offIcon"
[iconPos]="iconPos"
[size]="primeSize!"
[disabled]="disabled"
[allowEmpty]="allowEmpty"
[fluid]="fluid"
[ariaLabel]="ariaLabel"
[ariaLabelledBy]="ariaLabelledBy"
[inputId]="inputId"
[tabindex]="tabindex"
[autofocus]="autofocus"
[(ngModel)]="modelValue"
(onChange)="onChangeHandler($event)"
></p-togglebutton>
`,
})
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<ToggleButtonChangeEvent>();

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<string, boolean> {
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;
}
}
6 changes: 6 additions & 0 deletions src/prime-preset/map-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuraBaseDesignTokens> = {
primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'],
Expand Down Expand Up @@ -68,6 +70,10 @@ const presetTokens: Preset<AuraBaseDesignTokens> = {
...(tokens.components.select as unknown as ComponentsDesignTokens['select']),
css: selectCss,
},
togglebutton: {
...(tokens.components.togglebutton as unknown as ComponentsDesignTokens['togglebutton']),
css: togglebuttonCss,
},
} as ComponentsDesignTokens,
};

Expand Down
95 changes: 95 additions & 0 deletions src/prime-preset/tokens/components/togglebutton.ts
Original file line number Diff line number Diff line change
@@ -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')};
}
`;
2 changes: 1 addition & 1 deletion src/prime-preset/tokens/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading