diff --git a/README.md b/README.md index 7ddd313..2b3880c 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ createApp(App) ## Documentation - [Using ion-item-group](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/using-ion-item-group) — shared inset-list markup for iOS 26 and MD3. -- [Special markup](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/special-markup) — opt-in component combinations used by the demo. +- [Special markup](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/special-markup) — opt-in component combinations used by the demo, including the shared `ion-toolbar.tab-accessory` mini-player. - [ESLint](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/eslint) — check list structure with ESLint rules. - [Migration](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/migration) — changes required when updating theme markup. diff --git a/demo/src/app/tabs/tabs.page.html b/demo/src/app/tabs/tabs.page.html index d1a2c96..feaf19b 100644 --- a/demo/src/app/tabs/tabs.page.html +++ b/demo/src/app/tabs/tabs.page.html @@ -26,6 +26,25 @@
+ + + + + +

Now Playing

+

Demo Artist

+
+ + + 1:24 + 3:45 + + + + + + +
diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index 4daea0d..4d5ff74 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,5 +1,7 @@ -import { Component } from '@angular/core'; +import { Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'; import { + IonButton, + IonButtons, IonContent, IonIcon, IonItem, @@ -7,16 +9,95 @@ import { IonLabel, IonList, IonMenu, + IonProgressBar, IonSplitPane, IonTabBar, IonTabButton, IonTabs, + IonThumbnail, + IonToolbar, + ViewDidEnter, + ViewDidLeave, } from '@demo/ionic'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { enableTabAccessory, type TabAccessoryHandle } from '../../../../src'; @Component({ selector: 'app-tabs', templateUrl: 'tabs.page.html', styleUrls: ['tabs.page.scss'], - imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, IonSplitPane, IonMenu, IonContent, IonList, IonItemGroup, IonItem], + imports: [ + IonTabs, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonSplitPane, + IonMenu, + IonContent, + IonList, + IonItemGroup, + IonItem, + IonToolbar, + IonThumbnail, + IonButton, + IonButtons, + IonProgressBar, + ], }) -export class TabsPage {} +export class TabsPage implements OnInit, OnDestroy, ViewDidEnter, ViewDidLeave { + readonly #router = inject(Router); + readonly #el = inject(ElementRef); + readonly #destroyRef = inject(DestroyRef); + #accessory?: TabAccessoryHandle; + playing = true; + showAccessory = false; + accessoryActivated = false; + + ngOnInit() { + this.#router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.#destroyRef), + ) + .subscribe((params) => { + const tabBar = this.#el.nativeElement.querySelector('ion-tab-bar'); + if (!tabBar) { + return; + } + const path = params.urlAfterRedirects.split(/[?#]/, 1)[0]; + const hideTabs = ['/main/settings'].includes(path); + if (hideTabs) { + tabBar.classList.add('tab-bar-hidden'); + } else { + tabBar.classList.remove('tab-bar-hidden'); + } + this.showAccessory = !hideTabs && (path === '/main/album' || /[?&]miniPlayer(?:=|$|&)/.test(params.urlAfterRedirects)); + }); + } + + ngOnDestroy() { + this.ionViewDidLeave(); + } + + togglePlay(event: Event) { + event.stopPropagation(); + this.playing = !this.playing; + } + + onAccessoryActivate() { + this.accessoryActivated = true; + } + + ionViewDidEnter() { + this.#accessory = enableTabAccessory(); + } + + ionViewDidLeave() { + this.#accessory?.destroy(); + this.#accessory = undefined; + } +} diff --git a/demo/src/tab-accessory.spec.ts b/demo/src/tab-accessory.spec.ts new file mode 100644 index 0000000..a0d99cd --- /dev/null +++ b/demo/src/tab-accessory.spec.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { + attachTabAccessory, + enableTabAccessory, + TAB_ACCESSORY_GESTURE_ACTIVE_CLASS, + TAB_ACCESSORY_PANNING_X_CLASS, +} from '../../src/tab-accessory'; + +const gestureMocks = vi.hoisted(() => ({ + createGesture: vi.fn(() => ({ enable: vi.fn(), destroy: vi.fn() })), +})); + +vi.mock('@ionic/core', () => ({ + createGesture: gestureMocks.createGesture, +})); + +const createGestureMock = gestureMocks.createGesture; + +type GestureConfig = { + el: HTMLElement; + direction?: string; + gesturePriority?: number; + passive?: boolean; + disableScroll?: boolean; + maxAngle?: number; + canStart?: (detail: { event?: Event }) => boolean; + onStart?: (detail?: unknown) => void; + onMove?: (detail: { deltaY: number }) => void; + onEnd?: (detail: { deltaY: number; velocityY?: number }) => void; +}; + +const config = (): GestureConfig => { + const last = createGestureMock.mock.calls.at(-1) as [GestureConfig] | undefined; + if (!last) throw new Error('createGesture was not called'); + return last[0]; +}; + +const mount = (lift = false, alias = false) => { + const clip = document.createElement('div'); + if (lift) clip.className = 'tab-accessory-lift'; + const toolbarClass = alias ? 'md ios-theme-tab-accessory' : 'md tab-accessory'; + clip.innerHTML = ` + + + + `; + document.body.append(clip); + return { + clip, + toolbar: clip.querySelector('ion-toolbar')!, + play: clip.querySelector('ion-button')!, + }; +}; + +beforeEach(() => { + createGestureMock.mockClear(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +test('attaches an upward swipe gesture with follow-the-finger priority', () => { + const { toolbar } = mount(); + const effect = attachTabAccessory(toolbar); + expect(createGestureMock).toHaveBeenCalledTimes(1); + expect(config().el).toBe(toolbar); + expect(config().direction).toBe('y'); + expect(config().gesturePriority).toBe(20); + expect(config().passive).toBe(false); + expect(config().disableScroll).toBe(true); + expect(config().maxAngle).toBe(45); + effect?.destroy(); +}); + +test('attaches to the ios-theme-tab-accessory alias', () => { + const { toolbar } = mount(false, true); + attachTabAccessory(toolbar); + expect(createGestureMock).toHaveBeenCalledTimes(1); +}); + +test('lifts a marked wrapper instead of the toolbar', () => { + const { toolbar, clip } = mount(true); + attachTabAccessory(toolbar); + expect(config().el).toBe(clip); +}); + +test('clicks the toolbar after a -50px swipe-up', () => { + const { toolbar } = mount(); + const click = vi.fn(); + toolbar.addEventListener('click', click); + attachTabAccessory(toolbar); + config().onEnd?.({ deltaY: -80, velocityY: 0 } as never); + expect(click).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(click).toHaveBeenCalledTimes(1); +}); + +test('clicks the toolbar on a fast upward flick', () => { + const { toolbar } = mount(); + const click = vi.fn(); + toolbar.addEventListener('click', click); + attachTabAccessory(toolbar); + config().onEnd?.({ deltaY: -10, velocityY: -0.5 } as never); + vi.advanceTimersByTime(200); + expect(click).toHaveBeenCalledTimes(1); +}); + +test('snaps back when the swipe is too small', () => { + const { toolbar } = mount(); + const click = vi.fn(); + toolbar.addEventListener('click', click); + attachTabAccessory(toolbar); + config().onStart?.({} as never); + config().onEnd?.({ deltaY: -20, velocityY: -0.1 } as never); + expect(click).not.toHaveBeenCalled(); + expect(toolbar.style.transform).toBe('translateY(0)'); +}); + +test('follows the finger and fades while dragging up', () => { + const { toolbar } = mount(); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); + attachTabAccessory(toolbar); + config().onStart?.({} as never); + config().onMove?.({ deltaY: -80 } as never); + expect(toolbar.style.transform).toBe('translateY(-80px)'); + expect(toolbar.classList.contains(TAB_ACCESSORY_GESTURE_ACTIVE_CLASS)).toBe(true); + expect(Number(toolbar.style.opacity)).toBeLessThan(1); +}); + +test('does not start on the play control', () => { + const { toolbar, play } = mount(); + attachTabAccessory(toolbar); + const event = { target: play } as unknown as Event; + expect(config().canStart?.({ event } as never)).toBe(false); +}); + +test('does not activate while the host owns a horizontal pan', () => { + const { toolbar, clip } = mount(true); + const click = vi.fn(); + toolbar.addEventListener('click', click); + attachTabAccessory(toolbar); + clip.classList.add(TAB_ACCESSORY_PANNING_X_CLASS); + config().onEnd?.({ deltaY: -80, velocityY: 0 } as never); + vi.advanceTimersByTime(200); + expect(click).not.toHaveBeenCalled(); +}); + +test('enableTabAccessory attaches and tears down observed toolbars', () => { + const { toolbar } = mount(); + const handle = enableTabAccessory(); + expect(createGestureMock).toHaveBeenCalled(); + expect(config().el).toBe(toolbar); + handle.destroy(); + const gesture = createGestureMock.mock.results.at(-1)?.value as { destroy: ReturnType }; + expect(gesture.destroy).toHaveBeenCalled(); +}); diff --git a/docs/special-markup.md b/docs/special-markup.md index 1318461..1250666 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -55,6 +55,28 @@ This is an introductory group. Place regular list items in a separate `ion-item- ``` +## Tab accessory mini-player + +Add `.tab-accessory` to an `ion-toolbar` above `ion-tab-bar` (not inside `ion-content`). This theme styles the Material capsule. The same markup works with `@rdlabo/ionic-theme-ios27` (`ios-theme-tab-accessory` is kept as an alias). `.md3-disabled` opts an individual toolbar out. Call `enableTabAccessory()` once from this package for swipe-up; do not also call the ios27 copy. + +```html preview + + + + + +

Now Playing

+

Artist

+
+ + + + +
+``` + +Override layout with `--md3-tab-accessory-height`, `--md3-tab-accessory-inset`, `--md3-tab-accessory-radius`, and `--md3-tab-accessory-bottom`. Hosts that swipe the bar can wrap it in `.accessory-slide-clip`. + ## Opting out Add `.md3-disabled` to an individual Ionic component when it must retain Ionic's standard Material styling. diff --git a/package.json b/package.json index 7a41a0b..5c44da7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"", "fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"", "release": "np --no-tests --no-publish", + "prepare": "npm run build", "prepublishOnly": "npm run build", "test:esm": "rdlabo-check-esm" }, diff --git a/src/index.ts b/src/index.ts index 6c6ea3f..1fae7e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from './transition/md.transition'; +export * from './tab-accessory'; diff --git a/src/styles/components/ion-tab-accessory.scss b/src/styles/components/ion-tab-accessory.scss new file mode 100644 index 0000000..72b43bc --- /dev/null +++ b/src/styles/components/ion-tab-accessory.scss @@ -0,0 +1,259 @@ +// Shared opt-in: ion-toolbar.tab-accessory (same pattern as item-group-header). +// ios-theme-tab-accessory remains an alias for dual-theme markup. +ion-toolbar.md.tab-accessory:not(.md3-disabled), +ion-toolbar.md.ios-theme-tab-accessory:not(.md3-disabled) { + --md3-tab-accessory-radius: 16px; + --md3-tab-accessory-height: 64px; + --md3-tab-accessory-padding-block: 8px; + --md3-tab-accessory-inset: 16px; + --md3-tab-accessory-thumb-size: 36px; + --md3-tab-accessory-thumb-radius: 8px; + --md3-tab-accessory-bottom: calc(62px + 8px + env(safe-area-inset-bottom, 0px)); + --background: var(--ion-color-light, var(--ion-background-color, #fff)); + --border-width: 0; + --padding-start: 12px; + --padding-end: 4px; + --padding-top: var(--md3-tab-accessory-padding-block, 8px); + --padding-bottom: var(--md3-tab-accessory-padding-block, 8px); + --min-height: 0; + display: flex; + align-items: center; + position: absolute; + left: calc(var(--md3-tab-accessory-inset) + var(--ion-safe-area-left, 0px)); + right: calc(var(--md3-tab-accessory-inset) + var(--ion-safe-area-right, 0px)); + margin-inline: auto; + bottom: var(--md3-tab-accessory-bottom); + width: calc( + 100% - (var(--md3-tab-accessory-inset) + var(--ion-safe-area-left, 0px)) - + (var(--md3-tab-accessory-inset) + var(--ion-safe-area-right, 0px)) + ); + max-width: 471px; + height: var(--md3-tab-accessory-height); + min-height: var(--md3-tab-accessory-height); + contain: layout style; + border-radius: var(--md3-tab-accessory-radius); + overflow: hidden; + z-index: 3; + pointer-events: auto; + box-shadow: var(--token-elevation-1); + + &.tab-bar-hidden, + &.keyboardOpen { + display: none; + } + + &::part(background) { + background: var(--background); + border-radius: inherit; + } + + &::part(content) { + align-items: center; + align-self: center; + box-sizing: border-box; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + padding-top: var(--md3-tab-accessory-padding-block, 8px); + padding-bottom: var(--md3-tab-accessory-padding-block, 8px); + } + + ion-thumbnail, + [slot='start']:not(ion-buttons) { + --size: var(--md3-tab-accessory-thumb-size); + --border-radius: var(--md3-tab-accessory-thumb-radius); + width: var(--md3-tab-accessory-thumb-size); + height: var(--md3-tab-accessory-thumb-size); + min-width: var(--md3-tab-accessory-thumb-size); + min-height: var(--md3-tab-accessory-thumb-size); + flex-shrink: 0; + overflow: hidden; + border-radius: var(--md3-tab-accessory-thumb-radius); + margin-block: 0; + margin-inline-end: 10px; + align-self: center; + pointer-events: auto; + cursor: pointer; + } + + [slot='start']:not(ion-buttons) ion-thumbnail, + [slot='start']:not(ion-buttons) .image-container { + --size: var(--md3-tab-accessory-thumb-size); + --border-radius: var(--md3-tab-accessory-thumb-radius); + width: 100%; + height: 100%; + margin: 0; + border-radius: inherit; + } + + ion-label { + display: flex !important; + flex-direction: column; + justify-content: center; + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + margin: 0; + margin-inline-end: 8px; + align-self: stretch; + height: 100%; + padding-block: var(--md3-tab-accessory-padding-block, 8px); + box-sizing: border-box; + overflow: hidden; + h2 { + font-size: 0.875rem; + font-weight: 500; + margin: 0; + line-height: 1.2; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + p { + font-size: 0.75rem; + color: var(--ion-text-color-step-400, var(--ion-color-step-400, #999)); + margin: 0; + line-height: 1.2; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + ion-buttons[slot='end'] { + display: flex; + align-items: center; + flex: 0 0 auto; + flex-shrink: 0; + margin: 0; + } + + [data-tab-accessory='time'] { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + flex: 0 0 auto; + flex-shrink: 0; + align-self: center; + min-width: 4.75em; + margin: 0; + margin-inline-end: 4px; + padding: 0; + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + line-height: 1.2; + text-align: end; + white-space: nowrap; + pointer-events: none; + } + + [data-tab-accessory='elapsed'] { + color: var(--ion-text-color-step-150, var(--ion-color-step-150, #555)); + font-weight: 500; + } + + [data-tab-accessory='duration'] { + color: var(--ion-text-color-step-400, var(--ion-color-step-400, #999)); + } + + ion-button[data-tab-accessory='play'] { + --padding-start: 6px; + --padding-end: 6px; + --padding-top: 0; + --padding-bottom: 0; + margin: 0; + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + align-self: center; + flex-shrink: 0; + ion-icon { + font-size: 1.375rem; + margin: 0; + } + &::part(native) { + align-items: center; + justify-content: center; + height: 44px; + min-height: 44px; + } + } + + ion-progress-bar { + position: absolute; + left: 12px; + right: 12px; + bottom: 3px; + width: auto; + height: 2.5px; + min-height: 2.5px; + margin: 0; + flex: none; + z-index: 5; + pointer-events: none; + --progress-background: var(--ion-color-primary, #3880ff); + --background: rgba(var(--ion-color-primary-rgb, 56, 128, 255), 0.28); + &::part(track), + &::part(progress) { + height: 2.5px; + border-radius: 1px; + } + } + + @supports (color: color-mix(in srgb, red 22%, transparent)) { + ion-progress-bar { + --background: color-mix(in srgb, var(--progress-background) 22%, transparent); + } + } +} + +html.md .accessory-slide-clip { + --md3-tab-accessory-radius: 16px; + --md3-tab-accessory-height: 64px; + --md3-tab-accessory-padding-block: 8px; + --md3-tab-accessory-inset: 16px; + --md3-tab-accessory-bottom: calc(62px + 8px + env(safe-area-inset-bottom, 0px)); + position: absolute; + left: calc(var(--md3-tab-accessory-inset) + var(--ion-safe-area-left, 0px)); + right: calc(var(--md3-tab-accessory-inset) + var(--ion-safe-area-right, 0px)); + margin-inline: auto; + bottom: var(--md3-tab-accessory-bottom); + width: calc( + 100% - (var(--md3-tab-accessory-inset) + var(--ion-safe-area-left, 0px)) - + (var(--md3-tab-accessory-inset) + var(--ion-safe-area-right, 0px)) + ); + max-width: 471px; + height: var(--md3-tab-accessory-height); + min-height: var(--md3-tab-accessory-height); + contain: layout style; + border-radius: var(--md3-tab-accessory-radius); + overflow: hidden; + z-index: 3; + pointer-events: auto; + touch-action: none; + + &.keyboardOpen, + &.tab-bar-hidden { + display: none; + } + + ion-toolbar.md.tab-accessory:not(.md3-disabled), + ion-toolbar.md.ios-theme-tab-accessory:not(.md3-disabled) { + position: relative; + left: auto; + right: auto; + bottom: auto; + width: 100%; + max-width: none; + margin-inline: 0; + height: 100%; + min-height: 100%; + } +} diff --git a/src/styles/ionic-theme-md3.scss b/src/styles/ionic-theme-md3.scss index 895e0f7..fe34557 100644 --- a/src/styles/ionic-theme-md3.scss +++ b/src/styles/ionic-theme-md3.scss @@ -14,6 +14,7 @@ @use 'components/ion-range'; @use 'components/ion-searchbar'; @use 'components/ion-segment'; +@use 'components/ion-tab-accessory'; @use 'components/ion-tabs'; @use 'components/ion-toast'; @use 'components/ion-toggle'; diff --git a/src/tab-accessory/index.ts b/src/tab-accessory/index.ts new file mode 100644 index 0000000..f657e45 --- /dev/null +++ b/src/tab-accessory/index.ts @@ -0,0 +1,182 @@ +import { createGesture } from '@ionic/core'; +import type { Gesture, GestureDetail } from '@ionic/core'; + +export const TAB_ACCESSORY_SELECTOR = 'ion-toolbar.tab-accessory, ion-toolbar.ios-theme-tab-accessory'; +export const TAB_ACCESSORY_LIFT_SELECTOR = + '[data-tab-accessory-lift], .tab-accessory-lift, .ios-theme-tab-accessory-lift'; +export const TAB_ACCESSORY_PANNING_X_CLASS = 'tab-accessory-panning-x'; +export const TAB_ACCESSORY_PANNING_X_ALIAS = 'ios-theme-tab-accessory-panning-x'; +export const TAB_ACCESSORY_GESTURE_ACTIVE_CLASS = 'tab-accessory-gesture-active'; +export const TAB_ACCESSORY_SWIPING_CLASS = 'tab-accessory-swiping'; + +const PLAY_SELECTOR = '[data-tab-accessory="play"], ion-button[slot="end"]'; +const OPEN_DISTANCE = 50; +const OPEN_VELOCITY = -0.3; +const GESTURE_NAME = 'tab-accessory-swipe'; + +export interface TabAccessoryHandle { + destroy: () => void; +} + +export interface TabAccessoryOptions { + liftTarget?: HTMLElement; + onActivate?: (toolbar: HTMLElement) => void; +} + +const liftOf = (toolbar: HTMLElement, options?: TabAccessoryOptions): HTMLElement => + options?.liftTarget ?? toolbar.closest(TAB_ACCESSORY_LIFT_SELECTOR) ?? toolbar; + +const playHit = (event: Event | undefined): boolean => { + const target = event?.target; + return target instanceof Element && !!target.closest(PLAY_SELECTOR); +}; + +const panningX = (el: HTMLElement): boolean => + el.classList.contains(TAB_ACCESSORY_PANNING_X_CLASS) || + el.classList.contains(TAB_ACCESSORY_PANNING_X_ALIAS) || + !!el.closest(`.${TAB_ACCESSORY_PANNING_X_CLASS}, .${TAB_ACCESSORY_PANNING_X_ALIAS}`); + +const hidden = (el: HTMLElement): boolean => el.style.opacity === '0'; + +const resetLift = (el: HTMLElement, later: (fn: () => void, ms: number) => void): void => { + el.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; + el.style.transform = 'translateY(0)'; + el.style.opacity = '1'; + el.classList.remove(TAB_ACCESSORY_GESTURE_ACTIVE_CLASS, TAB_ACCESSORY_SWIPING_CLASS); + later(() => { + el.style.transition = ''; + }, 300); +}; + +/** + * Swipe-up on a web tab accessory: follow-the-finger lift + fade, then activate + * the host toolbar (click). Put `.tab-accessory-lift` (or `data-tab-accessory-lift`) + * on a wrapper to lift that node instead of the toolbar. Hosts may set + * `.tab-accessory-panning-x` while they own a horizontal gesture so swipe-up does not steal it. + */ +export const attachTabAccessory = (toolbar: HTMLElement, options?: TabAccessoryOptions): TabAccessoryHandle | undefined => { + if (!toolbar.matches(TAB_ACCESSORY_SELECTOR)) return; + + let gesture: Gesture | undefined; + let opening = false; + const timers: number[] = []; + const win = (): Window | null => toolbar.ownerDocument.defaultView; + const later = (fn: () => void, ms: number): void => { + const id = win()?.setTimeout(fn, ms); + if (id !== undefined) timers.push(id); + }; + const lift = (): HTMLElement => liftOf(toolbar, options); + const skip = (): boolean => opening || panningX(lift()) || hidden(lift()); + + const activate = (): void => { + if (opening) return; + opening = true; + gesture?.enable(false); + const el = lift(); + const height = win()?.innerHeight ?? 800; + el.style.transition = 'transform 0.2s ease-out, opacity 0.2s ease-out'; + el.style.transform = `translateY(${-height}px)`; + el.style.opacity = '0'; + later(() => { + (options?.onActivate ?? ((host: HTMLElement) => host.click()))(toolbar); + el.style.transition = 'transform 0s, opacity 0s'; + el.style.transform = 'translateY(0)'; + el.classList.remove(TAB_ACCESSORY_GESTURE_ACTIVE_CLASS, TAB_ACCESSORY_SWIPING_CLASS); + opening = false; + gesture?.enable(true); + }, 200); + }; + + gesture = createGesture({ + el: lift(), + gestureName: GESTURE_NAME, + direction: 'y', + threshold: 10, + maxAngle: 45, + gesturePriority: 20, + passive: false, + disableScroll: true, + canStart: (detail: GestureDetail) => !playHit(detail.event) && !skip(), + onStart: () => { + const el = lift(); + el.classList.add(TAB_ACCESSORY_GESTURE_ACTIVE_CLASS); + el.style.transition = 'none'; + el.style.opacity = '1'; + }, + onMove: (detail: GestureDetail) => { + if (skip()) return; + if (detail.deltaY >= 0) return; + const el = lift(); + const height = toolbar.ownerDocument.defaultView?.innerHeight ?? 800; + const translateY = Math.max(detail.deltaY, -height); + el.style.transform = `translateY(${translateY}px)`; + el.style.opacity = String(Math.max(0, Math.min(1, 1 - Math.abs(translateY) / (height / 2)))); + if (Math.abs(detail.deltaY) > 20) el.classList.add(TAB_ACCESSORY_SWIPING_CLASS); + if (detail.deltaY <= -height / 2) activate(); + }, + onEnd: (detail: GestureDetail) => { + if (opening) return; + if (panningX(lift())) { + resetLift(lift(), later); + return; + } + if (detail.deltaY < -OPEN_DISTANCE || detail.velocityY < OPEN_VELOCITY) activate(); + else resetLift(lift(), later); + }, + }); + gesture.enable(true); + + return { + destroy: () => { + for (const id of timers) win()?.clearTimeout(id); + timers.length = 0; + gesture?.destroy(); + gesture = undefined; + }, + }; +}; + +const enabled = new WeakMap(); + +/** Observes `ion-toolbar.tab-accessory` and attaches swipe-up. Call once at app start. */ +export const enableTabAccessory = (root?: ParentNode): TabAccessoryHandle => { + const scope = root ?? (typeof document === 'undefined' ? undefined : document); + if (!scope) return { destroy: () => {} }; + const existing = enabled.get(scope); + if (existing) return existing; + + const live = new Map(); + const sync = (): void => { + for (const [toolbar, effect] of live) { + if (!toolbar.isConnected) { + effect.destroy(); + live.delete(toolbar); + } + } + for (const toolbar of Array.from(scope.querySelectorAll(TAB_ACCESSORY_SELECTOR))) { + if (live.has(toolbar) || !toolbar.isConnected) continue; + const effect = attachTabAccessory(toolbar); + if (effect) live.set(toolbar, effect); + } + }; + + const observer = new MutationObserver(sync); + observer.observe(scope instanceof Document ? scope.documentElement : (scope as Node), { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'], + }); + sync(); + + const handle: TabAccessoryHandle = { + destroy: () => { + observer.disconnect(); + for (const effect of live.values()) effect.destroy(); + live.clear(); + enabled.delete(scope); + }, + }; + enabled.set(scope, handle); + return handle; +};