diff --git a/packages/components/code-block/__screenshots__/01-dark.png b/packages/components/code-block/__screenshots__/01-dark.png index 614c237700..d035eef858 100644 Binary files a/packages/components/code-block/__screenshots__/01-dark.png and b/packages/components/code-block/__screenshots__/01-dark.png differ diff --git a/packages/components/code-block/__screenshots__/01-light.png b/packages/components/code-block/__screenshots__/01-light.png index c9388718b9..31e0c1d95e 100644 Binary files a/packages/components/code-block/__screenshots__/01-light.png and b/packages/components/code-block/__screenshots__/01-light.png differ diff --git a/packages/components/tabs/__screenshots__/01-dark.png b/packages/components/tabs/__screenshots__/01-dark.png index 609a4932f5..2536311b31 100644 Binary files a/packages/components/tabs/__screenshots__/01-dark.png and b/packages/components/tabs/__screenshots__/01-dark.png differ diff --git a/packages/components/tabs/__screenshots__/01-light.png b/packages/components/tabs/__screenshots__/01-light.png index 759d46bf7c..ce7f7881eb 100644 Binary files a/packages/components/tabs/__screenshots__/01-light.png and b/packages/components/tabs/__screenshots__/01-light.png differ diff --git a/packages/components/tabs/_tabs-common.scss b/packages/components/tabs/_tabs-common.scss index 8397e9e56c..91120de92d 100644 --- a/packages/components/tabs/_tabs-common.scss +++ b/packages/components/tabs/_tabs-common.scss @@ -139,14 +139,23 @@ .kbq-tab-header__pagination { @include vendor-prefixes.user-select(none); - position: relative; + // Overlaid on top of the scroll container (rather than a flex sibling reserving its own + // space) so the tab list always spans the full width, matching Koobiq React: the button + // itself fades away at each scroll bound instead of the tab list resizing around it. + position: absolute; + inset-block: 0; display: none; justify-content: center; align-items: center; z-index: 2; -webkit-tap-highlight-color: transparent; - touch-action: none; + // Not `none`: this element overlays live scroll-container edge, so `none` also blocks a + // touch swipe that starts in that band from panning the strip at all. `manipulation` still + // suppresses double-tap-to-zoom, which is all this was here for. + touch-action: manipulation; padding: 0 var(--kbq-size-m); + opacity: 1; + visibility: visible; .kbq-tab-header__pagination-controls_enabled & { display: flex; @@ -155,6 +164,106 @@ &:not(.kbq-disabled) { cursor: pointer; } + + &.kbq-disabled { + opacity: 0; + visibility: hidden; + pointer-events: none; + } + } + + .kbq-tab-header__pagination_before { + inset-inline-start: 0; + } + + .kbq-tab-header__pagination_after { + inset-inline-end: 0; + } + + .kbq-tab-header__scroll-container { + @include vendor-prefixes.user-select(none); + + // Matches `.kbq-tab-header__pagination`'s real rendered width (its horizontal padding + // twice, plus the 16px arrow icon) rather than an unrelated constant — otherwise the label + // underneath fades in for a few pixels *before* it clears the paginator it's still hidden + // behind, which is a band that's visible but can't be clicked (the paginator, not the + // label, is what's on top there). + --mask-size: calc(var(--kbq-size-m) * 2 + 16px); + + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + // `kbq-tab-group_vertical` sits on an ancestor either way: the `` wrapping a + // `kbq-tab-header`, or the `[kbqTabNavBar]` host itself — so one descendant selector covers + // both `KbqPaginatedTabHeader` subclasses instead of the two disagreeing on the scrollbar. + .kbq-tab-group_vertical & { + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: auto; + + &::-webkit-scrollbar { + display: block; + } + } + + .kbq-tab-header__pagination-controls_enabled & { + cursor: grab; + } + + &.kbq-tab-header__scroll-container_dragging { + cursor: grabbing; + } + + &.kbq-tab-header__scroll-container_overflow-before { + mask-image: linear-gradient(to right, transparent var(--mask-size), #000 calc(var(--mask-size) * 1.5)); + } + + &.kbq-tab-header__scroll-container_overflow-after { + mask-image: linear-gradient( + to right, + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); + } + + &.kbq-tab-header__scroll-container_overflow-before.kbq-tab-header__scroll-container_overflow-after { + mask-image: linear-gradient( + to right, + transparent var(--mask-size), + #000 calc(var(--mask-size) * 1.5), + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); + } + + .kbq-tab-header_rtl &.kbq-tab-header__scroll-container_overflow-before { + mask-image: linear-gradient(to left, transparent var(--mask-size), #000 calc(var(--mask-size) * 1.5)); + } + + .kbq-tab-header_rtl &.kbq-tab-header__scroll-container_overflow-after { + mask-image: linear-gradient( + to left, + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); + } + + .kbq-tab-header_rtl + &.kbq-tab-header__scroll-container_overflow-before.kbq-tab-header__scroll-container_overflow-after { + mask-image: linear-gradient( + to left, + transparent var(--mask-size), + #000 calc(var(--mask-size) * 1.5), + #000 calc(100% - var(--mask-size) * 1.5), + transparent calc(100% - var(--mask-size)) + ); + } } .kbq-tab-header_underlined:not(.kbq-tab-header_vertical) .kbq-tab-list__content { diff --git a/packages/components/tabs/paginated-tab-header.ts b/packages/components/tabs/paginated-tab-header.ts index 07f4b5e618..c1628c874a 100644 --- a/packages/components/tabs/paginated-tab-header.ts +++ b/packages/components/tabs/paginated-tab-header.ts @@ -1,9 +1,9 @@ -import { FocusableOption, FocusKeyManager } from '@angular/cdk/a11y'; +import { FocusableOption, FocusKeyManager } from '@angular/cdk/a11y'; import { Direction, Directionality } from '@angular/cdk/bidi'; import { coerceNumberProperty } from '@angular/cdk/coercion'; import { ENTER, hasModifierKey, SPACE } from '@angular/cdk/keycodes'; +import { SharedResizeObserver } from '@angular/cdk/observers/private'; import { normalizePassiveListenerOptions, Platform } from '@angular/cdk/platform'; -import { ViewportRuler } from '@angular/cdk/scrolling'; import { AfterContentChecked, AfterContentInit, @@ -23,8 +23,8 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DOWN_ARROW, END, HOME, KBQ_WINDOW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW } from '@koobiq/components/core'; -import { fromEvent, merge, of as observableOf, Subject, timer } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { fromEvent, merge, of as observableOf, ReplaySubject, Subject, timer } from 'rxjs'; +import { auditTime, debounceTime, takeUntil } from 'rxjs/operators'; /** Config used to bind passive event listeners */ const passiveEventListenerOptions = normalizePassiveListenerOptions({ passive: true }) as EventListenerOptions; @@ -36,12 +36,6 @@ const passiveEventListenerOptions = normalizePassiveListenerOptions({ passive: t */ export type ScrollDirection = 'after' | 'before'; -/** - * The distance in pixels that will be overshot when scrolling a tab label into view. This helps - * provide a small affordance to the label next to it. - */ -const EXAGGERATED_OVERSCROLL = 60; - /** * Amount of milliseconds to wait before starting to scroll the header automatically. * Set a little conservatively in order to handle fake events dispatched on touch devices. @@ -54,9 +48,54 @@ const HEADER_SCROLL_DELAY = 650; */ const HEADER_SCROLL_INTERVAL = 100; -const VIEWPORT_THROTTLE_TIME = 150; +/** Fraction of the viewport width scrolled per arrow click/press tick. */ const SCROLL_DISTANCE = 0.8; +/** Minimum horizontal pointer movement (px) before a pointerdown is treated as a drag rather than a click. */ +const DRAG_THRESHOLD = 4; + +/** Below this speed (px/ms) an inertia coast stops. */ +const MIN_INERTIA_VELOCITY = 0.02; + +/** Clamp applied to the smoothed drag velocity so a jittery fast flick can't launch a huge coast. */ +const MAX_INERTIA_VELOCITY = 3; + +/** Clamp on a single inertia animation frame's elapsed time, guarding against dropped frames/backgrounded tabs. */ +const MAX_FRAME_DURATION = 32; + +/** Per-millisecond exponential decay rate applied to the inertia velocity. */ +const FRICTION_PER_MILLISECOND = 0.003; + +/** Audit interval (ms) for the scroll-box `ResizeObserver` — see the `auditTime` usage below. */ +const RESIZE_AUDIT_TIME = 100; + +/** Debounce (ms) for scroll-correction requests, so a burst of focus/selection changes settles before scrolling. */ +const SCROLL_CORRECTION_DEBOUNCE = 100; + +/** How often (ms) scroll/drag updates are allowed to trigger Angular change detection (arrow visibility, mask). */ +const SCROLL_CD_THROTTLE = 48; + +/** Applied to the scroll container while a drag gesture is in progress. */ +const DRAGGING_CLASS = 'kbq-tab-header__scroll-container_dragging'; + +/** + * Matches nested interactive controls (e.g. a tab's remove button) that have their own click + * behavior and are too small to reliably press without a few stray pixels of movement — a drag + * should never start on them, since crossing `DRAG_THRESHOLD` by accident would swallow their click. + */ +const NON_DRAGGABLE_TARGET_SELECTOR = 'button, [kbq-icon-button], input, select, textarea'; + +/** Tracks an in-progress mouse/pen drag gesture on the tab list. */ +interface DragState { + pointerId: number; + didDrag: boolean; + startX: number; + lastX: number; + lastTimestamp: number; + /** Smoothed pointer velocity in px/ms, positive meaning the pointer moved right. Exponential moving average. */ + velocity: number; +} + /** Item inside a paginated tab header. */ export type KbqPaginatedTabHeaderItem = FocusableOption & { elementRef: ElementRef }; @@ -99,23 +138,6 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte this.keyManager.setActiveItem(value); } - /** Sets the distance in pixels that the tab header should be transformed in the X-axis. */ - get scrollDistance(): number { - return this._scrollDistance; - } - - set scrollDistance(v: number) { - this._scrollDistance = Math.max(0, Math.min(this.getMaxScrollDistance(), v)); - - // Mark that the scroll distance has changed so that after the view is checked, the CSS - // transformation can move the header. - this.scrollDistanceChanged = true; - this.checkScrollingControls(); - } - - /** The distance in pixels that the tab labels should be translated to the left. */ - private _scrollDistance = 0; - abstract readonly items: QueryList; abstract readonly tabListContainer: ElementRef; abstract readonly tabList: ElementRef; @@ -169,9 +191,6 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte */ private tabLabelCount: number; - /** Whether the scroll distance has changed and should be applied after the view is checked. */ - private scrollDistanceChanged: boolean; - /** Used to manage focus between the tabs. */ private keyManager: FocusKeyManager; @@ -184,14 +203,46 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte /** Whether the header should scroll to the selected index after the view has been checked. */ private selectedIndexChanged = false; + /** State of the in-progress mouse/pen drag gesture, if any. */ + private dragState: DragState | null = null; + + /** `requestAnimationFrame` handle for an in-progress inertia coast, if any. */ + private inertiaFrameId: number | null = null; + + /** Set after a drag gesture so the click it would otherwise trigger on a tab is suppressed. */ + private suppressNextClick = false; + + /** Handle of the timer that clears {@link suppressNextClick}, so it can be cancelled on destroy. */ + private suppressClickTimeoutId: ReturnType | null = null; + + // Attached only while a drag is possibly in progress (from `pointerdown` to `pointerup`/ + // `pointercancel`) rather than for the component's whole lifetime: bound to `ownerDocument`, + // so every pointer move anywhere on the page would otherwise run this handler for every + // paginated tab header on the page, whether or not any of them is actually being dragged. + private readonly documentPointerMoveListener = (event: PointerEvent) => this.handlePointerMove(event); + private readonly documentPointerUpListener = (event: PointerEvent) => this.endDrag(event, true); + private readonly documentPointerCancelListener = (event: PointerEvent) => this.endDrag(event, false); + + /** Emits on every native `scroll` event and drag/inertia frame; throttled to limit change detection. */ + private readonly scrollProgress = new Subject(); + + /** + * Emits scroll-correction requests (from focus or selection changes); debounced so a burst + * settles once. `ReplaySubject(1)`, not `Subject`: content hooks (where the very first request, + * from `ngAfterContentChecked`, is sent) run before view hooks in the same initial change- + * detection pass, so a plain `Subject` would drop it — the `ngAfterViewInit` subscription below + * doesn't exist yet when it's emitted. + */ + private readonly scrollCorrectionRequest = new ReplaySubject<{ index: number; behavior: ScrollBehavior }>(1); + protected readonly destroyRef = inject(DestroyRef); public readonly elementRef = inject>(ElementRef); protected readonly changeDetectorRef = inject(ChangeDetectorRef); - private readonly viewportRuler = inject(ViewportRuler); private readonly ngZone = inject(NgZone); private readonly platform = inject(Platform); private readonly dir = inject(Directionality, { optional: true }); private readonly window = inject(KBQ_WINDOW); + private readonly sharedResizeObserver = inject(SharedResizeObserver); constructor() { // Bind the `mouseleave` event on the outside since it doesn't change anything in the view. @@ -212,11 +263,80 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte fromEvent(this.nextPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => this.handlePaginatorPress('after')); + + this.ngZone.runOutsideAngular(() => { + const container = this.tabListContainer.nativeElement; + + fromEvent(container, 'scroll', passiveEventListenerOptions) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + // A `vertical`/`disablePagination` header still fires native `scroll` events + // (e.g. `kbq-tab-group_vertical`'s own `overflow-y: auto`); without this guard + // every one of them would still throttle-trigger a whole-app change-detection + // tick for four booleans that `updateScrollState` has already pinned to a + // constant and can never change. + if (this.disablePagination) return; + + this.updateScrollState(); + this.scrollProgress.next(); + }); + + // Any wheel/trackpad input should immediately take over from a running inertia coast. + fromEvent(container, 'wheel', passiveEventListenerOptions) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.cancelInertia()); + + fromEvent(container, 'pointerdown') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.handlePointerDown(event)); + + // A native drag-and-drop gesture (e.g. starting on a `kbqTabLink`'s ``) would + // otherwise hijack our own pointer-based drag partway through, aborting the gesture via + // a `pointercancel` while a browser-native drag ghost follows the cursor instead. + fromEvent(container, 'dragstart') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + if (this.dragState?.didDrag) event.preventDefault(); + }); + + // Fallback for when capture is lost without a `pointerup`/`pointercancel` of its own + // reaching us (e.g. the element is detached, or another element steals capture). + fromEvent(container, 'lostpointercapture') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.endDrag(event, true)); + + fromEvent(container, 'click', { capture: true }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + if (this.suppressNextClick) { + event.preventDefault(); + event.stopPropagation(); + this.suppressNextClick = false; + } + }); + + this.scrollProgress + .pipe(auditTime(SCROLL_CD_THROTTLE), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.ngZone.run(() => this.changeDetectorRef.markForCheck())); + }); + + // Covers layout changes that resize the scroll box without a `scroll` event of their own + // (e.g. a sidebar toggling, not just the window resizing). `auditTime`, not `debounceTime`: + // a `debounceTime` only emits once a live resize (e.g. dragging a window edge or a splitter + // pane) has stopped for a full `RESIZE_AUDIT_TIME`, so pagination stays frozen — arrows + // hidden, drag-scroll dead, no edge mask — for the whole drag instead of updating as it goes. + this.sharedResizeObserver + .observe(this.tabListContainer.nativeElement) + .pipe(auditTime(RESIZE_AUDIT_TIME), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.updatePagination()); + + this.scrollCorrectionRequest + .pipe(debounceTime(SCROLL_CORRECTION_DEBOUNCE), takeUntilDestroyed(this.destroyRef)) + .subscribe(({ index, behavior }) => this.scrollCorrection(index, behavior)); } ngAfterContentInit() { const dirChange = this.dir ? this.dir.change : observableOf('ltr'); - const resize = this.viewportRuler.change(VIEWPORT_THROTTLE_TIME); const realign = () => { this.updatePagination(); @@ -236,20 +356,15 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte realign(); } - // On dir change or window resize, realign the ink bar and update the orientation of - // the key manager if the direction has changed. - merge(dirChange, resize, this.items.changes) + // On dir change or content change, realign and update the orientation of the key manager + // if the direction has changed. Container resize is handled separately by the `ResizeObserver`. + merge(dirChange, this.items.changes) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { // We need to defer this to give the browser some time to recalculate // the element dimensions. The call has to be wrapped in `NgZone.run`, - // because the viewport change handler runs outside of Angular. - this.ngZone.run(() => - Promise.resolve().then(() => { - this.updateScrollPosition(); - realign(); - }) - ); + // because the direction change handler can run outside of Angular. + this.ngZone.run(() => Promise.resolve().then(realign)); this.keyManager.withHorizontalOrientation(this.getLayoutDirection()); }); @@ -271,25 +386,22 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte this.changeDetectorRef.markForCheck(); } - // If the selected index has changed, scroll to the label and check if the scrolling controls - // should be disabled. + // If the selected index has changed, scroll to the label. if (this.selectedIndexChanged) { - this.scrollToLabel(this._selectedIndex); - this.checkScrollingControls(); this.selectedIndexChanged = false; - this.changeDetectorRef.markForCheck(); - } - - // If the scroll distance has been changed (tab selected, focused, scroll controls activated), - // then translate the header to reflect this. - if (this.scrollDistanceChanged) { - this.updateTabScrollPosition(); - this.scrollDistanceChanged = false; + this.scrollCorrectionRequest.next({ index: this._selectedIndex, behavior: 'smooth' }); this.changeDetectorRef.markForCheck(); } } ngOnDestroy() { + this.cancelDrag(); + this.cancelInertia(); + + if (this.suppressClickTimeoutId !== null) { + this.window.clearTimeout(this.suppressClickTimeoutId); + } + this.stopScrolling.complete(); } @@ -354,8 +466,8 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte if (!this.platform.isBrowser) return; this.checkPaginationEnabled(); - this.checkScrollingControls(); - this.updateTabScrollPosition(); + this.updateScrollState(); + this.changeDetectorRef.markForCheck(); } /** @@ -377,24 +489,16 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte * scrolling is enabled. */ setTabFocus(tabIndex: number) { - if (this.showPaginationControls) { - this.scrollToLabel(tabIndex); - } + if (!this.items?.length) return; - if (this.items?.length) { - this.items.toArray()[tabIndex].focus(); + const item = this.items.toArray()[tabIndex]; - // Do not let the browser manage scrolling to focus the element, this will be handled - // by using translation. In LTR, the scroll left should be 0. In RTL, the scroll width - // should be the full width minus the offset width. - const containerEl = this.tabListContainer.nativeElement; - const dir = this.getLayoutDirection(); + // Prevent the browser's own scroll-into-view behavior so the scroll-correction request + // below is the only thing driving the scroll position. + item.elementRef.nativeElement.focus({ preventScroll: true }); - if (dir === 'ltr') { - containerEl.scrollLeft = 0; - } else { - containerEl.scrollLeft = containerEl.scrollWidth - containerEl.offsetWidth; - } + if (this.showPaginationControls) { + this.scrollCorrectionRequest.next({ index: tabIndex, behavior: 'auto' }); } } @@ -403,47 +507,19 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte return this.dir?.value === 'rtl' ? 'rtl' : 'ltr'; } - /** Performs the CSS transformation on the tab list that will cause the list to scroll. */ - updateTabScrollPosition() { - if (this.disablePagination) { - return; - } - - const scrollDistance = this.scrollDistance; - const translateX = this.getLayoutDirection() === 'ltr' ? -scrollDistance : scrollDistance; - - // Don't use `translate3d` here because we don't want to create a new layer. A new layer - // seems to cause flickering and overflow in Internet Explorer. For example, the ink bar - // and ripples will exceed the boundaries of the visible tab bar. - // See: https://github.com/angular/components/issues/10276 - // We round the `transform` here, because transforms with sub-pixel precision cause some - // browsers to blur the content of the element. - this.tabList.nativeElement.style.transform = `translateX(${Math.round(translateX)}px)`; - - // Setting the `transform` on IE will change the scroll offset of the parent, causing the - // position to be thrown off in some cases. We have to reset it ourselves to ensure that - // it doesn't get thrown off. Note that we scope it only to IE and Edge, because messing - // with the scroll position throws off Chrome 71+ in RTL mode (see #14689). - if (this.platform.TRIDENT || this.platform.EDGE) { - this.tabListContainer.nativeElement.scrollLeft = 0; - } - } - /** * Moves the tab list in the 'before' or 'after' direction (towards the beginning of the list or - * the end of the list, respectively). The distance to scroll is computed to be a third of the - * length of the tab list view window. + * the end of the list, respectively). * * This is an expensive call that forces a layout reflow to compute box and scroll metrics and * should be called sparingly. */ scrollHeader(direction: ScrollDirection) { - const viewLength = this.tabListContainer.nativeElement.offsetWidth; + const container = this.tabListContainer.nativeElement; + const viewLength = container.clientWidth; + const amount = (direction === 'before' ? -1 : 1) * viewLength * SCROLL_DISTANCE; - // Move the scroll distance one-third the length of the tab list's viewport. - const scrollAmount = (direction === 'before' ? -1 : 1) * viewLength * SCROLL_DISTANCE; - - return this.scrollTo(this.scrollDistance + scrollAmount); + this.scroll(this.logicalScrollPosition + amount); } /** Handles click events on the pagination arrows. */ @@ -452,50 +528,6 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte this.scrollHeader(direction); } - /** - * Moves the tab list such that the desired tab label (marked by index) is moved into view. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - scrollToLabel(labelIndex: number) { - if (this.disablePagination) { - return; - } - - const selectedLabel = this.items ? this.items.toArray()[labelIndex] : null; - - if (!selectedLabel) { - return; - } - - // The view length is the visible width of the tab labels. - const viewLength = this.tabListContainer.nativeElement.offsetWidth; - const { offsetLeft, offsetWidth } = selectedLabel.elementRef.nativeElement; - - let labelBeforePos: number; - let labelAfterPos: number; - - if (this.getLayoutDirection() === 'ltr') { - labelBeforePos = offsetLeft; - labelAfterPos = labelBeforePos + (offsetWidth as number); - } else { - labelAfterPos = this.tabList.nativeElement.offsetWidth - offsetLeft; - labelBeforePos = labelAfterPos - offsetWidth; - } - - const beforeVisiblePos = this.scrollDistance; - const afterVisiblePos = this.scrollDistance + viewLength; - - if (labelBeforePos < beforeVisiblePos) { - // Scroll header to move label to the before direction - this.scrollDistance -= beforeVisiblePos - labelBeforePos + EXAGGERATED_OVERSCROLL; - } else if (labelAfterPos > afterVisiblePos) { - // Scroll header to move label to the after direction - this.scrollDistance += labelAfterPos - afterVisiblePos + EXAGGERATED_OVERSCROLL; - } - } - /** * Evaluate whether the pagination controls should be displayed. If the scroll width of the * tab list is wider than the size of the header container, then the pagination controls should @@ -507,53 +539,24 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte checkPaginationEnabled() { if (this.disablePagination) { this.showPaginationControls = false; - } else { - const isEnabled = this.tabList.nativeElement.scrollWidth > this.elementRef.nativeElement.offsetWidth; - if (!isEnabled) { - this.scrollDistance = 0; - } + return; + } - if (isEnabled !== this.showPaginationControls) { - this.changeDetectorRef.markForCheck(); - } + const container = this.tabListContainer.nativeElement; + const isEnabled = container.scrollWidth > container.clientWidth; - this.showPaginationControls = isEnabled; + if (!isEnabled) { + this.cancelDrag(); + this.cancelInertia(); + container.scrollLeft = 0; } - } - /** - * Evaluate whether the before and after controls should be enabled or disabled. - * If the header is at the beginning of the list (scroll distance is equal to 0) then disable the - * before button. If the header is at the end of the list (scroll distance is equal to the - * maximum distance we can scroll), then disable the after button. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - checkScrollingControls() { - if (this.disablePagination) { - this.disableScrollAfter = this.disableScrollBefore = true; - } else { - // Check if the pagination arrows should be activated. - this.disableScrollBefore = this.scrollDistance === 0; - this.disableScrollAfter = this.scrollDistance === this.getMaxScrollDistance(); + if (isEnabled !== this.showPaginationControls) { this.changeDetectorRef.markForCheck(); } - } - /** - * Determines what is the maximum length in pixels that can be set for the scroll distance. This - * is equal to the difference in width between the tab list container and tab header container. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - getMaxScrollDistance(): number { - const lengthOfTabList = this.tabList.nativeElement.scrollWidth; - const viewLength = this.tabListContainer.nativeElement.offsetWidth; - - return lengthOfTabList - viewLength || 0; + this.showPaginationControls = isEnabled; } /** Stops the currently-running paginator interval. */ @@ -582,44 +585,323 @@ export abstract class KbqPaginatedTabHeader implements AfterContentChecked, Afte // Keep the timer going until something tells it to stop or the component is destroyed. .pipe(takeUntilDestroyed(this.destroyRef), takeUntil(this.stopScrolling)) .subscribe(() => { - const { maxScrollDistance, distance } = this.scrollHeader(direction); - - // Stop the timer if we've reached the start or the end. - if (distance === 0 || distance >= maxScrollDistance) { + // Read live scroll metrics rather than `disableScrollBefore`/`disableScrollAfter`: + // those are only refreshed by the native `scroll` event, which lags a `smooth` + // `scrollHeader` write by more than one `HEADER_SCROLL_INTERVAL` tick. Checking the + // bound matching `direction` (not either bound) also stops the repeat exactly once + // that side is reached, instead of the moment the *other* side happens to be at rest. + this.updateScrollState(); + + if (direction === 'before' ? this.disableScrollBefore : this.disableScrollAfter) { this.stopInterval(); + + return; } + + this.scrollHeader(direction); }); } protected abstract itemSelected(event: KeyboardEvent): void; /** - * Scrolls the header to a given position. - * @param position Position to which to scroll. - * @returns Information on the current scroll distance and the maximum. + * The tab list's scroll position expressed independently of reading direction: `0` at the + * start of the tab list, growing towards the end — mirrors native `scrollLeft`'s RTL-dependent + * sign so callers can reason about "before"/"after" without checking direction themselves. + */ + private get logicalScrollPosition(): number { + const scrollLeft = this.tabListContainer.nativeElement.scrollLeft; + + return this.getLayoutDirection() === 'rtl' ? -scrollLeft : scrollLeft; + } + + /** + * Scrolls the header to a given logical position (see {@link logicalScrollPosition}). + * @param value Logical position to scroll to. + * @param behavior Scroll animation behavior; `'auto'` jumps instantly, `'smooth'` animates. + */ + private scroll(value: number, behavior: ScrollBehavior = 'smooth'): void { + if (this.disablePagination) return; + + this.cancelInertia(); + + const left = this.getLayoutDirection() === 'rtl' ? -value : value; + + this.tabListContainer.nativeElement.scrollTo({ left, behavior }); + } + + /** + * Moves the tab list such that the desired tab label (marked by index) is moved into view. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. */ - private scrollTo(position: number) { + private scrollCorrection(labelIndex: number, behavior: ScrollBehavior = 'smooth'): void { + if (this.disablePagination) return; + // A focus/selection change can queue a correction (debounced by `SCROLL_CORRECTION_DEBOUNCE`) + // that then fires in the middle of an unrelated drag or inertia coast — e.g. a `pointerdown` + // on a `kbqTabLink` focuses it before the drag threshold is crossed. Without this guard the + // correction teleports the strip back under the cursor mid-drag, or kills a running coast. + if (this.dragState || this.inertiaFrameId !== null) return; + + const selectedLabel = this.items ? this.items.toArray()[labelIndex] : null; + + if (!selectedLabel) return; + + const container = this.tabListContainer.nativeElement; + const viewLength = container.clientWidth; + const { offsetLeft, offsetWidth } = selectedLabel.elementRef.nativeElement; + + let labelBeforePos: number; + let labelAfterPos: number; + + if (this.getLayoutDirection() === 'ltr') { + labelBeforePos = offsetLeft; + labelAfterPos = labelBeforePos + (offsetWidth as number); + } else { + labelAfterPos = this.tabList.nativeElement.offsetWidth - offsetLeft; + labelBeforePos = labelAfterPos - offsetWidth; + } + + const scrollPosition = this.logicalScrollPosition; + const beforeVisiblePos = scrollPosition; + const afterVisiblePos = scrollPosition + viewLength; + + if (labelBeforePos < beforeVisiblePos) { + // Overshoot by the real paginator button width, so the label isn't flush with the + // edge the arrow overlays. + const overscroll = this.previousPaginator.nativeElement.clientWidth || 0; + + this.scroll(labelBeforePos - overscroll, behavior); + } else if (labelAfterPos > afterVisiblePos) { + const overscroll = this.nextPaginator.nativeElement.clientWidth || 0; + + this.scroll(scrollPosition + (labelAfterPos - afterVisiblePos + overscroll), behavior); + } + } + + /** + * Recomputes the pagination arrow-enabled state from the container's real scroll metrics. + * Bound to the native `scroll` event — this is the sole source of truth, no imperative call + * is needed after drag/inertia/arrow-click scroll writes. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + private updateScrollState(): void { if (this.disablePagination) { - return { maxScrollDistance: 0, distance: 0 }; + this.disableScrollAfter = this.disableScrollBefore = true; + + return; + } + + const container = this.tabListContainer.nativeElement; + const position = this.logicalScrollPosition; + + // `Math.ceil` guards against subpixel `scrollWidth`/`clientWidth` rounding producing a + // false "still scrollable" reading right at the end. + this.disableScrollBefore = position <= 0; + this.disableScrollAfter = Math.ceil(position + container.clientWidth) >= container.scrollWidth; + } + + private handlePointerDown(event: PointerEvent): void { + if (!this.platform.isBrowser || this.disablePagination || !this.showPaginationControls) return; + + // Any press on the strip takes over from a running inertia coast, whatever the pointer + // type — otherwise a tap on a touch-panning device, or a press on a nested control, would + // leave the previous coast running underneath it. + this.cancelInertia(); + + // Touch keeps its existing interaction model (pagination arrows); only mouse/pen drag here. + if (this.dragState || event.pointerType === 'touch' || event.button !== 0) return; + // Don't hijack presses on nested controls (e.g. a tab's remove button) into a drag. + if ((event.target as HTMLElement).closest?.(NON_DRAGGABLE_TARGET_SELECTOR)) return; + + this.attachDocumentDragListeners(); + this.dragState = { + pointerId: event.pointerId, + didDrag: false, + startX: event.clientX, + lastX: event.clientX, + lastTimestamp: event.timeStamp, + velocity: 0 + }; + } + + private handlePointerMove(event: PointerEvent): void { + const state = this.dragState; + + if (!state || event.pointerId !== state.pointerId) return; + + // A release outside the window/document never reaches `ownerDocument` as a `pointerup` — + // a buttonless move is the only signal that the gesture already ended. + if (!event.buttons) { + this.endDrag(event, true); + + return; } - const maxScrollDistance = this.getMaxScrollDistance(); + if (!state.didDrag) { + if (Math.abs(event.clientX - state.startX) < DRAG_THRESHOLD) return; + + state.didDrag = true; + this.tabListContainer.nativeElement.classList.add(DRAGGING_CLASS); + + try { + this.tabListContainer.nativeElement.setPointerCapture?.(state.pointerId); + } catch { + // Pointer capture can fail if the pointer is no longer active — the drag still + // works via the document-level pointermove/pointerup listeners. + } + } + + event.preventDefault(); + + const distance = event.clientX - state.lastX; + const elapsed = event.timeStamp - state.lastTimestamp; + const instantVelocity = elapsed > 0 ? this.clampVelocity(-distance / elapsed) : 0; - this.scrollDistance = Math.max(0, Math.min(maxScrollDistance, position)); + // Exponential moving average, not a windowed sample array — smooths out jittery per-move deltas. + state.velocity = state.velocity === 0 ? instantVelocity : state.velocity * 0.7 + instantVelocity * 0.3; + state.lastX = event.clientX; + state.lastTimestamp = event.timeStamp; - // Mark that the scroll distance has changed so that after the view is checked, the CSS - // transformation can move the header. - this.scrollDistanceChanged = true; - this.checkScrollingControls(); + // The browser clamps this for free at the scroll bounds — no `getMaxScrollDistance()` needed. + this.tabListContainer.nativeElement.scrollLeft -= distance; + } + + // Aborts an in-progress drag without applying inertia, e.g. when pagination is disabled mid-gesture. + private cancelDrag(): void { + const state = this.dragState; + + if (!state) return; + + this.tabListContainer.nativeElement.classList.remove(DRAGGING_CLASS); + this.dragState = null; + this.detachDocumentDragListeners(); - return { maxScrollDistance, distance: this.scrollDistance }; + // Only a gesture that actually became a drag captured the pointer / needs its resulting + // click suppressed — matches the same condition `endDrag` guards on below. + if (state.didDrag) this.finishDrag(state); } - private updateScrollPosition() { - const maxScrollDistance = this.getMaxScrollDistance(); + // Ends a drag gesture; `applyInertia` is false for `pointercancel`, where no coast is expected. + private endDrag(event: PointerEvent, applyInertia: boolean): void { + const state = this.dragState; + + if (!state || event.pointerId !== state.pointerId) return; + + this.dragState = null; + this.tabListContainer.nativeElement.classList.remove(DRAGGING_CLASS); + this.detachDocumentDragListeners(); + + if (!state.didDrag) return; - if (this.scrollDistance > maxScrollDistance) { - this.scrollTo(maxScrollDistance); + this.finishDrag(state); + + const releaseDelay = event.timeStamp - state.lastTimestamp; + const releaseVelocity = + applyInertia && releaseDelay <= 80 + ? state.velocity * Math.exp(-FRICTION_PER_MILLISECOND * releaseDelay) + : 0; + + if (Math.abs(releaseVelocity) >= MIN_INERTIA_VELOCITY) { + this.startInertia(releaseVelocity); } } + + // Releases pointer capture and arms the click-suppression latch — shared by `endDrag`/`cancelDrag` + // so a drag aborted mid-gesture (e.g. by `checkPaginationEnabled`) doesn't strand capture or end + // in an unintended tab selection. + private finishDrag(state: DragState): void { + try { + this.tabListContainer.nativeElement.releasePointerCapture?.(state.pointerId); + } catch { + // Capture may already be lost, e.g. if the element was detached mid-drag. + } + + this.suppressNextClick = true; + + if (this.suppressClickTimeoutId !== null) this.window.clearTimeout(this.suppressClickTimeoutId); + + // `pointercancel` never produces a trailing `click` — if it's the one that armed the + // suppression, it would otherwise stay stuck `true` forever and eat the next unrelated + // click. Reset unconditionally on a timer instead of only inside the click listener. + this.suppressClickTimeoutId = this.window.setTimeout(() => { + this.suppressNextClick = false; + this.suppressClickTimeoutId = null; + }, 0); + } + + // Attached for the duration of a possible drag only — see the listener fields' doc comment. + private attachDocumentDragListeners(): void { + const ownerDocument = this.elementRef.nativeElement.ownerDocument; + + ownerDocument.addEventListener('pointermove', this.documentPointerMoveListener); + ownerDocument.addEventListener('pointerup', this.documentPointerUpListener); + ownerDocument.addEventListener('pointercancel', this.documentPointerCancelListener); + } + + private detachDocumentDragListeners(): void { + const ownerDocument = this.elementRef.nativeElement.ownerDocument; + + ownerDocument.removeEventListener('pointermove', this.documentPointerMoveListener); + ownerDocument.removeEventListener('pointerup', this.documentPointerUpListener); + ownerDocument.removeEventListener('pointercancel', this.documentPointerCancelListener); + } + + // Coasts the header from the release velocity, decaying it every frame — matches a natural flick. + private startInertia(releaseVelocity: number): void { + const container = this.tabListContainer.nativeElement; + const maxScrollLeft = container.scrollWidth - container.clientWidth; + // `scrollLeft`'s native bounds, expressed in whichever direction is "positive" for this + // reading direction — RTL browsers run `scrollLeft` from 0 down to `-maxScrollLeft`. + const [minBound, maxBound] = this.getLayoutDirection() === 'rtl' ? [-maxScrollLeft, 0] : [0, maxScrollLeft]; + + let velocity = releaseVelocity; + // Accumulated in a local rather than read back from `scrollLeft`: the browser snaps the + // stored offset to the device-pixel grid (e.g. 1/3 px steps at devicePixelRatio 1.5), so a + // sub-pixel-per-frame write at a high refresh rate would otherwise round away to nothing and + // the readback-equality boundary check below would mistake that for having hit a bound. + let position = container.scrollLeft; + let lastTimestamp: number | null = null; + + const step = (timestamp: number) => { + const frameDuration = lastTimestamp === null ? 0 : Math.min(timestamp - lastTimestamp, MAX_FRAME_DURATION); + + lastTimestamp = timestamp; + velocity *= Math.exp(-FRICTION_PER_MILLISECOND * frameDuration); + position += velocity * frameDuration; + + const clamped = Math.max(minBound, Math.min(maxBound, position)); + + container.scrollLeft = clamped; + + const reachedBoundary = clamped !== position; + + position = clamped; + + if (Math.abs(velocity) < MIN_INERTIA_VELOCITY || reachedBoundary) { + this.inertiaFrameId = null; + + return; + } + + this.inertiaFrameId = this.window.requestAnimationFrame(step); + }; + + this.inertiaFrameId = this.window.requestAnimationFrame(step); + } + + // Stops an in-progress inertia coast, e.g. because a new gesture or scroll input took over. + private cancelInertia(): void { + if (this.inertiaFrameId === null) return; + + this.window.cancelAnimationFrame(this.inertiaFrameId); + this.inertiaFrameId = null; + } + + private clampVelocity(velocity: number): number { + return Math.max(-MAX_INERTIA_VELOCITY, Math.min(MAX_INERTIA_VELOCITY, velocity)); + } } diff --git a/packages/components/tabs/tab-group.scss b/packages/components/tabs/tab-group.scss index 37522df83d..0dcdeaa94a 100644 --- a/packages/components/tabs/tab-group.scss +++ b/packages/components/tabs/tab-group.scss @@ -17,10 +17,6 @@ .kbq-tab-list__content { gap: var(--kbq-tabs-size-tab-stack-vertical-content-gap-vertical) 0; } - - & .kbq-tab-header__container { - overflow-y: auto; - } } .kbq-tab-body__wrapper { diff --git a/packages/components/tabs/tab-header.component.ts b/packages/components/tabs/tab-header.component.ts index 9022d9bbfc..ab95fc30c9 100644 --- a/packages/components/tabs/tab-header.component.ts +++ b/packages/components/tabs/tab-header.component.ts @@ -14,6 +14,7 @@ import { } from '@angular/core'; import { isUndefined } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqNativeScrollbar } from '@koobiq/components/scrollbar'; import { KbqPaginatedTabHeader } from './paginated-tab-header'; import { KbqTabLabelWrapper } from './tab-label-wrapper.directive'; @@ -36,7 +37,7 @@ const TAB_PADDING = 12; */ @Component({ selector: 'kbq-tab-header', - imports: [KbqIconModule, CdkObserveContent], + imports: [KbqIconModule, CdkObserveContent, KbqNativeScrollbar], templateUrl: './tab-header.html', styleUrl: './tab-header.scss', changeDetection: ChangeDetectionStrategy.Default, diff --git a/packages/components/tabs/tab-header.html b/packages/components/tabs/tab-header.html index 223b62fe8c..6d724af39d 100644 --- a/packages/components/tabs/tab-header.html +++ b/packages/components/tabs/tab-header.html @@ -6,10 +6,17 @@ (mousedown)="handlePaginatorPress('before', $event)" (touchend)="stopInterval()" > - + -
+
@@ -31,5 +38,5 @@ (mousedown)="handlePaginatorPress('after', $event)" (touchend)="stopInterval()" > - +
diff --git a/packages/components/tabs/tab-header.scss b/packages/components/tabs/tab-header.scss index 672606130a..6e4bf6e0ec 100644 --- a/packages/components/tabs/tab-header.scss +++ b/packages/components/tabs/tab-header.scss @@ -41,7 +41,6 @@ display: flex; flex-grow: 1; z-index: 1; - overflow: hidden; .kbq-tab-header_underlined:not(.kbq-tab-header__pagination-controls_enabled) & { overflow: visible; @@ -51,7 +50,6 @@ .kbq-tab-list { position: relative; width: 100%; - transition: transform 500ms cubic-bezier(0.35, 0, 0.25, 1); } .kbq-tab-label { diff --git a/packages/components/tabs/tab-header.spec.ts b/packages/components/tabs/tab-header.spec.ts index 8413aaedf2..a17552e8e1 100644 --- a/packages/components/tabs/tab-header.spec.ts +++ b/packages/components/tabs/tab-header.spec.ts @@ -1,22 +1,42 @@ -import { Direction, Directionality } from '@angular/cdk/bidi'; +import { Direction, Directionality } from '@angular/cdk/bidi'; +import { SharedResizeObserver } from '@angular/cdk/observers/private'; import { PortalModule } from '@angular/cdk/portal'; -import { ScrollingModule, ViewportRuler } from '@angular/cdk/scrolling'; -import { Component, viewChild } from '@angular/core'; -import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, flush, tick } from '@angular/core/testing'; -import { - END, - ENTER, - HOME, - LEFT_ARROW, - RIGHT_ARROW, - SPACE, - dispatchFakeEvent, - dispatchKeyboardEvent -} from '@koobiq/components/core'; -import { Subject } from 'rxjs'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { Component, Injectable, viewChild } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE, dispatchKeyboardEvent } from '@koobiq/components/core'; +import { Observable, Subject } from 'rxjs'; +import { KbqPaginatedTabHeader } from './paginated-tab-header'; import { KbqTabHeader } from './tab-header.component'; import { KbqTabLabelWrapper } from './tab-label-wrapper.directive'; +// jsdom doesn't implement `Element.prototype.scrollTo` at all +// (https://github.com/jsdom/jsdom/issues/1695). `KbqPaginatedTabHeader`'s scroll-correction path +// calls `container.scrollTo({ left, behavior })` directly, so the arrow-click/focus-scroll tests +// below need it to actually move `scrollLeft`, not just be callable. Scoped to this file — jsdom +// gives every spec file its own global environment, so this can't affect other suites the way a +// `tools/jest/setup.ts` addition would. +if (!Element.prototype.scrollTo) { + Element.prototype.scrollTo = function (this: Element, options?: ScrollToOptions | number): void { + if (typeof options === 'object' && options?.left !== undefined) this.scrollLeft = options.left; + }; +} + +/** Audit interval (ms) the header waits before re-checking pagination after a scroll-box resize. See `RESIZE_AUDIT_TIME`. */ +const RESIZE_AUDIT_TIME = 100; + +@Injectable() +class MockResizeObserver extends SharedResizeObserver { + // A plain `Subject`, not a `BehaviorSubject`: the latter replays its initial `[]` to every new + // subscriber, which would make a test pass merely by subscribing — without proving a + // *subsequent* emission (an actual resize) re-triggers anything. + readonly changes = new Subject(); + + override observe(_target: Element, _options?: ResizeObserverOptions): Observable { + return this.changes.asObservable(); + } +} + describe('KbqTabHeader', () => { let dir: Direction = 'ltr'; let change: Subject; @@ -35,7 +55,7 @@ describe('KbqTabHeader', () => { SimpleTabHeaderApp ], providers: [ - ViewportRuler, + { provide: SharedResizeObserver, useClass: MockResizeObserver }, { provide: Directionality, useFactory: () => ({ @@ -202,8 +222,11 @@ describe('KbqTabHeader', () => { it('should not show pagination when tab list fits container', () => { const header = appComponent.tabHeader(); - Object.defineProperty(header.tabList.nativeElement, 'scrollWidth', { configurable: true, value: 60 }); - Object.defineProperty(header.elementRef.nativeElement, 'offsetWidth', { + Object.defineProperty(header.tabListContainer.nativeElement, 'scrollWidth', { + configurable: true, + value: 60 + }); + Object.defineProperty(header.tabListContainer.nativeElement, 'clientWidth', { configurable: true, value: 130 }); @@ -217,8 +240,11 @@ describe('KbqTabHeader', () => { it('should show pagination when tab list exceeds container', () => { const header = appComponent.tabHeader(); - Object.defineProperty(header.tabList.nativeElement, 'scrollWidth', { configurable: true, value: 240 }); - Object.defineProperty(header.elementRef.nativeElement, 'offsetWidth', { + Object.defineProperty(header.tabListContainer.nativeElement, 'scrollWidth', { + configurable: true, + value: 240 + }); + Object.defineProperty(header.tabListContainer.nativeElement, 'clientWidth', { configurable: true, value: 130 }); @@ -229,41 +255,102 @@ describe('KbqTabHeader', () => { expect(header.showPaginationControls).toBe(true); }); - it('should scroll to show the focused tab label', () => { - appComponent.addTabsForScrolling(); - fixture.detectChanges(); - expect(appComponent.tabHeader().scrollDistance).toBe(0); + it('should recheck pagination when tabs are removed from the list', () => { + const header = appComponent.tabHeader(); + const container = header.tabListContainer.nativeElement; + + Object.defineProperty(container, 'scrollWidth', { configurable: true, value: 240 }); + Object.defineProperty(container, 'clientWidth', { configurable: true, value: 130 }); - appComponent.tabHeader().focusIndex = appComponent.tabs.length - 1; + header.checkPaginationEnabled(); fixture.detectChanges(); - expect(appComponent.tabHeader().scrollDistance).toBe(appComponent.tabHeader().getMaxScrollDistance()); + expect(header.showPaginationControls).toBe(true); - appComponent.tabHeader().focusIndex = 0; + // Shrink `scrollWidth` to what removing the tabs would really produce, then remove + // them — `ngAfterContentChecked` diffs `items.length` on every change-detection + // pass, so pagination re-evaluates without an explicit `updatePagination()` call. + Object.defineProperty(container, 'scrollWidth', { configurable: true, value: 60 }); + appComponent.tabs = appComponent.tabs.slice(0, 1); fixture.detectChanges(); - expect(appComponent.tabHeader().scrollDistance).toBe(0); + + expect(header.showPaginationControls).toBe(false); }); - it('should align scroll header when tabs removed from end of the list', fakeAsync(() => { - appComponent.addTabsForScrolling(); + it('should scroll to bring a focused, out-of-view tab label into view', fakeAsync(() => { + const header = appComponent.tabHeader(); + const container = header.tabListContainer.nativeElement; + + Object.defineProperty(container, 'scrollWidth', { configurable: true, value: 400 }); + Object.defineProperty(container, 'clientWidth', { configurable: true, value: 100 }); + + // Real browsers clamp `scrollLeft` to [0, scrollWidth - clientWidth]; the file-local + // `scrollTo` polyfill above doesn't, so an out-of-range target (e.g. the overscroll + // below the first tab) would otherwise assert a value no browser actually produces. + let scrollLeft = 0; + + Object.defineProperty(container, 'scrollLeft', { + configurable: true, + get: () => scrollLeft, + set: (value: number) => { + scrollLeft = Math.max(0, Math.min(300, value)); + } + }); + + const lastLabel = header.items.get(3)!.elementRef.nativeElement; + + Object.defineProperty(lastLabel, 'offsetLeft', { configurable: true, value: 300 }); + Object.defineProperty(lastLabel, 'offsetWidth', { configurable: true, value: 30 }); + Object.defineProperty(header.nextPaginator.nativeElement, 'clientWidth', { + configurable: true, + value: 20 + }); + + header.updatePagination(); fixture.detectChanges(); - expect(appComponent.tabHeader().scrollDistance).toBe(0); + expect(container.scrollLeft).toBe(0); - appComponent.tabHeader().focusIndex = appComponent.tabs.length - 1; + header.focusIndex = 3; fixture.detectChanges(); - const previousMaxScrollDistance = appComponent.tabHeader().getMaxScrollDistance(); + tick(150); + + // labelAfterPos(330) > afterVisiblePos(100) -> scroll by (330 - 100 + overscroll(20)) = 250 + expect(container.scrollLeft).toBe(250); + + const firstLabel = header.items.get(0)!.elementRef.nativeElement; - expect(appComponent.tabHeader().scrollDistance).toBe(previousMaxScrollDistance); + Object.defineProperty(firstLabel, 'offsetLeft', { configurable: true, value: 0 }); + Object.defineProperty(firstLabel, 'offsetWidth', { configurable: true, value: 30 }); + Object.defineProperty(header.previousPaginator.nativeElement, 'clientWidth', { + configurable: true, + value: 20 + }); - appComponent.tabs.pop(); + header.focusIndex = 0; fixture.detectChanges(); - tick(1000); + tick(150); + + // labelBeforePos(0) < beforeVisiblePos(250) -> target (0 - overscroll(20)) = -20, + // clamped to the real minimum of 0. + expect(container.scrollLeft).toBe(0); + })); + + it('should not drop the scroll-into-view request for a selectedIndex set before the first change detection', fakeAsync(() => { + // `ngAfterContentChecked` (a content hook) queues this request before + // `ngAfterViewInit` (a view hook) has run and subscribed to it — a plain `Subject` + // would silently drop it, and `` would render + // with the selected tab off-screen and no scroll ever happening. + const scrollCorrectionSpy = jest.spyOn(KbqPaginatedTabHeader.prototype as any, 'scrollCorrection'); - const updatedMaxScrollDistance = appComponent.tabHeader().getMaxScrollDistance(); + fixture = TestBed.createComponent(SimpleTabHeaderApp); + appComponent = fixture.componentInstance; + appComponent.selectedIndex = 3; - expect(appComponent.tabHeader().scrollDistance).toBe(updatedMaxScrollDistance); - expect(previousMaxScrollDistance > updatedMaxScrollDistance); + fixture.detectChanges(); + tick(150); - flush(); + expect(scrollCorrectionSpy).toHaveBeenCalledWith(3, 'smooth'); + + scrollCorrectionSpy.mockRestore(); })); }); @@ -277,35 +364,340 @@ describe('KbqTabHeader', () => { fixture.detectChanges(); }); - it('should scroll to show the focused tab label', () => { - appComponent.addTabsForScrolling(); + it('should scroll towards negative scrollLeft to bring a focused, out-of-view tab label into view', fakeAsync(() => { + const header = appComponent.tabHeader(); + const container = header.tabListContainer.nativeElement; + + Object.defineProperty(container, 'scrollWidth', { configurable: true, value: 400 }); + Object.defineProperty(container, 'clientWidth', { configurable: true, value: 100 }); + Object.defineProperty(header.tabList.nativeElement, 'offsetWidth', { + configurable: true, + value: 400 + }); + + const lastLabel = header.items.get(3)!.elementRef.nativeElement; + + // RTL: labelAfterPos = tabList.offsetWidth - offsetLeft. + Object.defineProperty(lastLabel, 'offsetLeft', { configurable: true, value: 70 }); + Object.defineProperty(lastLabel, 'offsetWidth', { configurable: true, value: 30 }); + Object.defineProperty(header.nextPaginator.nativeElement, 'clientWidth', { + configurable: true, + value: 20 + }); + + header.updatePagination(); + fixture.detectChanges(); + expect(container.scrollLeft).toBe(0); + + header.focusIndex = 3; fixture.detectChanges(); - expect(appComponent.tabHeader().scrollDistance).toBe(0); + tick(150); - appComponent.tabHeader().focusIndex = appComponent.tabs.length - 1; + // Same logical math as LTR (250), mirrored onto native scrollLeft's negative RTL range. + expect(container.scrollLeft).toBe(-250); + })); + + it('should toggle the pagination arrows from the negative RTL scrollLeft range', () => { + const header = appComponent.tabHeader(); + const container = header.tabListContainer.nativeElement; + + Object.defineProperty(container, 'scrollWidth', { configurable: true, value: 400 }); + Object.defineProperty(container, 'clientWidth', { configurable: true, value: 100 }); + + header.updatePagination(); fixture.detectChanges(); - expect(appComponent.tabHeader().scrollDistance).toBe(appComponent.tabHeader().getMaxScrollDistance()); - appComponent.tabHeader().focusIndex = 0; + // At rest, `scrollLeft` is 0 in both directions — the RTL start (nothing to scroll + // back to) and the LTR start (nothing scrolled yet) coincide. + expect(header.disableScrollBefore).toBe(true); + expect(header.disableScrollAfter).toBe(false); + + // Native RTL `scrollLeft` runs from 0 to -(scrollWidth - clientWidth) as the user + // scrolls towards the end of the (reading-order) list. + container.scrollLeft = -300; + container.dispatchEvent(new Event('scroll')); fixture.detectChanges(); - expect(appComponent.tabHeader().scrollDistance).toBe(0); + + expect(header.disableScrollBefore).toBe(false); + expect(header.disableScrollAfter).toBe(true); }); }); - it('should update arrows when the window is resized', fakeAsync(() => { + describe('scroll box resize', () => { + it('should recheck pagination when the scroll box is resized', fakeAsync(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + const header = fixture.componentInstance.tabHeader(); + const mockResizeObserver = TestBed.inject(SharedResizeObserver) as unknown as MockResizeObserver; + const checkPaginationEnabledSpy = jest.spyOn(header, 'checkPaginationEnabled'); + + mockResizeObserver.changes.next([]); + tick(RESIZE_AUDIT_TIME); + fixture.detectChanges(); + + expect(checkPaginationEnabledSpy).toHaveBeenCalled(); + })); + }); + }); + + describe('drag scrolling', () => { + let header: KbqTabHeader; + + // Inertia coasts via `requestAnimationFrame`; drive it deterministically with explicit + // timestamps instead of relying on real frame timing or zone.js's fakeAsync rAF patch. + let pendingFrame: FrameRequestCallback | null; + + const flushFrame = (timestamp: number) => { + const callback = pendingFrame; + + pendingFrame = null; + callback?.(timestamp); + }; + + // Runs the inertia loop to completion (bounded), simulating unclamped native scrollLeft. + const runInertiaToCompletion = () => { + let timestamp = 0; + + for (let i = 0; i < 200 && pendingFrame; i++) { + timestamp += 32; + flushFrame(timestamp); + } + }; + + const createPointerEvent = ( + type: string, + init: MouseEventInit & { pointerId?: number; pointerType?: string; timeStamp?: number } = {} + ): PointerEvent => { + // `MouseEventInit.buttons` defaults to 0 (no button held) — wrong for a `pointermove` + // mid-drag, which is what almost every caller here simulates. Callers that need to + // simulate a release outside the window (no buttons on the move) pass `buttons: 0` explicitly. + const { pointerId = 1, pointerType = 'mouse', timeStamp, buttons = 1, ...mouseInit } = init; + const event = new MouseEvent(type, { buttons, ...mouseInit }); + + Object.defineProperties(event, { + pointerId: { value: pointerId }, + pointerType: { value: pointerType }, + ...(timeStamp === undefined ? {} : { timeStamp: { value: timeStamp } }) + }); + + return event as PointerEvent; + }; + + const enableOverflow = () => { + Object.defineProperty(header.tabListContainer.nativeElement, 'scrollWidth', { + configurable: true, + value: 400 + }); + Object.defineProperty(header.tabListContainer.nativeElement, 'clientWidth', { + configurable: true, + value: 100 + }); + header.updatePagination(); + fixture.detectChanges(); + }; + + beforeEach(() => { + pendingFrame = null; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + pendingFrame = callback; + + return 0; + }); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { + pendingFrame = null; + }); + + dir = 'ltr'; fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + appComponent = fixture.componentInstance; + header = appComponent.tabHeader(); + enableOverflow(); + + // `ngAfterContentInit`'s own `requestAnimationFrame(realign)` (queued during + // `fixture.detectChanges()` above, before this spy could distinguish it from an inertia + // frame) would otherwise leave `pendingFrame` non-null before any test runs — making + // `expect(pendingFrame).not.toBeNull()` assertions pass regardless of whether `endDrag` + // actually queued an inertia frame, and `runInertiaToCompletion()` would invoke that + // leftover `realign` as if it were one. + pendingFrame = null; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should toggle kbq-disabled on the previous/next arrows at each scroll bound without removing them from the DOM', () => { + const before = fixture.nativeElement.querySelector('.kbq-tab-header__pagination_before'); + const after = fixture.nativeElement.querySelector('.kbq-tab-header__pagination_after'); + + expect(before.classList.contains('kbq-disabled')).toBe(true); + expect(after.classList.contains('kbq-disabled')).toBe(false); + + // scrollWidth(400) - clientWidth(100) = 300, i.e. the max scrollLeft a real browser would allow. + header.tabListContainer.nativeElement.scrollLeft = 300; + header.tabListContainer.nativeElement.dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + + expect(before.classList.contains('kbq-disabled')).toBe(false); + expect(after.classList.contains('kbq-disabled')).toBe(true); + }); + + it('should not start a drag for small movement, allowing a normal click to select a tab', () => { + const tabListContainer = header.tabListContainer.nativeElement; + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: 2 })); + document.dispatchEvent(createPointerEvent('pointerup', { clientX: 2 })); + + expect(tabListContainer.scrollLeft).toBe(0); + + const label = header.items.get(2)!.elementRef.nativeElement; + + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fixture.detectChanges(); + + expect(appComponent.selectedIndex).toBe(2); + }); + + it('should scroll while dragging past the threshold, and suppress the resulting click', () => { + const tabListContainer = header.tabListContainer.nativeElement; + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -20 })); + + expect(tabListContainer.scrollLeft).toBe(20); + + document.dispatchEvent(createPointerEvent('pointerup', { clientX: -20 })); + + const label = header.items.get(2)!.elementRef.nativeElement; + + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fixture.detectChanges(); + + expect(appComponent.selectedIndex).toBe(0); + }); + + it('should end the drag on a buttonless move when no pointerup ever reaches the document', () => { + const tabListContainer = header.tabListContainer.nativeElement; + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -20 })); + expect(tabListContainer.scrollLeft).toBe(20); - const header = fixture.componentInstance.tabHeader(); + // Simulates the button being released outside the window: no `pointerup` fires, but a + // move still reaches the document (e.g. the pointer re-enters), reporting no buttons held. + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -25, buttons: 0 })); - const checkPaginationEnabledSpyFn = jest.spyOn(header, 'checkPaginationEnabled'); + expect(tabListContainer.scrollLeft).toBe(20); + expect(tabListContainer.classList.contains('kbq-tab-header__scroll-container_dragging')).toBe(false); - dispatchFakeEvent(window, 'resize'); - tick(10); + // The drag is over — a further move (even with a button reported again, e.g. a new, + // unrelated press) must not resume scrolling the old gesture. + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -60 })); + expect(tabListContainer.scrollLeft).toBe(20); + }); + + it('should reset click suppression after a drag even without a trailing click', fakeAsync(() => { + const tabListContainer = header.tabListContainer.nativeElement; + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -20 })); + document.dispatchEvent(createPointerEvent('pointerup', { clientX: -20 })); + + tick(0); + + const label = header.items.get(2)!.elementRef.nativeElement; + + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); fixture.detectChanges(); - expect(checkPaginationEnabledSpyFn).toHaveBeenCalled(); - discardPeriodicTasks(); + expect(appComponent.selectedIndex).toBe(2); })); + + it('should not drag for touch pointers, leaving the existing touch/arrow interactions untouched', () => { + const tabListContainer = header.tabListContainer.nativeElement; + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0, pointerType: 'touch' })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -20, pointerType: 'touch' })); + + expect(tabListContainer.scrollLeft).toBe(0); + }); + + it('should coast after release, decaying frame by frame until it settles', () => { + const tabListContainer = header.tabListContainer.nativeElement; + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0, timeStamp: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -10, timeStamp: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -30, timeStamp: 50 })); + + expect(tabListContainer.scrollLeft).toBe(30); + + document.dispatchEvent(createPointerEvent('pointerup', { clientX: -30, timeStamp: 50 })); + + // Release doesn't jump straight to a target — an inertia frame is queued and the + // position hasn't moved yet. + expect(pendingFrame).not.toBeNull(); + expect(tabListContainer.scrollLeft).toBe(30); + + runInertiaToCompletion(); + + expect(pendingFrame).toBeNull(); + expect(tabListContainer.scrollLeft).toBeGreaterThan(30); + }); + + it('should stop the coast exactly at the scroll boundary', () => { + const tabListContainer = header.tabListContainer.nativeElement; + + // The boundary is now computed from `scrollWidth - clientWidth` (see `startInertia`), + // not read back from `scrollLeft` — so it has to agree with the setter's own clamp + // below, instead of `enableOverflow()`'s unrelated 400/100 (a 300px range). + Object.defineProperty(tabListContainer, 'scrollWidth', { configurable: true, value: 140 }); + + // Simulate a real browser clamping `scrollLeft` to [0, 40]. + let scrollLeft = 0; + + Object.defineProperty(tabListContainer, 'scrollLeft', { + configurable: true, + get: () => scrollLeft, + set: (value: number) => { + scrollLeft = Math.max(0, Math.min(40, value)); + } + }); + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0, timeStamp: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -50, timeStamp: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -100, timeStamp: 10 })); + + expect(tabListContainer.scrollLeft).toBe(40); + + document.dispatchEvent(createPointerEvent('pointerup', { clientX: -100, timeStamp: 10 })); + + flushFrame(1000); // primes the timestamp, no movement yet + expect(pendingFrame).not.toBeNull(); + + flushFrame(1020); // scrollLeft is already at the boundary -> the write is a no-op -> stop + + expect(tabListContainer.scrollLeft).toBe(40); + expect(pendingFrame).toBeNull(); + }); + + it('should cancel an in-progress inertia coast on wheel input', () => { + const tabListContainer = header.tabListContainer.nativeElement; + + tabListContainer.dispatchEvent(createPointerEvent('pointerdown', { clientX: 0, timeStamp: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -10, timeStamp: 0 })); + document.dispatchEvent(createPointerEvent('pointermove', { clientX: -30, timeStamp: 50 })); + document.dispatchEvent(createPointerEvent('pointerup', { clientX: -30, timeStamp: 50 })); + + expect(pendingFrame).not.toBeNull(); + + tabListContainer.dispatchEvent(new WheelEvent('wheel', { deltaX: 10 })); + + expect(pendingFrame).toBeNull(); + }); }); describe('activeTabOffset', () => { @@ -440,8 +832,4 @@ class SimpleTabHeaderApp { constructor() { this.tabs[this.disabledTabIndex].disabled = true; } - - addTabsForScrolling() { - this.tabs.push({ label: 'new' }, { label: 'new' }, { label: 'new' }, { label: 'new' }); - } } diff --git a/packages/components/tabs/tab-label-wrapper.directive.ts b/packages/components/tabs/tab-label-wrapper.directive.ts index 850a1f5f34..81c202cc4a 100644 --- a/packages/components/tabs/tab-label-wrapper.directive.ts +++ b/packages/components/tabs/tab-label-wrapper.directive.ts @@ -50,9 +50,17 @@ export class KbqTabLabelWrapper implements AfterViewInit { this.addClassModifierForIcons(Array.from(this.elementRef.nativeElement.querySelectorAll('.kbq-icon'))); } - /** Sets focus on the wrapper element */ + /** + * Sets focus on the wrapper element. + * + * `preventScroll: true`: `FocusKeyManager.setActiveItem` calls this itself, right after (and + * unconditionally on) the `change` emission that runs `KbqPaginatedTabHeader.setTabFocus` — so + * without the guard here too, that call's own `preventScroll: true` is immediately undone by + * this one, and an overflowing header double-jumps (native scroll-into-view, then the + * paginator-aware `scrollCorrection`) on every arrow-key press. + */ focus(): void { - this.elementRef.nativeElement.focus(); + this.elementRef.nativeElement.focus({ preventScroll: true }); } getOffsetLeft(): number { diff --git a/packages/components/tabs/tab-nav-bar.html b/packages/components/tabs/tab-nav-bar.html index 2279a004c4..e405477475 100644 --- a/packages/components/tabs/tab-nav-bar.html +++ b/packages/components/tabs/tab-nav-bar.html @@ -6,10 +6,17 @@ (mousedown)="handlePaginatorPress('before', $event)" (touchend)="stopInterval()" > - +
-