diff --git a/projects/cdk/directives/native-validator/native-validator.directive.ts b/projects/cdk/directives/native-validator/native-validator.directive.ts index b29d33aedc8a..7c0ef247de19 100644 --- a/projects/cdk/directives/native-validator/native-validator.directive.ts +++ b/projects/cdk/directives/native-validator/native-validator.directive.ts @@ -13,7 +13,7 @@ import {AbstractControl, NG_VALIDATORS, Validator} from '@angular/forms'; ], }) export class TuiNativeValidatorDirective implements Validator { - private readonly el: HTMLInputElement = inject(ElementRef).nativeElement; + private readonly host: HTMLInputElement = inject(ElementRef).nativeElement; private readonly zone = inject(NgZone); private control?: AbstractControl; @@ -33,4 +33,8 @@ export class TuiNativeValidatorDirective implements Validator { return null; } + + private get el(): HTMLInputElement { + return this.host.querySelector('input,textarea,select') || this.host; + } } diff --git a/projects/cdk/services/directive-styles.service.ts b/projects/cdk/services/directive-styles.service.ts index 85f22ca26077..2e0de4c4ae4d 100644 --- a/projects/cdk/services/directive-styles.service.ts +++ b/projects/cdk/services/directive-styles.service.ts @@ -8,13 +8,13 @@ import { } from '@angular/core'; /** - * Service to use styles with directives + * @deprecated use {@link tuiWithStyles} instead */ @Injectable({ - providedIn: 'root', + providedIn: `root`, }) export class TuiDirectiveStylesService { - private readonly map = new Map, unknown>(); + readonly map = new Map(); constructor( @Inject(ComponentFactoryResolver) diff --git a/projects/cdk/utils/miscellaneous/index.ts b/projects/cdk/utils/miscellaneous/index.ts index eedf115d72fa..bb813703ad21 100644 --- a/projects/cdk/utils/miscellaneous/index.ts +++ b/projects/cdk/utils/miscellaneous/index.ts @@ -21,3 +21,4 @@ export * from './nullable-same'; export * from './object-from-entries'; export * from './provide-options'; export * from './uniq-by'; +export * from './with-styles'; diff --git a/projects/cdk/utils/miscellaneous/with-styles.ts b/projects/cdk/utils/miscellaneous/with-styles.ts new file mode 100644 index 000000000000..413e22fb56a0 --- /dev/null +++ b/projects/cdk/utils/miscellaneous/with-styles.ts @@ -0,0 +1,12 @@ +import {createComponent, EnvironmentInjector, inject, Type} from '@angular/core'; +import {TuiDirectiveStylesService} from '@taiga-ui/cdk/services'; + +// TODO: Add cleanup with DestroyRef and replace service with just a map from a token +export function tuiWithStyles(component: Type): void { + const map = inject(TuiDirectiveStylesService).map; + const environmentInjector = inject(EnvironmentInjector); + + if (!map.has(component)) { + map.set(component, createComponent(component, {environmentInjector})); + } +} diff --git a/projects/core/directives/dropdown/dropdown-open.directive.ts b/projects/core/directives/dropdown/dropdown-open.directive.ts index b8d559cde4f4..637bec37f601 100644 --- a/projects/core/directives/dropdown/dropdown-open.directive.ts +++ b/projects/core/directives/dropdown/dropdown-open.directive.ts @@ -126,14 +126,18 @@ export class TuiDropdownOpenDirective implements OnChanges { @HostListener('document:keydown.silent', ['$event']) onKeydown({key, target, defaultPrevented}: KeyboardEvent): void { if ( - !defaultPrevented && - tuiIsEditingKey(key) && - this.editable && - tuiIsHTMLElement(target) && - !tuiIsElementEditable(target) + defaultPrevented || + !tuiIsEditingKey(key) || + !this.editable || + !this.focused || + !tuiIsHTMLElement(target) || + (tuiIsElementEditable(target) && target !== this.host) ) { - this.host.focus({preventScroll: true}); + return; } + + this.update(true); + this.host.focus({preventScroll: true}); } ngOnChanges(): void { diff --git a/projects/core/directives/hint/hint-describe.directive.ts b/projects/core/directives/hint/hint-describe.directive.ts index e6619e874513..8e370ae9e549 100644 --- a/projects/core/directives/hint/hint-describe.directive.ts +++ b/projects/core/directives/hint/hint-describe.directive.ts @@ -12,6 +12,7 @@ import {tuiAsDriver, TuiDriver} from '@taiga-ui/core/abstract'; import { debounce, distinctUntilChanged, + fromEvent, map, merge, of, @@ -30,13 +31,7 @@ export class TuiHintDescribeDirective extends TuiDriver implements OnChanges { private readonly id$ = new ReplaySubject(1); private readonly stream$ = this.id$.pipe( - tuiIfMap( - () => - tuiTypedFromEvent(this.doc, 'keydown', { - capture: true, - }), - tuiIsPresent, - ), + tuiIfMap(() => fromEvent(this.doc, 'keydown', {capture: true}), tuiIsPresent), switchMap(() => this.focused ? of(false) @@ -53,7 +48,7 @@ export class TuiHintDescribeDirective extends TuiDriver implements OnChanges { ); @Input() - tuiHintDescribe: string | '' | null = ''; + tuiHintDescribe?: string | null = ''; readonly type = 'hint'; diff --git a/projects/core/styles/theme/appearance.less b/projects/core/styles/theme/appearance.less index 99a813262512..aebb28f1009c 100644 --- a/projects/core/styles/theme/appearance.less +++ b/projects/core/styles/theme/appearance.less @@ -8,3 +8,4 @@ @import 'appearance/primary.less'; @import 'appearance/secondary.less'; @import 'appearance/status.less'; +@import 'appearance/textfield.less'; diff --git a/projects/core/styles/theme/appearance/textfield.less b/projects/core/styles/theme/appearance/textfield.less new file mode 100644 index 000000000000..98f2420dff14 --- /dev/null +++ b/projects/core/styles/theme/appearance/textfield.less @@ -0,0 +1,35 @@ +@import '../../taiga-ui-local.less'; + +[tuiAppearance][data-appearance='textfield'] { + --t-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.1); + background: var(--tui-base-01); + color: var(--tui-text-01); + box-shadow: var(--t-shadow); + outline: 1px solid var(--tui-base-03); + outline-offset: -1px; + + &:valid[data-invalid='true'], + &:invalid:not([data-invalid='false']) { + outline-color: var(--tui-negative); + } + + &:read-only { + box-shadow: none; + outline-color: var(--tui-base-04) !important; + } + .transition(~'box-shadow, background, outline-color'); + + .appearance-hover({ + --t-shadow: 0 0.125rem 0.3125rem rgba(0, 0, 0, 0.16); + }); + + .appearance-focus({ + box-shadow: none; + outline: 0.125rem solid var(--tui-primary); + outline-offset: -0.125rem; + }); + + .appearance-disabled({ + box-shadow: none; + }); +} diff --git a/projects/demo/src/modules/app/app.routes.ts b/projects/demo/src/modules/app/app.routes.ts index c8fb7093ca57..e2e82f064fb8 100644 --- a/projects/demo/src/modules/app/app.routes.ts +++ b/projects/demo/src/modules/app/app.routes.ts @@ -410,6 +410,15 @@ export const ROUTES: Routes = [ title: 'Surface', }, }, + { + path: `experimental/textfield`, + loadChildren: async () => + (await import(`../experimental/textfield/textfield.module`)) + .ExampleTuiTextfieldModule, + data: { + title: `Textfield`, + }, + }, { path: 'experimental/thumbnail-card', loadChildren: async () => diff --git a/projects/demo/src/modules/app/pages.ts b/projects/demo/src/modules/app/pages.ts index 3206da309de7..977ccb3bdbe1 100644 --- a/projects/demo/src/modules/app/pages.ts +++ b/projects/demo/src/modules/app/pages.ts @@ -316,8 +316,45 @@ export const pages: TuiDocPages = [ section: 'Tools', title: 'DropdownSelection', keywords: - 'dropdown, контекст, selection, выделение, выпадашка, дропдаун, Context', - route: '/directives/dropdown-selection', + `контекст, выпадашка, дропдаун, меню, Context, ContextMenu, option,` + + `optGroup, опции, tuiOption, варианты, dropdown, menu`, + route: `/components/data-list-wrapper`, + }, + { + section: `Components`, + title: `HostedDropdown`, + keywords: `dropdown, контекст, выпадашка, дропдаун, меню, menu`, + route: `/components/hosted-dropdown`, + }, + { + section: `Tools`, + title: `Dropdown`, + keywords: `dropdown, контекст, выпадашка, дропдаун, Context`, + route: `/directives/dropdown`, + }, + { + section: `Tools`, + title: `DropdownOpen`, + keywords: `dropdown, hosted, контекст, выпадашка, дропдаун, меню, menu`, + route: `/directives/dropdown-open`, + }, + { + section: `Tools`, + title: `DropdownContext`, + keywords: `dropdown, контекст, выпадашка, дропдаун, Context, right-click`, + route: `/directives/dropdown-context`, + }, + { + section: `Tools`, + title: `DropdownHover`, + keywords: `dropdown, hover, выпадашка, дропдаун`, + route: `/directives/dropdown-hover`, + }, + { + section: `Tools`, + title: `DropdownSelection`, + keywords: `dropdown, контекст, selection, выделение, выпадашка, дропдаун, Context`, + route: `/directives/dropdown-selection`, }, ], }, @@ -973,6 +1010,12 @@ export const pages: TuiDocPages = [ keywords: 'card, container, wrapper, image, blur, overlay', route: '/experimental/surface', }, + { + section: `Experimental`, + title: `Textfield`, + keywords: `form, input, select, textarea, combobox, ввод, форма, поле`, + route: `/experimental/textfield`, + }, { section: 'Experimental', title: 'Title', diff --git a/projects/demo/src/modules/experimental/textfield/examples/1/index.html b/projects/demo/src/modules/experimental/textfield/examples/1/index.html new file mode 100644 index 000000000000..55709f67202c --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/examples/1/index.html @@ -0,0 +1,219 @@ +
+

Label inside

+ + + + + + + +

Label outside

+ + +

Disabled

+ + + + + + + +

Read only

+ + + + + + + +

Invalid

+ + + + + + + + + + + +
+ +
+

 

+ + I am a label + + +

 

+ + +

 

+ + I am a label + + +

 

+ + I am a label + + +

 

+ + I am a label + + + I am a label + +
diff --git a/projects/demo/src/modules/experimental/textfield/examples/1/index.less b/projects/demo/src/modules/experimental/textfield/examples/1/index.less new file mode 100644 index 000000000000..0ae23c2008cb --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/examples/1/index.less @@ -0,0 +1,12 @@ +:host { + display: flex; + gap: 1rem; + width: 40rem; +} + +div { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/projects/demo/src/modules/experimental/textfield/examples/1/index.ts b/projects/demo/src/modules/experimental/textfield/examples/1/index.ts new file mode 100644 index 000000000000..7c939db0b755 --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/examples/1/index.ts @@ -0,0 +1,17 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; + +@Component({ + selector: 'tui-textfield-example-1', + templateUrl: './index.html', + styleUrls: ['./index.less'], + encapsulation, + changeDetection, +}) +export class TuiTextfieldExample1 { + value = 'Test'; + filler = 'I am filler'; + + readonly sizes = ['l', 'm', 's'] as const; +} diff --git a/projects/demo/src/modules/experimental/textfield/examples/import/import-module.md b/projects/demo/src/modules/experimental/textfield/examples/import/import-module.md new file mode 100644 index 000000000000..3fc5e55f8b02 --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/examples/import/import-module.md @@ -0,0 +1,13 @@ +```ts +import {NgModule} from '@angular/core'; +import {TuiTextfieldModule} from '@taiga-ui/experimental'; +// ... + +@NgModule({ + imports: [ + // ... + TuiTextfieldModule, + ], +}) +export class MyModule {} +``` diff --git a/projects/demo/src/modules/experimental/textfield/examples/import/insert-template.md b/projects/demo/src/modules/experimental/textfield/examples/import/insert-template.md new file mode 100644 index 000000000000..a7d5c576edca --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/examples/import/insert-template.md @@ -0,0 +1,8 @@ +```html + + + +``` diff --git a/projects/demo/src/modules/experimental/textfield/textfield.component.ts b/projects/demo/src/modules/experimental/textfield/textfield.component.ts new file mode 100644 index 000000000000..65d10b7372db --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/textfield.component.ts @@ -0,0 +1,23 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {TuiDocExample, TuiRawLoaderContent} from '@taiga-ui/addon-doc'; + +@Component({ + selector: 'example-textfield', + templateUrl: './textfield.template.html', + changeDetection, +}) +export class ExampleTuiTextfieldComponent { + readonly exampleModule: TuiRawLoaderContent = import( + './examples/import/import-module.md?raw' + ); + + readonly exampleHtml: TuiRawLoaderContent = import( + './examples/import/insert-template.md?raw' + ); + + readonly example1: TuiDocExample = { + HTML: import('./examples/1/index.html?raw'), + LESS: import('./examples/1/index.less?raw'), + }; +} diff --git a/projects/demo/src/modules/experimental/textfield/textfield.module.ts b/projects/demo/src/modules/experimental/textfield/textfield.module.ts new file mode 100644 index 000000000000..66b63bb2c1ea --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/textfield.module.ts @@ -0,0 +1,38 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {tuiGetDocModules} from '@taiga-ui/addon-doc'; +import { + TuiHintModule, + TuiNotificationModule, + TuiPrimitiveTextfieldModule, + TuiTextfieldControllerModule, +} from '@taiga-ui/core'; +import { + TuiIconModule, + TuiLabelDirective, + TuiTextfieldModule, + TuiTooltipModule, +} from '@taiga-ui/experimental'; + +import {TuiTextfieldExample1} from './examples/1'; +import {ExampleTuiTextfieldComponent} from './textfield.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + TuiTextfieldModule, + TuiLabelDirective, + TuiTooltipModule, + TuiNotificationModule, + TuiPrimitiveTextfieldModule, + TuiTextfieldControllerModule, + TuiHintModule, + TuiIconModule, + tuiGetDocModules(ExampleTuiTextfieldComponent), + ], + declarations: [ExampleTuiTextfieldComponent, TuiTextfieldExample1], + exports: [ExampleTuiTextfieldComponent], +}) +export class ExampleTuiTextfieldModule {} diff --git a/projects/demo/src/modules/experimental/textfield/textfield.template.html b/projects/demo/src/modules/experimental/textfield/textfield.template.html new file mode 100644 index 000000000000..a3f70dcf15d3 --- /dev/null +++ b/projects/demo/src/modules/experimental/textfield/textfield.template.html @@ -0,0 +1,43 @@ + + + + This code is + experimental + and is a subject to change. Expect final solution to be shipped in the next major version + + + + + + + + +
    +
  1. +

    Import module:

    + + +
  2. + +
  3. +

    Add to the template:

    + + +
  4. +
+
+
diff --git a/projects/experimental/components/index.ts b/projects/experimental/components/index.ts index f17b9a38c245..df2492eea082 100644 --- a/projects/experimental/components/index.ts +++ b/projects/experimental/components/index.ts @@ -8,9 +8,11 @@ export * from '@taiga-ui/experimental/components/checkbox'; export * from '@taiga-ui/experimental/components/chip'; export * from '@taiga-ui/experimental/components/compass'; export * from '@taiga-ui/experimental/components/icon'; +export * from '@taiga-ui/experimental/components/label'; export * from '@taiga-ui/experimental/components/pin'; export * from '@taiga-ui/experimental/components/radio'; export * from '@taiga-ui/experimental/components/rating'; +export * from '@taiga-ui/experimental/components/textfield'; export * from '@taiga-ui/experimental/components/thumbnail-card'; export * from '@taiga-ui/experimental/components/toggle'; export * from '@taiga-ui/experimental/components/tooltip'; diff --git a/projects/experimental/components/label/index.ts b/projects/experimental/components/label/index.ts new file mode 100644 index 000000000000..fc69af75fc46 --- /dev/null +++ b/projects/experimental/components/label/index.ts @@ -0,0 +1,2 @@ +export * from './label.component'; +export * from './label.directive'; diff --git a/projects/experimental/components/label/label.component.ts b/projects/experimental/components/label/label.component.ts new file mode 100644 index 000000000000..76ab3787d32b --- /dev/null +++ b/projects/experimental/components/label/label.component.ts @@ -0,0 +1,13 @@ +import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; + +@Component({ + standalone: true, + template: '', + styleUrls: ['./label.style.less'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'tui-label-styles', + }, +}) +export class TuiLabelComponent {} diff --git a/projects/experimental/components/label/label.directive.ts b/projects/experimental/components/label/label.directive.ts new file mode 100644 index 000000000000..4282317248d4 --- /dev/null +++ b/projects/experimental/components/label/label.directive.ts @@ -0,0 +1,21 @@ +import {Directive, ElementRef, HostBinding, inject} from '@angular/core'; +import {tuiWithStyles} from '@taiga-ui/cdk'; +import {TuiTextfieldComponent} from '@taiga-ui/experimental/components/textfield'; + +import {TuiLabelComponent} from './label.component'; + +@Directive({ + standalone: true, + selector: 'label[tuiLabel]', +}) +export class TuiLabelDirective { + // @ts-ignore + private readonly nothing = tuiWithStyles(TuiLabelComponent); + private readonly el: HTMLLabelElement = inject(ElementRef).nativeElement; + private readonly textfield = inject(TuiTextfieldComponent, {optional: true}); + + @HostBinding('attr.for') + get for(): string | undefined { + return this.el.htmlFor || this.textfield?.id; + } +} diff --git a/projects/experimental/components/label/label.style.less b/projects/experimental/components/label/label.style.less new file mode 100644 index 000000000000..33b534218079 --- /dev/null +++ b/projects/experimental/components/label/label.style.less @@ -0,0 +1,9 @@ +@import '@taiga-ui/core/styles/taiga-ui-local'; + +[tuiLabel] { + display: flex; + flex-direction: column; + gap: 0.25rem; + font: var(--tui-font-text-s); + color: var(--tui-text-01); +} diff --git a/projects/experimental/components/label/label.template.html b/projects/experimental/components/label/label.template.html new file mode 100644 index 000000000000..9eea86ed6b53 --- /dev/null +++ b/projects/experimental/components/label/label.template.html @@ -0,0 +1,10 @@ + + + + + + diff --git a/projects/experimental/components/label/ng-package.json b/projects/experimental/components/label/ng-package.json new file mode 100644 index 000000000000..bebf62dcb5e5 --- /dev/null +++ b/projects/experimental/components/label/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/experimental/components/textfield/index.ts b/projects/experimental/components/textfield/index.ts new file mode 100644 index 000000000000..ac0594302027 --- /dev/null +++ b/projects/experimental/components/textfield/index.ts @@ -0,0 +1,4 @@ +export * from './textfield.component'; +export * from './textfield.directive'; +export * from './textfield.module'; +export * from './textfield.options'; diff --git a/projects/experimental/components/textfield/ng-package.json b/projects/experimental/components/textfield/ng-package.json new file mode 100644 index 000000000000..bebf62dcb5e5 --- /dev/null +++ b/projects/experimental/components/textfield/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/experimental/components/textfield/textfield.component.ts b/projects/experimental/components/textfield/textfield.component.ts new file mode 100644 index 000000000000..ff79b59e76a6 --- /dev/null +++ b/projects/experimental/components/textfield/textfield.component.ts @@ -0,0 +1,111 @@ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + ElementRef, + inject, + Input, +} from '@angular/core'; +import {NgControl} from '@angular/forms'; +import {ResizeObserverModule} from '@ng-web-apis/resize-observer'; +import { + TuiContextWithImplicit, + tuiIsNativeFocusedIn, + TuiNativeValidatorDirective, +} from '@taiga-ui/cdk'; +import {TuiDropdownDirective, tuiDropdownOptionsProvider} from '@taiga-ui/core'; +import {TuiButtonModule} from '@taiga-ui/experimental/components/button'; +import {TuiLabelDirective} from '@taiga-ui/experimental/components/label'; +import {tuiAppearanceOptionsProvider} from '@taiga-ui/experimental/directives/appearance'; +import {TuiIconsDirective} from '@taiga-ui/experimental/directives/icons'; +import {PolymorpheusContent, PolymorpheusModule} from '@tinkoff/ng-polymorpheus'; + +import {TuiTextfieldDirective} from './textfield.directive'; +import {TUI_TEXTFIELD_OPTIONS, TuiTextfieldOptionsDirective} from './textfield.options'; + +export interface TuiTextfieldContext extends TuiContextWithImplicit { + readonly active: boolean; +} + +@Component({ + standalone: true, + selector: 'tui-textfield', + imports: [ + CommonModule, + ResizeObserverModule, + TuiTextfieldDirective, + TuiButtonModule, + PolymorpheusModule, + ], + templateUrl: './textfield.template.html', + styleUrls: ['./textfield.style.less'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + tuiAppearanceOptionsProvider(TUI_TEXTFIELD_OPTIONS), + tuiDropdownOptionsProvider({limitWidth: 'fixed'}), + ], + host: { + '[style.--t-side.px]': 'side', + '[attr.data-size]': 'options.size', + '[class._with-label]': 'label', + '[class._disabled]': 'input?.nativeElement.disabled', + '(input)': '0', + '(focusin)': '0', + '(focusout)': '0', + }, + hostDirectives: [ + TuiNativeValidatorDirective, + { + directive: TuiIconsDirective, + inputs: ['iconLeft', 'iconRight'], + }, + ], +}) +export class TuiTextfieldComponent { + private readonly el = inject(ElementRef).nativeElement; + private readonly dropdown = inject(TuiDropdownDirective, { + optional: true, + self: true, + }); + + @ContentChild(TuiTextfieldDirective, {read: ElementRef}) + readonly input?: ElementRef; + + @ContentChild(TuiLabelDirective) + readonly label?: unknown; + + @Input() + filler = ''; + + @Input() + content: PolymorpheusContent>; + + readonly directive? = inject(TuiTextfieldOptionsDirective, {optional: true}); + readonly options = inject(TUI_TEXTFIELD_OPTIONS); + readonly control = inject(NgControl, {optional: true}); + side = 0; + + get computedFiller(): string { + const value = this.input?.nativeElement.value || ''; + const filler = value + this.filler.slice(value.length); + + return filler.length > value.length ? filler : ''; + } + + get id(): string { + return this.input?.nativeElement.id || ''; + } + + get focused(): boolean { + return !!this.dropdown?.dropdownBoxRef || tuiIsNativeFocusedIn(this.el); + } + + get showFiller(): boolean { + return ( + this.focused && + !!this.computedFiller && + (!!this.input?.nativeElement.value || !this.input?.nativeElement.placeholder) + ); + } +} diff --git a/projects/experimental/components/textfield/textfield.directive.ts b/projects/experimental/components/textfield/textfield.directive.ts new file mode 100644 index 000000000000..b8c614617c97 --- /dev/null +++ b/projects/experimental/components/textfield/textfield.directive.ts @@ -0,0 +1,39 @@ +import {Directive, DoCheck, ElementRef, inject, Input} from '@angular/core'; +import {TuiIdService, TuiNativeValidatorDirective} from '@taiga-ui/cdk'; +import {TuiInteractiveStateT} from '@taiga-ui/core'; +import {TuiAppearanceDirective} from '@taiga-ui/experimental/directives/appearance'; + +import {TuiTextfieldComponent} from './textfield.component'; + +@Directive({ + standalone: true, + selector: 'input[tuiTextfieldd]', + host: { + '[id]': 'input.id || this.idService.generate()', + '[placeholder]': 'input.placeholder || " "', + '[attr.data-invalid]': 'invalid', + }, + hostDirectives: [TuiNativeValidatorDirective, TuiAppearanceDirective], +}) +export class TuiTextfieldDirective implements DoCheck { + private readonly textfield = inject(TuiTextfieldComponent); + private readonly appearance = inject(TuiAppearanceDirective); + + @Input() + invalid: boolean | null = null; + + @Input() + focused: boolean | null = null; + + @Input() + state: TuiInteractiveStateT | null = null; + + readonly idService = inject(TuiIdService); + readonly input: HTMLInputElement = inject(ElementRef).nativeElement; + + ngDoCheck(): void { + this.appearance.tuiAppearance = this.textfield.options.appearance; + this.appearance.tuiAppearanceFocus = this.focused ?? this.textfield.focused; + this.appearance.tuiAppearanceState = this.state; + } +} diff --git a/projects/experimental/components/textfield/textfield.module.ts b/projects/experimental/components/textfield/textfield.module.ts new file mode 100644 index 000000000000..f46f57555393 --- /dev/null +++ b/projects/experimental/components/textfield/textfield.module.ts @@ -0,0 +1,11 @@ +import {NgModule} from '@angular/core'; + +import {TuiTextfieldComponent} from './textfield.component'; +import {TuiTextfieldDirective} from './textfield.directive'; +import {TuiTextfieldOptionsDirective} from './textfield.options'; + +@NgModule({ + imports: [TuiTextfieldComponent, TuiTextfieldDirective, TuiTextfieldOptionsDirective], + exports: [TuiTextfieldComponent, TuiTextfieldDirective, TuiTextfieldOptionsDirective], +}) +export class TuiTextfieldModule {} diff --git a/projects/experimental/components/textfield/textfield.options.ts b/projects/experimental/components/textfield/textfield.options.ts new file mode 100644 index 000000000000..028be0d51326 --- /dev/null +++ b/projects/experimental/components/textfield/textfield.options.ts @@ -0,0 +1,53 @@ +import {Directive, inject, Input, Provider} from '@angular/core'; +import {AbstractTuiController, tuiCreateToken, tuiProvideOptions} from '@taiga-ui/cdk'; +import {TuiSizeL, TuiSizeS} from '@taiga-ui/core'; +import {TuiAppearanceOptions} from '@taiga-ui/experimental/directives/appearance'; + +export interface TuiTextfieldOptions extends TuiAppearanceOptions { + readonly size: TuiSizeL | TuiSizeS; + readonly cleaner: boolean; +} + +export const TUI_TEXTFIELD_DEFAULT_OPTIONS: TuiTextfieldOptions = { + appearance: `textfield`, + size: `l`, + cleaner: false, +}; + +export const TUI_TEXTFIELD_OPTIONS = tuiCreateToken(TUI_TEXTFIELD_DEFAULT_OPTIONS); + +export function tuiTextfieldOptionsProvider( + options: Partial, +): Provider { + return tuiProvideOptions( + TUI_TEXTFIELD_OPTIONS, + options, + TUI_TEXTFIELD_DEFAULT_OPTIONS, + ); +} + +@Directive({ + standalone: true, + selector: `[tuiTextfieldAppearance],[tuiTextfieldSize],[tuiTextfieldCleaner]`, + providers: [ + { + provide: TUI_TEXTFIELD_OPTIONS, + useExisting: TuiTextfieldOptionsDirective, + }, + ], +}) +export class TuiTextfieldOptionsDirective + extends AbstractTuiController + implements TuiTextfieldOptions +{ + private readonly options = inject(TUI_TEXTFIELD_OPTIONS, {skipSelf: true}); + + @Input(`tuiTextfieldAppearance`) + appearance = this.options.appearance; + + @Input(`tuiTextfieldSize`) + size = this.options.size; + + @Input(`tuiTextfieldCleaner`) + cleaner = this.options.cleaner; +} diff --git a/projects/experimental/components/textfield/textfield.style.less b/projects/experimental/components/textfield/textfield.style.less new file mode 100644 index 000000000000..0bb8853b74ea --- /dev/null +++ b/projects/experimental/components/textfield/textfield.style.less @@ -0,0 +1,217 @@ +@import '@taiga-ui/core/styles/taiga-ui-local'; + +:host { + + --t-left: 0rem; + --t-right: 0rem; + position: relative; + display: flex; + align-items: center; + pointer-events: none; + cursor: pointer; + height: var(--t-height); + color: var(--tui-text-03); + + &[data-size='s'] { + --t-height: var(--tui-height-s); + padding: 0 var(--tui-padding-s); + border-radius: var(--tui-radius-m); + font: var(--tui-font-text-s); + + &._icon-left { + --t-left: 1.25rem; + } + + &._icon-right { + --t-right: 1.25rem; + } + + &:before { + margin-inline-start: -0.25rem; + margin-inline-end: 0.5rem; + font-size: 1rem; + } + + &:after { + margin-inline-end: -0.175rem; + margin-inline-start: 0.25rem; + font-size: 1rem; + } + + ::ng-deep input { + font: var(--tui-font-text-s); + } + + .t-content { + gap: 0; + } + } + + &[data-size='m'] { + --t-height: var(--tui-height-m); + padding: 0 var(--tui-padding-m); + border-radius: var(--tui-radius-m); + font: var(--tui-font-text-s); + + &._icon-left { + --t-left: 1.75rem; + } + + &._icon-right { + --t-right: 1.75rem; + } + + &:before { + margin-inline-start: -0.125rem; + margin-inline-end: 0.375rem; + } + + &:after { + margin-inline-start: 0.375rem; + margin-inline-end: -0.125rem; + } + + ::ng-deep input { + font: var(--tui-font-text-s); + } + } + + &[data-size='l'] { + --t-height: var(--tui-height-l); + padding: 0 var(--tui-padding-l); + border-radius: var(--tui-radius-l); + font: var(--tui-font-text-m); + + &._icon-left { + --t-left: 2.25rem; + } + + &._icon-right { + --t-right: 2.25rem; + } + + &:before { + margin-inline-end: 0.75rem; + } + + &:after { + margin-inline-start: 0.25rem; + } + + ::ng-deep input { + font: var(--tui-font-text-m); + } + } + + &:hover { + color: var(--tui-text-02); + } + + &:before { + z-index: 1; + } + + &._disabled:before, + &._disabled:after { + opacity: var(--tui-disabled-opacity); + } + + &._with-label { + .t-template, + ::ng-deep input { + padding-top: calc(var(--t-height) / 3); + + &::placeholder { + color: transparent; + } + } + } + + .t-template, + ::ng-deep input { + .fullsize(); + appearance: none; + box-sizing: border-box; + border: none; + border-left: var(--t-left) solid transparent; + border-right: calc(var(--t-right) + var(--t-side)) solid transparent; + border-radius: inherit; + padding: inherit; + } + + ::ng-deep input { + pointer-events: auto; + + &:read-only ~ .t-filler { + display: none; + } + + &:disabled { + & ~ label, + & ~ .t-content { + opacity: var(--tui-disabled-opacity); + + tui-tooltip { + display: none; + } + } + } + + &:not(:placeholder-shown) { + & ~ label { + font-size: 0.83em; + transform: translateY(-0.7em); + } + + &:not(:disabled):not(:read-only)[data-invalid='true'] ~ label, + &:invalid:not(:disabled):not(:read-only):not([data-invalid='true']) ~ label { + color: var(--tui-negative); + } + + &:not(:disabled):not(:read-only:not(._readonly)) ~ .t-content .t-clear { + display: flex; + } + } + + .appearance-focus({ + &::placeholder { + color: var(--tui-text-03); + } + + & ~ label { + color: var(--tui-text-01) !important; + font-size: 0.83em; + transform: translateY(-0.7em); + } + }); + } + + ::ng-deep label { + .transition(all); + .text-overflow(); + position: relative; + display: block; + flex: 1; + font-size: inherit; + color: var(--tui-text-02); + } + .transition(color); +} + +.t-content { + display: flex; + gap: 0.25rem; + margin-inline-start: auto; +} + +.t-clear { + display: none; + pointer-events: auto; +} + +.t-filler { + pointer-events: none; + background: none; + color: var(--tui-text-03); + opacity: 1; +} diff --git a/projects/experimental/components/textfield/textfield.template.html b/projects/experimental/components/textfield/textfield.template.html new file mode 100644 index 000000000000..ba7d6cdefa8a --- /dev/null +++ b/projects/experimental/components/textfield/textfield.template.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + {{ text }} + + + diff --git a/projects/experimental/components/tooltip/tooltip.component.ts b/projects/experimental/components/tooltip/tooltip.component.ts index 075c17597555..39fe6027be42 100644 --- a/projects/experimental/components/tooltip/tooltip.component.ts +++ b/projects/experimental/components/tooltip/tooltip.component.ts @@ -3,6 +3,7 @@ import { Component, HostListener, Inject, + inject, Input, Optional, Self, @@ -18,6 +19,7 @@ import { TuiHintOptions, TuiHintOptionsDirective, } from '@taiga-ui/core'; +import {TuiTextfieldComponent} from '@taiga-ui/experimental/components/textfield'; import {TuiAppearanceDirective} from '@taiga-ui/experimental/directives/appearance'; import {Observable, takeUntil} from 'rxjs'; @@ -34,6 +36,7 @@ import {TUI_TOOLTIP_OPTIONS, TuiTooltipOptions} from './tooltip.options'; inputs: ['content', 'direction', 'appearance', 'showDelay', 'hideDelay'], }) export class TuiTooltipComponent extends TuiHintOptionsDirective { + private readonly textfield = inject(TuiTextfieldComponent, {optional: true}); private mode: TuiBrightness | null = null; @ViewChild(TuiHintHoverDirective) @@ -62,6 +65,10 @@ export class TuiTooltipComponent extends TuiHintOptionsDirective { }); } + get id(): string { + return this.describeId || this.textfield?.id || ''; + } + get computedAppearance(): string { return this.appearance || this.mode || ''; } diff --git a/projects/experimental/components/tooltip/tooltip.template.html b/projects/experimental/components/tooltip/tooltip.template.html index 5d1cdb082896..719582395434 100644 --- a/projects/experimental/components/tooltip/tooltip.template.html +++ b/projects/experimental/components/tooltip/tooltip.template.html @@ -5,13 +5,13 @@ tuiIconButton class="t-tooltip-button" [appearance]="iconAppearance?.tuiAppearance || 'icon'" - [attr.tabindex]="describeId ? -1 : 0" + [attr.tabindex]="id ? -1 : 0" [iconLeft]="tooltipIcon" [tuiAppearanceState]="(driver | async) ? 'active' : null" [tuiHint]="content" [tuiHintAppearance]="computedAppearance" [tuiHintContext]="context" - [tuiHintDescribe]="describeId" + [tuiHintDescribe]="id" [tuiHintDirection]="direction" [tuiHintHideDelay]="hideDelay" [tuiHintShowDelay]="showDelay"