diff --git a/projects/cdk/tokens/environment.ts b/projects/cdk/tokens/environment.ts index 257f101571ad..5caeeb22a24c 100644 --- a/projects/cdk/tokens/environment.ts +++ b/projects/cdk/tokens/environment.ts @@ -1,7 +1,9 @@ import {inject} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; import {NAVIGATOR, USER_AGENT, WINDOW} from '@ng-web-apis/common'; import {TUI_FALSE_HANDLER} from '@taiga-ui/cdk/constants'; import {tuiCreateTokenFromFactory, tuiIsIos} from '@taiga-ui/cdk/utils'; +import {fromEvent, map} from 'rxjs'; // https://stackoverflow.com/a/11381730/2706426 http://detectmobilebrowsers.com/ const firstRegex = @@ -33,6 +35,14 @@ export const TUI_PLATFORM = tuiCreateTokenFromFactory<'android' | 'ios' | 'web'> return inject(TUI_IS_ANDROID) ? 'android' : 'web'; }); +export const TUI_IS_TOUCH = tuiCreateTokenFromFactory(() => { + const media = inject(WINDOW).matchMedia('(pointer: coarse)'); + + return toSignal(fromEvent(media, 'change').pipe(map(() => media.matches)), { + initialValue: media.matches, + }); +}); + /** * Detect if app is running under Cypress * {@link https://docs.cypress.io/faq/questions/using-cypress-faq#Is-there-any-way-to-detect-if-my-app-is-running-under-Cypress Cypress docs} diff --git a/projects/core/directives/dropdown/dropdown-context.directive.ts b/projects/core/directives/dropdown/dropdown-context.directive.ts index 8374fb4f0ec5..105edac36aaf 100644 --- a/projects/core/directives/dropdown/dropdown-context.directive.ts +++ b/projects/core/directives/dropdown/dropdown-context.directive.ts @@ -1,6 +1,7 @@ -import {Directive, HostListener, inject} from '@angular/core'; +import {computed, Directive, HostListener, inject} from '@angular/core'; import {EMPTY_CLIENT_RECT} from '@taiga-ui/cdk/constants'; import {TuiActiveZone} from '@taiga-ui/cdk/directives/active-zone'; +import {TUI_IS_IOS, TUI_IS_TOUCH} from '@taiga-ui/cdk/tokens'; import {tuiPointToClientRect} from '@taiga-ui/cdk/utils/dom'; import {tuiAsDriver, tuiAsRectAccessor, TuiRectAccessor} from '@taiga-ui/core/classes'; import {shouldCall} from '@taiga-ui/event-plugins'; @@ -11,6 +12,9 @@ function activeZoneFilter(this: TuiDropdownContext, target: Element): boolean { return !this.activeZone.contains(target); } +const TAP_DELAY = 700; +const MOVE_THRESHOLD = 15; + @Directive({ standalone: true, selector: '[tuiDropdownContext]', @@ -20,11 +24,20 @@ function activeZoneFilter(this: TuiDropdownContext, target: Element): boolean { tuiAsDriver(TuiDropdownDriver), tuiAsRectAccessor(TuiDropdownContext), ], + host: { + '[style.user-select]': 'userSelect()', + '[style.-webkit-user-select]': 'userSelect()', + '[style.-webkit-touch-callout]': 'userSelect()', + }, }) export class TuiDropdownContext extends TuiRectAccessor { + private readonly isIOS = inject(TUI_IS_IOS); + private readonly isTouch = inject(TUI_IS_TOUCH); private readonly driver = inject(TuiDropdownDriver); private currentRect = EMPTY_CLIENT_RECT; + private longTapTimeout: any = NaN; + protected readonly userSelect = computed(() => (this.isTouch() ? 'none' : null)); protected readonly activeZone = inject(TuiActiveZone); public readonly type = 'dropdown'; @@ -40,10 +53,47 @@ export class TuiDropdownContext extends TuiRectAccessor { } @shouldCall(activeZoneFilter) - @HostListener('document:click.silent', ['$event.target']) + @HostListener('document:pointerdown.silent', ['$event.target']) @HostListener('document:contextmenu.capture.silent', ['$event.target']) @HostListener('document:keydown.esc', ['$event.currentTarget']) protected closeDropdown(): void { this.driver.next(false); + this.currentRect = EMPTY_CLIENT_RECT; + } + + @HostListener('touchstart.silent.passive', [ + '$event.touches[0].clientX', + '$event.touches[0].clientY', + ]) + protected onTouchStart(x: number, y: number): void { + if (!this.isIOS || !this.isTouch() || this.currentRect !== EMPTY_CLIENT_RECT) { + return; + } + + this.currentRect = tuiPointToClientRect(x, y); + this.longTapTimeout = setTimeout(() => { + this.driver.next(true); + }, TAP_DELAY); + } + + @HostListener('touchmove.silent.passive', [ + '$event.touches[0].clientX', + '$event.touches[0].clientY', + ]) + protected onTouchMove(x: number, y: number): void { + if ( + this.isIOS && + this.isTouch() && + this.currentRect !== EMPTY_CLIENT_RECT && + Math.hypot(x - this.currentRect.x, y - this.currentRect.y) > MOVE_THRESHOLD + ) { + this.onTouchEnd(); + } + } + + @HostListener('touchend.silent.passive') + @HostListener('touchcancel.silent.passive') + protected onTouchEnd(): void { + clearTimeout(this.longTapTimeout); } }