diff --git a/projects/cdk/constants/used-icons.ts b/projects/cdk/constants/used-icons.ts index 3cc8794ce6a3..ecb56346750f 100644 --- a/projects/cdk/constants/used-icons.ts +++ b/projects/cdk/constants/used-icons.ts @@ -46,6 +46,8 @@ export const TUI_USED_ICONS = [ '@tui.file', '@tui.trash', '@tui.phone', + '@tui.heart', + '@tui.heart-filled', '@tui.star', '@tui.calendar', '@tui.rotate-ccw-square', diff --git a/projects/demo-playwright/tests/kit/like/like.spec.ts b/projects/demo-playwright/tests/kit/like/like.spec.ts new file mode 100644 index 000000000000..e78db687c6a8 --- /dev/null +++ b/projects/demo-playwright/tests/kit/like/like.spec.ts @@ -0,0 +1,70 @@ +import {DemoRoute} from '@demo/routes'; +import {TuiDocumentationPagePO, tuiGoto} from '@demo-playwright/utils'; +import {expect, test} from '@playwright/test'; + +test.describe('TuiLike', () => { + test.describe('Examples', () => { + test('Basic like', async ({page}) => { + await tuiGoto(page, DemoRoute.Like); + const example = new TuiDocumentationPagePO(page).getExample('#basic'); + + await example.locator('[tuiLike]').first().click(); + + await expect(example).toHaveScreenshot('01-basic-like.png'); + }); + + test('External icon loads', async ({page}) => { + await tuiGoto(page, DemoRoute.Like); + const example = new TuiDocumentationPagePO(page).getExample( + '#external-icons', + ); + + await example.locator('[tuiLike]').first().click(); + + await expect(example).toHaveScreenshot('02-external-icon-like.png'); + }); + + test('Other appearances hover', async ({page}) => { + await tuiGoto(page, DemoRoute.Like); + const example = new TuiDocumentationPagePO(page).getExample( + '#other-appearances', + ); + + await example.locator('[tuiLike]').nth(1).hover(); + + await expect(example).toHaveScreenshot('03-other-appearances-hover-like.png'); + }); + + test('Other appearances click', async ({page}) => { + await tuiGoto(page, DemoRoute.Like); + const example = new TuiDocumentationPagePO(page).getExample( + '#other-appearances', + ); + + await example.locator('[tuiLike]').nth(1).click(); + + await expect(example).toHaveScreenshot('04-other-appearances-click-like.png'); + }); + + test('With forms click', async ({page}) => { + await tuiGoto(page, DemoRoute.Like); + const example = new TuiDocumentationPagePO(page).getExample('#with-forms'); + + await example.locator('[tuiLike]').nth(0).click(); + + await expect(example).toHaveScreenshot( + '05-with-forms-ngModel-click-like.png', + ); + + await example.locator('[tuiLike]').nth(1).click(); + + await expect(example).toHaveScreenshot( + '06-with-forms-reactive-forms-click-like.png', + ); + + await example.locator('[tuiButton]').click(); + + await expect(example).toHaveScreenshot('07-with-forms-click-toggle.png'); + }); + }); +}); diff --git a/projects/demo/src/modules/app/app.routes.ts b/projects/demo/src/modules/app/app.routes.ts index 592439abb41d..da38a03588ea 100644 --- a/projects/demo/src/modules/app/app.routes.ts +++ b/projects/demo/src/modules/app/app.routes.ts @@ -479,6 +479,11 @@ export const ROUTES: Routes = [ loadComponent: async () => import('../components/line-clamp'), title: 'LineClamp', }), + route({ + path: DemoRoute.Like, + loadComponent: async () => import('../components/like'), + title: 'Like', + }), route({ path: DemoRoute.Link, loadComponent: async () => import('../components/link'), diff --git a/projects/demo/src/modules/app/demo-routes.ts b/projects/demo/src/modules/app/demo-routes.ts index 97d819ed51ea..e2f330554d80 100644 --- a/projects/demo/src/modules/app/demo-routes.ts +++ b/projects/demo/src/modules/app/demo-routes.ts @@ -117,6 +117,7 @@ export const DemoRoute = { Status: '/components/status', Stepper: '/navigation/stepper', Preview: '/components/preview', + Like: '/components/like', AppBar: '/navigation/app-bar', TabBar: '/navigation/tab-bar', Tabs: '/navigation/tabs', diff --git a/projects/demo/src/modules/app/pages.ts b/projects/demo/src/modules/app/pages.ts index 9d38332969bf..bfbcb7df8e91 100644 --- a/projects/demo/src/modules/app/pages.ts +++ b/projects/demo/src/modules/app/pages.ts @@ -601,6 +601,12 @@ export const pages: TuiDocRoutePages = [ keywords: 'лэйбл, метка, форма, label', route: DemoRoute.Label, }, + { + section: 'Components', + title: 'Like', + keywords: 'like, лайк, эмодзи, смайлик, стикер', + route: DemoRoute.Like, + }, { section: 'Components', title: 'LineClamp', diff --git a/projects/demo/src/modules/components/like/examples/1/index.html b/projects/demo/src/modules/components/like/examples/1/index.html new file mode 100644 index 000000000000..e319c40e503a --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/1/index.html @@ -0,0 +1,12 @@ + + + diff --git a/projects/demo/src/modules/components/like/examples/1/index.ts b/projects/demo/src/modules/components/like/examples/1/index.ts new file mode 100644 index 000000000000..bfe0f7ac09bd --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/1/index.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; +import {TuiLike} from '@taiga-ui/kit'; + +@Component({ + standalone: true, + imports: [TuiLike], + templateUrl: './index.html', + styles: [':host { display: flex; gap: 1rem; align-items: center; }'], + encapsulation, + changeDetection, +}) +export default class Example {} diff --git a/projects/demo/src/modules/components/like/examples/2/index.html b/projects/demo/src/modules/components/like/examples/2/index.html new file mode 100644 index 000000000000..22e42411c38e --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/2/index.html @@ -0,0 +1,4 @@ + diff --git a/projects/demo/src/modules/components/like/examples/2/index.ts b/projects/demo/src/modules/components/like/examples/2/index.ts new file mode 100644 index 000000000000..57a27af142a8 --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/2/index.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; +import {TuiLike, tuiLikeOptionsProvider} from '@taiga-ui/kit'; + +@Component({ + standalone: true, + imports: [TuiLike], + templateUrl: './index.html', + styles: [':host { display: flex; gap: 1rem; align-items: center; }'], + encapsulation, + changeDetection, + providers: [ + tuiLikeOptionsProvider({ + icons: {unchecked: '@tui.star', checked: '@tui.star-filled'}, + }), + ], +}) +export default class Example {} diff --git a/projects/demo/src/modules/components/like/examples/3/index.html b/projects/demo/src/modules/components/like/examples/3/index.html new file mode 100644 index 000000000000..d5bd4ad8a7a1 --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/3/index.html @@ -0,0 +1,4 @@ + diff --git a/projects/demo/src/modules/components/like/examples/3/index.ts b/projects/demo/src/modules/components/like/examples/3/index.ts new file mode 100644 index 000000000000..1057a41f6acb --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/3/index.ts @@ -0,0 +1,23 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; +import {TuiLike, tuiLikeOptionsProvider} from '@taiga-ui/kit'; + +@Component({ + standalone: true, + imports: [TuiLike], + templateUrl: './index.html', + styles: [':host { display: flex; gap: 1rem; align-items: center; }'], + encapsulation, + changeDetection, + providers: [ + tuiLikeOptionsProvider({ + icons: { + unchecked: + 'https://raw.githubusercontent.com/MarsiBarsi/readme-icons/main/github.svg', + checked: 'https://cdn-icons-png.flaticon.com/64/12710/12710759.png', + }, + }), + ], +}) +export default class Example {} diff --git a/projects/demo/src/modules/components/like/examples/4/index.html b/projects/demo/src/modules/components/like/examples/4/index.html new file mode 100644 index 000000000000..f9223a0f4ee5 --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/4/index.html @@ -0,0 +1,6 @@ + diff --git a/projects/demo/src/modules/components/like/examples/4/index.ts b/projects/demo/src/modules/components/like/examples/4/index.ts new file mode 100644 index 000000000000..bc1a6a381945 --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/4/index.ts @@ -0,0 +1,17 @@ +import {NgForOf} from '@angular/common'; +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; +import {TuiLike} from '@taiga-ui/kit'; + +@Component({ + standalone: true, + imports: [NgForOf, TuiLike], + templateUrl: './index.html', + styles: [':host { display: flex; gap: 1rem; align-items: center; }'], + encapsulation, + changeDetection, +}) +export default class Example { + protected readonly appearances = ['error', 'success', 'warning', 'flat'] as const; +} diff --git a/projects/demo/src/modules/components/like/examples/5/index.html b/projects/demo/src/modules/components/like/examples/5/index.html new file mode 100644 index 000000000000..dcb5da6c07e3 --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/5/index.html @@ -0,0 +1,35 @@ +
+

NgModel

+ + + +

Liked: {{ liked }}

+
+ +
+

Reactive form

+ +
+ +
+ +

Liked: {{ likeForm.value.liked }}

+
+ + diff --git a/projects/demo/src/modules/components/like/examples/5/index.ts b/projects/demo/src/modules/components/like/examples/5/index.ts new file mode 100644 index 000000000000..51c9e7f5e1ef --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/5/index.ts @@ -0,0 +1,30 @@ +import {CommonModule} from '@angular/common'; +import {Component} from '@angular/core'; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; +import {TuiButton} from '@taiga-ui/core'; +import {TuiLike} from '@taiga-ui/kit'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, ReactiveFormsModule, TuiButton, TuiLike], + templateUrl: './index.html', + styles: [ + ':host { display: flex; column-gap: 3rem; justify-content: space-between; flex-wrap: wrap; }', + ], + encapsulation, + changeDetection, +}) +export default class Example { + protected liked = false; + + protected likeForm = new FormGroup({ + liked: new FormControl(false), + }); + + protected changeValue(): void { + this.liked = !this.liked; + this.likeForm.setValue({liked: !this.likeForm.value.liked}); + } +} diff --git a/projects/demo/src/modules/components/like/examples/import/import.md b/projects/demo/src/modules/components/like/examples/import/import.md new file mode 100644 index 000000000000..3c21eb2e8032 --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/import/import.md @@ -0,0 +1,14 @@ +```ts +import {Component} from '@angular/core'; +import {TuiLike} from '@taiga-ui/kit'; +// ... + +@Component({ + standalone: true, + imports: [ + // ... + TuiLike, + ], +}) +export class Example {} +``` diff --git a/projects/demo/src/modules/components/like/examples/import/template.md b/projects/demo/src/modules/components/like/examples/import/template.md new file mode 100644 index 000000000000..9977127f5757 --- /dev/null +++ b/projects/demo/src/modules/components/like/examples/import/template.md @@ -0,0 +1,6 @@ +```html + +``` diff --git a/projects/demo/src/modules/components/like/index.html b/projects/demo/src/modules/components/like/index.html new file mode 100644 index 000000000000..8721a95b5dad --- /dev/null +++ b/projects/demo/src/modules/components/like/index.html @@ -0,0 +1,23 @@ + + +

+ A like component based on native checkbox with icons and custom color for icon when + :checked + state. +

+ + +
+ + +
diff --git a/projects/demo/src/modules/components/like/index.ts b/projects/demo/src/modules/components/like/index.ts new file mode 100644 index 000000000000..819a43e5e333 --- /dev/null +++ b/projects/demo/src/modules/components/like/index.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {TuiDemo} from '@demo/utils'; + +@Component({ + standalone: true, + imports: [TuiDemo], + templateUrl: './index.html', + changeDetection, +}) +export default class Page { + protected readonly examples = [ + 'Basic', + 'Icons from DI', + 'External icons', + 'Other appearances', + 'With forms', + ]; +} diff --git a/projects/demo/used-icons.ts b/projects/demo/used-icons.ts index 977fa3e36ece..5291b64ef326 100644 --- a/projects/demo/used-icons.ts +++ b/projects/demo/used-icons.ts @@ -65,6 +65,7 @@ export const TUI_USED_ICONS = [ '@tui.thumbs-down', '@tui.circle-check', '@tui.circle-x', + '@tui.star-filled', '@tui.external-link', '@tui.gitlab', '@tui.alarm-clock', diff --git a/projects/kit/components/index.ts b/projects/kit/components/index.ts index 100618303e12..00669040f0d6 100644 --- a/projects/kit/components/index.ts +++ b/projects/kit/components/index.ts @@ -23,6 +23,7 @@ export * from '@taiga-ui/kit/components/input-inline'; export * from '@taiga-ui/kit/components/input-password'; export * from '@taiga-ui/kit/components/input-phone-international'; export * from '@taiga-ui/kit/components/items-with-more'; +export * from '@taiga-ui/kit/components/like'; export * from '@taiga-ui/kit/components/line-clamp'; export * from '@taiga-ui/kit/components/pagination'; export * from '@taiga-ui/kit/components/pdf-viewer'; diff --git a/projects/kit/components/like/index.ts b/projects/kit/components/like/index.ts new file mode 100644 index 000000000000..7a8f1da8bbb2 --- /dev/null +++ b/projects/kit/components/like/index.ts @@ -0,0 +1,2 @@ +export * from './like.component'; +export * from './like.options'; diff --git a/projects/kit/components/like/like.component.ts b/projects/kit/components/like/like.component.ts new file mode 100644 index 000000000000..fea57a5da42a --- /dev/null +++ b/projects/kit/components/like/like.component.ts @@ -0,0 +1,60 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + Input, + ViewEncapsulation, +} from '@angular/core'; +import type {TuiStringHandler} from '@taiga-ui/cdk/types'; +import {tuiIsString} from '@taiga-ui/cdk/utils/miscellaneous'; +import { + tuiAppearanceOptionsProvider, + TuiWithAppearance, +} from '@taiga-ui/core/directives/appearance'; +import {tuiInjectIconResolver} from '@taiga-ui/core/tokens'; +import type {TuiSizeS} from '@taiga-ui/core/types'; + +import {TUI_LIKE_OPTIONS, type TuiLikeOptions} from './like.options'; + +@Component({ + standalone: true, + selector: 'input[tuiLike][type=checkbox]', + template: '', + styles: ['@import "@taiga-ui/kit/styles/components/like.less";'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [tuiAppearanceOptionsProvider(TUI_LIKE_OPTIONS)], + hostDirectives: [TuiWithAppearance], + host: { + tuiLike: '', + '[attr.data-size]': 'size', + '[attr.data-mode]': '""', + '[style.--t-icon-color]': 'color', + '[style.--t-unchecked-icon]': 'getIcon("unchecked")', + '[style.--t-checked-icon]': 'getIcon("checked")', + }, +}) +export class TuiLike { + private readonly options = inject(TUI_LIKE_OPTIONS); + private readonly resolver = tuiInjectIconResolver(); + + @Input('tuiLike') + public color = ''; + + @Input() + public uncheckedIcon: TuiStringHandler | string = + this.options.icons.unchecked; + + @Input() + public checkedIcon: TuiStringHandler | string = this.options.icons.checked; + + @Input() + public size: TuiSizeS = this.options.size; + + protected getIcon(state: keyof TuiLikeOptions['icons']): string { + const option = state === 'checked' ? this.checkedIcon : this.uncheckedIcon; + const icon = tuiIsString(option) ? option : option(this.size); + + return icon && `url(${this.resolver(icon)})`; + } +} diff --git a/projects/kit/components/like/like.options.ts b/projects/kit/components/like/like.options.ts new file mode 100644 index 000000000000..6e097d9d031c --- /dev/null +++ b/projects/kit/components/like/like.options.ts @@ -0,0 +1,22 @@ +import type {TuiStringHandler} from '@taiga-ui/cdk/types'; +import {tuiCreateOptions} from '@taiga-ui/cdk/utils/di'; +import type {TuiAppearanceOptions} from '@taiga-ui/core/directives'; +import type {TuiSizeS} from '@taiga-ui/core/types'; + +export interface TuiLikeOptions extends TuiAppearanceOptions { + readonly size: TuiSizeS; + readonly icons: Readonly<{ + checked: TuiStringHandler | string; + unchecked: TuiStringHandler | string; + }>; +} + +export const [TUI_LIKE_OPTIONS, tuiLikeOptionsProvider] = + tuiCreateOptions({ + size: 'm', + appearance: 'secondary', + icons: { + unchecked: '@tui.heart', + checked: '@tui.heart-filled', + }, + }); diff --git a/projects/kit/components/like/ng-package.json b/projects/kit/components/like/ng-package.json new file mode 100644 index 000000000000..bebf62dcb5e5 --- /dev/null +++ b/projects/kit/components/like/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/kit/styles/components/like.less b/projects/kit/styles/components/like.less new file mode 100644 index 000000000000..9cd96fe19320 --- /dev/null +++ b/projects/kit/styles/components/like.less @@ -0,0 +1,69 @@ +@import '@taiga-ui/core/styles/taiga-ui-local'; + +/** + * @name Like + * @selector [tuiLike] + * + * @description + * A stylized input type="checkbox" + * + * @attributes + * data-size — size (default: 'm') ('s' | 'm') + * + * @vars + * --t-icon-color - custom color for `:checked` state + * --t-unchecked-icon — default state icon + * --t-checked-icon — checkmark icon + * + * @example + * + * + * @see-also + * Checkbox, Switch, Radio, Appearance + */ +[tuiLike] { + --t-size: var(--tui-height-m); + --t-border-width: 0.75rem; + + inline-size: var(--t-size); + block-size: var(--t-size); + border: var(--t-border-width) transparent solid; + border-radius: 100%; + cursor: pointer; + margin: 0; + flex-shrink: 0; + + &::before, + &::after { + .fullsize(); + .transition(~'transform, opacity'); + + content: ''; + background: currentColor; + mask: var(--t-unchecked-icon) no-repeat center/contain; + } + + &::after { + mask-image: var(--t-checked-icon); + opacity: 0; + color: var(--t-icon-color, inherit); + transform: scale(0); + } + + &:checked { + &::before { + opacity: 0; + } + + &::after { + opacity: 1; + transform: scale(1); + transition-timing-function: cubic-bezier(0.2, 0.6, 0.5, 1.8), ease-in-out; + } + } + + &[data-size='s'] { + --t-size: var(--tui-height-s); + --t-border-width: 0.5rem; + } +}