diff --git a/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.component.ts b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.component.ts new file mode 100644 index 000000000000..bebfd1d2ce30 --- /dev/null +++ b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.component.ts @@ -0,0 +1,154 @@ +import {DOCUMENT, NgIf} from '@angular/common'; +import type {AfterViewInit, OnDestroy} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + inject, + ViewEncapsulation, +} from '@angular/core'; +import {IntersectionObserverModule} from '@ng-web-apis/intersection-observer'; +import {TuiKeyboardService} from '@taiga-ui/addon-mobile/services'; +import type {TuiSwipe} from '@taiga-ui/cdk'; +import { + TuiActiveZoneDirective, + tuiGetNativeFocused, + tuiInjectElement, + tuiIsElement, + tuiIsNodeIn, + tuiPx, + TuiSwipeDirective, +} from '@taiga-ui/cdk'; +import { + TUI_ANIMATIONS_SPEED, + TuiDropdownDirective, + tuiFadeIn, + tuiGetDuration, + tuiSlideInTop, +} from '@taiga-ui/core'; +import {PolymorpheusModule} from '@tinkoff/ng-polymorpheus'; + +import {TuiDropdownMobileDirective} from './dropdown-mobile.directive'; + +const GAP = 16; + +@Component({ + standalone: true, + selector: 'tui-dropdown-mobile', + imports: [IntersectionObserverModule, TuiSwipeDirective, NgIf, PolymorpheusModule], + templateUrl: './dropdown-mobile.template.html', + styleUrls: ['./dropdown-mobile.style.less'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [tuiSlideInTop, tuiFadeIn], + hostDirectives: [TuiActiveZoneDirective], + host: { + '[@tuiFadeIn]': 'animation', + '[@tuiSlideInTop]': 'animation', + '[class._sheet]': 'directive.tuiDropdownMobile', + '(document:click.silent.capture)': 'onClick($event)', + '(window>scroll.silent.capture)': 'refresh($event.currentTarget.visualViewport)', + '(visualViewport>resize.silent)': 'refresh($event.target)', + '(visualViewport>scroll.silent)': 'refresh($event.target)', + }, +}) +export class TuiDropdownMobileComponent implements OnDestroy, AfterViewInit { + private readonly el = tuiInjectElement(); + private readonly keyboard = inject(TuiKeyboardService); + private readonly doc = inject(DOCUMENT); + private readonly scrollTop = this.doc.documentElement.scrollTop; + private readonly observer = new ResizeObserver(() => + this.refresh(this.doc.defaultView!.visualViewport!), + ); + + protected readonly directive = inject(TuiDropdownMobileDirective); + protected readonly dropdown = inject(TuiDropdownDirective); + protected readonly animation = { + value: '', + params: { + start: '100vh', + duration: tuiGetDuration(inject(TUI_ANIMATIONS_SPEED)), + }, + } as const; + + constructor() { + this.observer.observe(this.dropdown.el); + this.doc.documentElement.style.setProperty('scroll-behavior', 'initial'); + } + + public ngAfterViewInit(): void { + this.el.scrollTop = this.directive.tuiDropdownMobile ? this.el.clientHeight : 0; + } + + public ngOnDestroy(): void { + this.observer.disconnect(); + this.doc.body.classList.remove('t-dropdown-mobile'); + this.doc.body.style.removeProperty('--t-root-top'); + this.doc.documentElement.scrollTop = this.scrollTop; + this.doc.documentElement.style.removeProperty('scroll-behavior'); + + if (this.focused) { + this.keyboard.hide(); + } + } + + protected onClick(event: MouseEvent): void { + if ( + !this.el.contains(event.target as Node) && + // TODO: find a better way to check if the click is inside interactive element in textfield + !( + tuiIsNodeIn(event.target as Node, 'tui-svg') || + (tuiIsElement(event.target) && event.target.tagName === 'button') + ) + ) { + event.stopPropagation(); + } + } + + protected onSwipe({direction}: TuiSwipe, el: HTMLElement): void { + if ( + direction === 'bottom' && + el.getBoundingClientRect().bottom > Number(this.doc.defaultView?.innerHeight) + ) { + this.close(); + } + } + + protected onIntersection([{isIntersecting}]: IntersectionObserverEntry[]): void { + if (isIntersecting) { + this.close(); + } + } + + protected close(): void { + this.dropdown.toggle(false); + } + + protected refresh({offsetTop, height}: VisualViewport): void { + this.doc.body.style.removeProperty('--t-root-top'); + + if ( + !this.focused || + this.directive.tuiDropdownMobile || + !this.doc.documentElement.style.getPropertyValue('scroll-behavior') + ) { + return; + } + + this.doc.documentElement.scrollTop = 0; + + const rect = this.dropdown.el.getBoundingClientRect(); + const offset = rect.height + GAP * 2; + + this.el.style.setProperty('top', tuiPx(offsetTop + offset)); + this.el.style.setProperty('height', tuiPx(height - offset)); + this.doc.body.classList.add('t-dropdown-mobile'); + this.doc.body.style.setProperty( + '--t-root-top', + tuiPx(offsetTop + GAP - rect.top), + ); + } + + private get focused(): boolean { + return this.dropdown.el.contains(tuiGetNativeFocused(this.doc)); + } +} diff --git a/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.directive.ts b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.directive.ts new file mode 100644 index 000000000000..bd695ca9aef7 --- /dev/null +++ b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.directive.ts @@ -0,0 +1,42 @@ +import {Directive, HostListener, inject, Input} from '@angular/core'; +import {TUI_IS_MOBILE, tuiIsHTMLElement} from '@taiga-ui/cdk'; +import {TUI_DROPDOWN_COMPONENT} from '@taiga-ui/core'; + +import {TuiDropdownMobileComponent} from './dropdown-mobile.component'; + +@Directive({ + standalone: true, + selector: '[tuiDropdownMobile]', + providers: [ + { + provide: TUI_DROPDOWN_COMPONENT, + useFactory: () => + inject(TUI_IS_MOBILE) + ? TuiDropdownMobileComponent + : inject(TUI_DROPDOWN_COMPONENT, {skipSelf: true}), + }, + ], + host: { + '[style.visibility]': '"visible"', + }, +}) +export class TuiDropdownMobileDirective { + private readonly isMobile = inject(TUI_IS_MOBILE); + + @Input() + public tuiDropdownMobile = ''; + + @HostListener('mousedown', ['$event']) + protected onMouseDown(event: MouseEvent): void { + if ( + !this.isMobile || + !tuiIsHTMLElement(event.target) || + !event.target.matches('input,textarea') + ) { + return; + } + + event.preventDefault(); + event.target.focus({preventScroll: true}); + } +} diff --git a/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.style.less b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.style.less new file mode 100644 index 000000000000..4670c875ea93 --- /dev/null +++ b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.style.less @@ -0,0 +1,108 @@ +@import '@taiga-ui/core/styles/taiga-ui-local'; + +tui-dropdown-mobile:not(._sheet) { + .scrollbar-hidden(); + .fullsize(fixed); + .transition(transform); + + visibility: visible !important; + transform: translate3d(0, 0, 0); + background: var(--tui-base-01); + overscroll-behavior: contain; + overflow: auto; + box-shadow: + 0 -0.5rem 0.5rem var(--tui-base-01), + 0 10rem var(--tui-base-01), + 0 20rem var(--tui-base-01), + 0 30rem var(--tui-base-01); + + &:after { + content: ''; + display: block; + height: 1px; + } + + > .t-container { + .scrollbar-hidden(); + + position: sticky; + top: 0; + height: 100%; + overflow: auto; + margin: 0 0.75rem; + touch-action: pan-y !important; + } + + [tuiDropdownButton][tuiDropdownButton] { + position: fixed; + right: 1rem; + bottom: 1rem; + display: inline-flex; + } +} + +tui-dropdown-mobile._sheet { + .fullsize(fixed, inset); + .scrollbar-hidden(); + + overflow: auto; + background: rgba(0, 0, 0, 0.75); + /* stylelint-disable-next-line */ + box-shadow: 0 -50vh 5rem 5rem rgba(0, 0, 0, 0.75); + overflow-y: scroll; + scroll-snap-type: y mandatory; + overscroll-behavior: none; + + > .t-filler { + height: 100%; + scroll-snap-stop: always; + scroll-snap-align: start; + } + + > .t-container { + display: flex; + max-height: calc(100% - 1rem); + flex-direction: column; + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; + padding: 0 0.5rem; + scroll-snap-stop: always; + scroll-snap-align: start; + background: var(--tui-elevation-01); + + > .t-heading { + position: relative; + margin: 0; + padding: 2rem 0.5rem 0.75rem; + font: var(--tui-font-heading-6); + + &:before { + content: ''; + position: absolute; + left: 50%; + top: 0.75rem; + width: 2rem; + height: 0.25rem; + border-radius: 1rem; + background: var(--tui-clear-hover); + transform: translate(-50%, -50%); + } + } + + > .t-content { + .scrollbar-hidden(); + + overflow: auto; + } + } +} + +.t-dropdown-mobile { + touch-action: none; + visibility: hidden; + + * { + touch-action: inherit; + visibility: inherit; + } +} diff --git a/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.template.html b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.template.html new file mode 100644 index 000000000000..7b70c608d9f5 --- /dev/null +++ b/projects/addon-mobile/directives/dropdown-mobile/dropdown-mobile.template.html @@ -0,0 +1,25 @@ +
+You can even insert other components: