diff --git a/README.md b/README.md index 529b0239..51b12e48 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Give familiar Ionic screens the iOS 27 visual language: Liquid Glass, styled too ### Project your Ionic UI into Native UI -On Capacitor iOS, the optional [Native UI Shell](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell) reads supported fixed controls from your existing Ionic markup. It is available as a preview in `1.2.0`. It projects their text, resolved `ion-icon` artwork or supported static SVGs, and selection state into UIKit controls with system Liquid Glass. Changes and native actions flow through the original Ionic components, so the Web and native presentations share one UI definition. Page content and routing stay in the WebView; unsupported layouts keep their Web presentation. +On Capacitor iOS, the optional [Native UI Shell](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell) reads supported fixed controls from your existing Ionic markup. It is available as a preview in `1.2.0`. It projects their text, resolved `ion-icon` artwork or supported static SVGs, and selection state into UIKit controls with system Liquid Glass. Changes and native actions flow through the original Ionic components, so the Web and native presentations share one UI definition. Page content and routing stay in the WebView; unsupported layouts keep their Web presentation. A mini-player uses `ion-toolbar.tab-accessory` (alias `ios-theme-tab-accessory`): iOS 26+ can project it as `UITabAccessory`; older iOS and the Web use the theme CSS capsule, or `.ios-theme-tab-accessory-classic` for a 12px rounded rectangle. See [Native UI Shell](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell#web-fallback-and-older-ios). **Tab drag on iOS 27:** The same Library screen with Native UI Shell off (Web) and on (UIKit). Both frames were captured while dragging the selected tab; the lower panels enlarge the glass around the tab bar. diff --git a/demo/.browserslistrc b/demo/.browserslistrc index 3f85021e..cce05fe7 100644 --- a/demo/.browserslistrc +++ b/demo/.browserslistrc @@ -11,5 +11,5 @@ Chrome >=107 Firefox >=106 Edge >=107 -Safari >=16.1 -iOS >=16.1 +Safari >=15 +iOS >=15 diff --git a/demo/e2e/tab-accessory.spec.ts b/demo/e2e/tab-accessory.spec.ts new file mode 100644 index 00000000..bb8aee95 --- /dev/null +++ b/demo/e2e/tab-accessory.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const shots = resolve(__dirname, '../../screenshots/mini-player'); + +const openAlbum = async (page: import('@playwright/test').Page) => { + await page.addInitScript(() => (document.IONIC_E2E_TESTING = true)); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/main/album', { waitUntil: 'networkidle' }); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('ion-toolbar.ios-theme-tab-accessory')).toBeVisible(); +}; + +test.describe('tab accessory web fallback', () => { + test('default capsule uses a 28px radius, 36px thumb and rgba progress fallback', async ({ page }) => { + await openAlbum(page); + const toolbar = page.locator('ion-toolbar.ios-theme-tab-accessory'); + await expect(toolbar).toHaveCSS('border-radius', '28px'); + await expect(toolbar).toHaveCSS('align-items', 'center'); + await expect(toolbar.locator('ion-thumbnail')).toHaveCSS('width', '36px'); + const track = await toolbar.locator('ion-progress-bar').evaluate((el) => getComputedStyle(el).getPropertyValue('--background').trim()); + expect(track).toMatch(/rgba?\(|color-mix/); + const bottom = await toolbar.evaluate((el) => getComputedStyle(el).bottom); + expect(Number.parseFloat(bottom)).toBeGreaterThanOrEqual(70); + }); + + test('thumbnail is vertically centered in the 64px toolbar', async ({ page }) => { + await openAlbum(page); + const toolbar = page.locator('ion-toolbar.ios-theme-tab-accessory'); + const metrics = await toolbar.evaluate((el) => { + const thumb = el.querySelector('ion-thumbnail'); + const bar = el.getBoundingClientRect(); + const art = thumb!.getBoundingClientRect(); + return { top: art.top - bar.top, bottom: bar.bottom - art.bottom, height: bar.height }; + }); + expect(metrics.height).toBe(64); + expect(Math.abs(metrics.top - metrics.bottom)).toBeLessThanOrEqual(1); + }); + + test('classic preset uses a 12px radius and sits above the 62px island', async ({ page }) => { + await openAlbum(page); + const toolbar = page.locator('ion-toolbar.ios-theme-tab-accessory'); + await toolbar.evaluate((el) => el.classList.add('ios-theme-tab-accessory-classic')); + await expect(toolbar).toHaveCSS('border-radius', '12px'); + const bottom = await toolbar.evaluate((el) => getComputedStyle(el).bottom); + expect(Number.parseFloat(bottom)).toBeGreaterThanOrEqual(70); + }); + + test('writes docs screenshots for the capsule and classic presets', async ({ page, browserName }) => { + test.skip(browserName !== 'webkit', 'Docs shots are captured from WebKit'); + mkdirSync(shots, { recursive: true }); + await openAlbum(page); + const toolbar = page.locator('ion-toolbar.ios-theme-tab-accessory'); + await page.screenshot({ + path: resolve(shots, 'webkit-capsule.png'), + clip: { x: 0, y: 680, width: 390, height: 164 }, + }); + await toolbar.evaluate((el) => el.classList.add('ios-theme-tab-accessory-classic')); + await page.screenshot({ + path: resolve(shots, 'webkit-classic.png'), + clip: { x: 0, y: 680, width: 390, height: 164 }, + }); + }); +}); diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts index 5b1a3f3f..60f71ec4 100644 --- a/demo/playwright.config.ts +++ b/demo/playwright.config.ts @@ -20,12 +20,22 @@ export default defineConfig({ projects: [ { name: 'chromium', + testIgnore: 'tab-accessory.spec.ts', use: { ...devices['Desktop Chrome'], // Explicitly enable headless mode for better performance in CI headless: true, }, }, + { + name: 'webkit', + testMatch: 'tab-accessory.spec.ts', + use: { + ...devices['iPhone 13'], + browserName: 'webkit', + headless: true, + }, + }, ], webServer: { command: process.env['IONIC_MAJOR'] === '8' ? 'npm run start -- --configuration=ionic8' : 'npm run start', diff --git a/demo/src/app/tabs/tabs.page.html b/demo/src/app/tabs/tabs.page.html index 72b06e0d..58db6815 100644 --- a/demo/src/app/tabs/tabs.page.html +++ b/demo/src/app/tabs/tabs.page.html @@ -26,6 +26,31 @@
+ + + + + +

Now Playing

+

Demo Artist

+
+ + + 1:24 + 3:45 + + + + + + +
diff --git a/demo/src/app/tabs/tabs.page.spec.ts b/demo/src/app/tabs/tabs.page.spec.ts index a1677411..d563bbe6 100644 --- a/demo/src/app/tabs/tabs.page.spec.ts +++ b/demo/src/app/tabs/tabs.page.spec.ts @@ -66,6 +66,36 @@ describe('TabsPage', () => { } }); + it('shows the mini-player accessory on the Library tab', () => { + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: { supports: () => true }, + }); + navigation.next(new NavigationEnd(1, '/main/album', '/main/album')); + expect(component.showAccessory).toBe(true); + expect(component.classicAccessory).toBe(false); + }); + + it('opts into the classic mini-player from ?classic=', () => { + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: { supports: () => true }, + }); + navigation.next(new NavigationEnd(1, '/main/album?classic=', '/main/album?classic=')); + expect(component.showAccessory).toBe(true); + expect(component.classicAccessory).toBe(true); + }); + + it('uses classic automatically when text-wrap: pretty is unsupported', () => { + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: { supports: () => false }, + }); + navigation.next(new NavigationEnd(1, '/main/album', '/main/album')); + expect(component.showAccessory).toBe(true); + expect(component.classicAccessory).toBe(true); + }); + it('stops reacting to router events after the component is destroyed', () => { const tabBar = fixture.nativeElement.querySelector('ion-tab-bar') as HTMLElement; navigation.next(new NavigationEnd(1, '/main/settings', '/main/settings')); diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index c6db3f9a..43afd747 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,5 +1,7 @@ import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core'; import { + IonButton, + IonButtons, IonContent, IonIcon, IonItem, @@ -7,10 +9,13 @@ import { IonLabel, IonList, IonMenu, + IonProgressBar, IonSplitPane, IonTabBar, IonTabButton, IonTabs, + IonThumbnail, + IonToolbar, ViewDidEnter, ViewDidLeave, } from '@demo/ionic'; @@ -18,7 +23,7 @@ import { NavigationEnd, Router, RouterLink } from '@angular/router'; import { filter } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { registeredEffect, registerTabBarEffect } from '../../../../src'; +import { enableTabAccessory, registeredEffect, registerTabBarEffect } from '../../../../src'; import { applyFoldStateClasses } from '../../../../src/vertical-bars'; import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; import { Capacitor } from '@capacitor/core'; @@ -39,6 +44,11 @@ import { Capacitor } from '@capacitor/core'; IonList, IonItem, IonItemGroup, + IonToolbar, + IonThumbnail, + IonButton, + IonButtons, + IonProgressBar, RouterLink, ], }) @@ -49,6 +59,10 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, #hingeListener?: { remove(): Promise }; readonly #destroyRef = inject(DestroyRef); readonly registeredGestures: registeredEffect[] = []; + playing = true; + showAccessory = false; + classicAccessory = false; + accessoryActivated = false; ngOnInit() { this.#router.events .pipe( @@ -61,11 +75,16 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, return; } const path = params.urlAfterRedirects.split(/[?#]/, 1)[0]; - if (['/main/settings', '/main/index/toolbar', '/main/index/button-projection'].includes(path)) { + const hideTabs = ['/main/settings', '/main/index/toolbar', '/main/index/button-projection'].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)); + this.classicAccessory = + /[?&]classic(?:=|$|&)/.test(params.urlAfterRedirects) || + !(typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('text-wrap', 'pretty')); }); } @@ -103,11 +122,21 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, this.ionViewDidLeave(); } + togglePlay(event: Event) { + event.stopPropagation(); + this.playing = !this.playing; + } + + onAccessoryActivate() { + this.accessoryActivated = true; + } + ionViewDidEnter() { const registerGesture = registerTabBarEffect(document.querySelector('ion-tab-bar')!); if (registerGesture) { this.registeredGestures.push(registerGesture); } + this.registeredGestures.push(enableTabAccessory()); } ionViewDidLeave() { diff --git a/demo/src/glass-mixins.spec.ts b/demo/src/glass-mixins.spec.ts index 3f6fbb4c..49e4d6fe 100644 --- a/demo/src/glass-mixins.spec.ts +++ b/demo/src/glass-mixins.spec.ts @@ -14,3 +14,17 @@ test('glass-background keeps positional and named argument compatibility', () => compile('@use "api"; .probe { @include api.glass-background($opacity: 0.1, $blur: 0, $saturate: 120%, $include-border: false); }'), ); }); + +test('tab accessory classic preset compiles with layout variables and an rgba progress fallback', () => { + const css = compileString('@use "ion-tab-accessory";', { + loadPaths: ['../src/styles/components'], + style: 'compressed', + }).css; + expect(css).toContain('--ios-theme-tab-accessory-radius'); + expect(css).toContain('--ios-theme-tab-accessory-height'); + expect(css).toContain('--ios-theme-tab-accessory-inset'); + expect(css).toContain('--ios-theme-tab-accessory-thumb-size'); + expect(css).toContain('--ios-theme-tab-accessory-bottom'); + expect(css).toContain('ios-theme-tab-accessory-classic'); + expect(css).toMatch(/rgba\(var\(--ion-color-primary-rgb/); +}); diff --git a/demo/src/global.scss b/demo/src/global.scss index fba628bb..3fd5b52e 100644 --- a/demo/src/global.scss +++ b/demo/src/global.scss @@ -57,6 +57,9 @@ @include meta.load-css('../node_modules/@rdlabo/ionic-theme-ios26/src/styles/md-remove-ios-class-effect'); } +// Mini-player CSS is independent of the iOS 26/27 generation gate so Safari 15–18 can still render it. +@include meta.load-css('../../src/styles/components/ion-tab-accessory'); + :root { --ios-theme-menu-width: 300px; } diff --git a/demo/src/ion-tab-accessory.spec.ts b/demo/src/ion-tab-accessory.spec.ts new file mode 100644 index 00000000..a578db9e --- /dev/null +++ b/demo/src/ion-tab-accessory.spec.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, test } from 'vitest'; +import { readCandidate } from '../../src/native/components'; + +const size = () => ({ x: 16, y: 700, width: 358, height: 56, top: 700, left: 16, right: 374, bottom: 756 }) as DOMRect; + +const identify = (el: HTMLElement) => { + if (el.matches('[data-tab-accessory="play"]') || el.tagName === 'ION-BUTTON') return 'play'; + if (el.matches('[data-tab-accessory="artwork"]') || el.tagName === 'IMG') return 'artwork'; + return 'accessory'; +}; + +const mount = (playing = true) => { + document.documentElement.style.cssText = 'display: block; visibility: visible; opacity: 1; transform: none'; + document.body.style.cssText = 'display: block; visibility: visible; opacity: 1; transform: none'; + document.body.innerHTML = ` + + + +

Mahamudra

+

Lama Ole

+
+ + + 1:09:29 + 3:04:05 + + + + + + +
`; + const toolbar = document.querySelector('ion-toolbar')!; + const play = document.querySelector('ion-button')!; + const icon = document.querySelector('ion-icon')!; + const shadow = icon.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + for (const element of [toolbar, play, icon, ...Array.from(document.querySelectorAll('img, ion-label, ion-progress-bar'))]) { + element.style.cssText = 'display: flex; visibility: visible; opacity: 1; width: 358px; height: 56px'; + element.getBoundingClientRect = size; + } + return toolbar; +}; + +afterEach(() => document.body.replaceChildren()); + +test('projects marked toolbar title, progress and play state', () => { + const candidate = readCandidate(mount(true), identify); + expect(candidate?.control.kind).toBe('ion-toolbar'); + expect(candidate?.control.title).toBe('Mahamudra'); + expect(candidate?.control.subtitle).toBe('Lama Ole'); + expect(candidate?.control.artworkUrl).toContain('cover.jpg'); + expect(candidate?.control.progress).toBe(0.4); + expect(candidate?.control.elapsed).toBe('1:09:29'); + expect(candidate?.control.duration).toBe('3:04:05'); + expect(candidate?.control.items).toHaveLength(2); + expect(candidate?.control.items[0].selected).toBe(true); + expect(candidate?.control.items[1].label).toBe('Artwork'); + expect(candidate?.actions.get('accessory')).toBe(document.querySelector('ion-toolbar')); + expect(candidate?.actions.get('play')).toBe(document.querySelector('ion-button')); + expect(candidate?.actions.get('artwork')).toBe(document.querySelector('img')); +}); + +test('projects play without a hydrated ion-icon svg', () => { + const toolbar = mount(true); + document.querySelector('ion-icon')?.shadowRoot?.replaceChildren(); + const candidate = readCandidate(toolbar, identify); + expect(candidate?.control.items).toHaveLength(2); + expect(candidate?.control.items[0].selected).toBe(true); +}); + +test('projects play only when artwork is absent', () => { + const toolbar = mount(true); + document.querySelector('img')?.remove(); + const candidate = readCandidate(toolbar, identify); + expect(candidate?.control.items).toHaveLength(1); + expect(candidate?.control.items[0].id).toBe('play'); + expect(candidate?.actions.get('artwork')).toBeUndefined(); +}); + +test('projects progress of 0', () => { + const toolbar = mount(false); + const bar = document.querySelector('ion-progress-bar')!; + bar.setAttribute('value', '0'); + (bar as HTMLElement & { value?: number }).value = 0; + const candidate = readCandidate(toolbar, identify); + expect(candidate?.control.progress).toBe(0); +}); + +test('maps play icon to unselected and skips unmarked toolbars', () => { + const candidate = readCandidate(mount(false), identify); + expect(candidate?.control.items[0].selected).toBe(false); + document.body.innerHTML = ''; + const plain = document.querySelector('ion-toolbar')!; + document.documentElement.style.cssText = 'display: block; visibility: visible; opacity: 1; transform: none'; + document.body.style.cssText = 'display: block; visibility: visible; opacity: 1; transform: none'; + plain.style.cssText = 'display: flex; visibility: visible; opacity: 1; width: 358px; height: 56px; transform: none'; + plain.getBoundingClientRect = size; + expect(readCandidate(plain, () => 'plain')).toBeUndefined(); +}); + +test('does not project an accessory inside ion-modal', () => { + const toolbar = mount(true); + const modal = document.createElement('ion-modal'); + modal.append(toolbar); + document.body.append(modal); + expect(readCandidate(toolbar, identify)).toBeUndefined(); +}); + +test('projects a classic-preset toolbar', () => { + const toolbar = mount(true); + toolbar.classList.add('ios-theme-tab-accessory-classic'); + const candidate = readCandidate(toolbar, identify); + expect(candidate?.control.kind).toBe('ion-toolbar'); + expect(candidate?.control.title).toBe('Mahamudra'); + expect(candidate?.control.progress).toBe(0.4); +}); diff --git a/demo/src/tab-accessory.spec.ts b/demo/src/tab-accessory.spec.ts new file mode 100644 index 00000000..7dee53df --- /dev/null +++ b/demo/src/tab-accessory.spec.ts @@ -0,0 +1,166 @@ +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 = alias ? 'ios-theme-tab-accessory-lift' : 'tab-accessory-lift'; + const toolbarClass = alias ? 'ios ios-theme-tab-accessory' : 'ios 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('skips Native UI Shell hosts', () => { + const { toolbar } = mount(); + toolbar.setAttribute('data-native-ui-shell', ''); + expect(attachTabAccessory(toolbar)).toBeUndefined(); + expect(createGestureMock).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/native-ui-shell.md b/docs/native-ui-shell.md index 25e1c0ca..c8ba3cf9 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -49,6 +49,7 @@ The table below describes the ordinary Native UI Shell. Vertical Bars uses the s | `ion-back-button` | Standard icon and color in a fixed header/footer toolbar | Glass `UIButton`, using the resolved Ionic label/icon | | `ion-menu-button` | Fixed toolbar, inside the theme glass `ion-buttons` | Glass `UIButton`; original Ionic menu toggle | | `ion-tab-bar` | Fixed tabs, icon-only/label-only items, one icon per item, dot/text badges, selection and disabled state | `UITabBar` and `UITabBarItem` | +| `ion-toolbar.tab-accessory` (alias `ios-theme-tab-accessory`) | Mini-player in a fixed footer region above `ion-tab-bar`: thumbnail, title, subtitle, play/pause button, progress | iOS 26+ `UITabAccessory` glass capsule in the system gap above the projected tab bar; Web CSS on other platforms | | `ion-segment` | Fixed toolbar, non-scrollable, text **or** one icon per item | `UISegmentedControl` | | `ion-fab` / `ion-fab-button` / `ion-fab-list` | Glass FAB in an `ion-content` fixed slot; one main button and optional directional lists | Persistent glass `UIButton` per button; one FAB synchronization group | @@ -68,6 +69,74 @@ If a child inside a shared native surface opts out, the entire surface stays on Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; Vertical Bars projection does not require `ios` mode classes. Collapsed headers, opted-out controls and departed pages are excluded. Full-width foreground modals can participate in Vertical Bars as described below; other overlay surfaces keep their own layout. +```html + + + + + +

Teaching title

+

Teacher · place

+
+ + + + +
+``` + +Place the toolbar in a fixed footer region above `ion-tab-bar` (not inside `ion-content` or an overlay). Native activation clicks the original play button, the toolbar, or `[data-tab-accessory="artwork"]` when present. The plugin does not interpret artwork (hosts wire video, album, or any other action). `data-shell="disabled"` opts it out with the rest of the shell. Without native projection the same markup uses the theme CSS capsule. + +### Web fallback and older iOS + +Native glass (`UITabAccessory`) requires iOS 26+. The Web mini-player is CSS, and it is **not** tied to the iOS 26/27 generation gate (`overflow-anchor` / `text-wrap: pretty`). Load it next to the theme so Safari 15–18 still get a bar: + +```scss +@use 'sass:meta'; + +// iOS 27 / Safari 27+ +@supports (overflow-anchor: auto) { /* ionic-theme-ios27 */ } +// iOS 26 / Safari 17.4–26 +@supports (text-wrap: pretty) and (not (overflow-anchor: auto)) { /* ionic-theme-ios26 */ } + +@include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/components/ion-tab-accessory'); +``` + +| Surface | CSS feature | Mini-player | +| --- | --- | --- | +| iOS 26+ Capacitor with Native UI Shell | — | Native `UITabAccessory` glass capsule | +| Safari 27+ / iOS 27 without native | `overflow-anchor: auto` | Theme CSS capsule (28px) | +| Safari 17.4–26 / iOS 17.4–26 | `text-wrap: pretty` | Theme CSS capsule, if accessory CSS is loaded | +| Safari 15–17.3 / iOS 15–17.3 | neither | Ionic chrome + accessory CSS (use `.ios-theme-tab-accessory-classic`) | +| Safari 15–16.1 | no `color-mix` | Progress track uses `rgba(var(--ion-color-primary-rgb), .22)` | + +The default Web look is the 64×28px capsule: 8px block padding, a 36px thumb, two text lines, and a 2.5px progress track. That 64px is **not** the native height. `UITabAccessory` sizes its own glass platter (about 42pt on iOS 26). Native content is vertically centered with a shrinkable 36pt thumb so it fits that platter; there is no 42px plugin token. Override `--ios-theme-tab-accessory-height` (and `--ios-theme-tab-accessory-padding-block`, `--ios-theme-tab-accessory-thumb-size`) on `:root` or `.accessory-slide-clip` if a host needs a taller or tighter Web bar. Add `.ios-theme-tab-accessory-classic` for a 12px rounded rectangle that sits 8px above the in-flow tab bar (closer to iOS 15–18 Music / segmented controls than the pill): + +```html + +``` + +Override layout with CSS variables (defaults in parentheses): + +| Variable | Default | Classic | +| --- | --- | --- | +| `--ios-theme-tab-accessory-radius` | `28px` | `12px` | +| `--ios-theme-tab-accessory-height` | `64px` (Web only) | `64px` (Web only) | +| `--ios-theme-tab-accessory-padding-block` | `8px` | `8px` | +| `--ios-theme-tab-accessory-inset` | `16px` | `8px` | +| `--ios-theme-tab-accessory-thumb-size` | `36px` | `36px` | +| `--ios-theme-tab-accessory-thumb-radius` | `6px` | `6px` | +| `--ios-theme-tab-accessory-bottom` | floating 62px island + 8px | same on iOS 17.4+; in-flow 50px + 8px on iOS 15–17.3 | + +Thumbnail sizing applies to `ion-thumbnail` and to any `[slot="start"]` host (for example a custom `app-image`). + +Plain-plugin shots from the demo Library tab (`/main/album`; add `?classic=` for the 12px preset): + +

+ Web CSS capsule mini-player above the tab bar + Classic 12px rounded-rectangle mini-player +

+ Native tabs accept equal-width items with Ionic's default `layout="icon-top"`. The native bar uses a local compact horizontal and regular vertical size class to preserve the Web's stacked icon/label arrangement on iPad and in landscape. This does not change the app's size class. Label size and weight follow the Web snapshot. Other explicit Ionic layouts (`icon-start`, `icon-end`, `icon-bottom`, `icon-hide`, `label-hide`) and unequal item widths keep the entire tab bar on the Web. Start, center and end placement follow the original `ion-tab-bar`, including RTL. Directional `ion-icon` artwork preserves its rendered RTL flip. Outside Vertical Bars, standalone clear, solid and outline buttons are excluded. A glass `ion-buttons` group of two or more clear buttons is projected as one surface; its children retain separate actions. Menu buttons can also share that group. A single menu button uses its parent `ion-buttons` as the glass surface, so no Web glass is left underneath the native button. A menu button outside this theme glass remains on the Web. Mixed fills, unsupported children, or a theme-disabled child keep the group on the Web. Single clear buttons remain on the Web. Custom button colors, custom back icons/colors, collapsing headers, toolbars inside scrolling content, modal content, scrollable/expanded segments and segment-view integration remain on the Web. Complex slots and unsupported SVG features also fall back to Web. The plugin does not translate arbitrary application CSS into UIKit styles. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift index bb7caeac..3f523f4b 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift @@ -2,7 +2,7 @@ import UIKit // Static composition of supported wire kinds. Component files own their names and rendering. enum ShellComponents { - static let supported = Set(ShellButton.kinds + [ShellButtons.kind, ShellTabBar.kind, ShellSegment.kind, ShellFab.kind]) + static let supported = Set(ShellButton.kinds + [ShellButtons.kind, ShellTabBar.kind, ShellComponent.tabAccessory, ShellSegment.kind, ShellFab.kind]) @available(iOS 26.0, *) static func make(_ node: ShellControl, scale: CGFloat, rendering: ShellRendering, @@ -13,6 +13,7 @@ enum ShellComponents { case .tabBar: return ShellTabBar.make(node, rendering: rendering, delegate: tabDelegate) case .segment: return ShellSegment.make(node, scale: scale, rendering: rendering, activate: activate) case .fab: return ShellFab() + case .tabAccessory: return nil } } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift index 6979df3f..8ab4a832 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift @@ -23,7 +23,20 @@ enum ShellSearchPhase: String { case input, focus, blur, clear, commit } // A controller's empty content must not intercept the existing WebView. final class ShellSearchHost: UIView { + weak var accessoryContent: UIView? + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let accessoryContent { + let local = convert(point, to: accessoryContent) + if let hit = accessoryContent.hitTest(local, with: event) { return hit } + if accessoryContent.point(inside: local, with: event) { return accessoryContent } + if let chrome = accessoryContent.superview, chrome !== self, chrome.bounds.height > 0, chrome.bounds.height <= 120 { + let chromeLocal = convert(point, to: chrome) + if chrome.point(inside: chromeLocal, with: event) { + return accessoryContent.hitTest(local, with: event) ?? accessoryContent + } + } + } guard let hit = super.hitTest(point, with: event) else { return nil } var ancestor: UIView? = hit while let current = ancestor, current !== self { @@ -82,6 +95,7 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat private var focusWork: DispatchWorkItem? private var pendingSelection: ShellTabBar.PendingSelection? // optimistic ordinary tab private var pendingExpiryWork: DispatchWorkItem? + private let accessory = ShellTabAccessoryBinding() var ownsKeyboard: Bool { search.searchBar.searchTextField.isFirstResponder } var ownsKeyboardChrome: Bool { session == .focused || ownsKeyboard } var activate: ((String) -> Void)? @@ -116,6 +130,16 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat } searchTab.automaticallyActivatesSearch = false inputDelegate.clear = { [weak self] in self?.emit(.clear) } + accessory.activate = { [weak self] id in self?.activate?(id) } + surface.accessoryContent = accessory.content + registerForTraitChanges([UITraitTabAccessoryEnvironment.self]) { [weak self] (_: UITraitEnvironment, _: UITraitCollection) in + self?.accessory.content.setInlineLayout(self?.traitCollection.tabAccessoryEnvironment == .inline) + } + } + + func applyAccessory(_ node: ShellControl?) { + loadViewIfNeeded() + accessory.apply(node, on: self) } deinit { @@ -228,6 +252,7 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat lockedWebFrame = nil clearPendingSelection() applySession(.idle, selectingSearchTab: false) + accessory.apply(nil, on: self) willMove(toParent: nil) restingBarFrame = nil surface.removeFromSuperview() diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabAccessory.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabAccessory.swift new file mode 100644 index 00000000..79723038 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabAccessory.swift @@ -0,0 +1,458 @@ +import UIKit + +/// Mini-player content hosted in `UITabAccessory` (iOS 26+). +@available(iOS 26.0, *) +final class ShellTabAccessoryContentView: UIView, UIGestureRecognizerDelegate { + var onPlayPause: (() -> Void)? + var onTap: (() -> Void)? + var onArtwork: (() -> Void)? + + private static let swipeUpThreshold: CGFloat = 50 + private static let swipeMaxHorizontalDrift: CGFloat = 48 + /// UITabAccessory's glass platter is about 42pt on iOS 26; content must fit that, not a CSS height. + private static let minInset: CGFloat = 4 + private static let regularArtwork: CGFloat = 36 + private static let inlineArtwork: CGFloat = 28 + + private let artworkButton = UIButton(type: .custom) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let playPauseButton = UIButton(type: .system) + private let elapsedLabel = UILabel() + private let durationLabel = UILabel() + private let stack = UIStackView() + private let textStack = UIStackView() + private let timeStack = UIStackView() + private let progressTrack = UIView() + private let progressFill = UIView() + private var isPlaying = false + private var isInlineLayout = false + private var progress: CGFloat = -1 + private var progressColor: UIColor? + private var artworkSize: NSLayoutConstraint? + private var artworkMax: NSLayoutConstraint? + private var stackLeading: NSLayoutConstraint? + private var stackTrailing: NSLayoutConstraint? + private var stackTop: NSLayoutConstraint? + private var stackBottom: NSLayoutConstraint? + private var panGesture: UIPanGestureRecognizer? + private var suppressNextTap = false + var currentArtworkUrl: String? + var hasArtwork: Bool { artworkButton.image(for: .normal) != nil } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + titleLabel.font = .preferredFont(forTextStyle: .subheadline).withWeight(.semibold) + titleLabel.textColor = .label + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + subtitleLabel.font = .preferredFont(forTextStyle: .caption1) + subtitleLabel.textColor = .secondaryLabel + subtitleLabel.numberOfLines = 1 + subtitleLabel.lineBreakMode = .byTruncatingTail + subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + elapsedLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) + elapsedLabel.textColor = .label + elapsedLabel.textAlignment = .right + elapsedLabel.setContentHuggingPriority(.required, for: .horizontal) + elapsedLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + durationLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + durationLabel.textColor = .secondaryLabel + durationLabel.textAlignment = .right + durationLabel.setContentHuggingPriority(.required, for: .horizontal) + durationLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + artworkButton.imageView?.contentMode = .scaleAspectFill + artworkButton.contentHorizontalAlignment = .fill + artworkButton.contentVerticalAlignment = .fill + artworkButton.clipsToBounds = true + artworkButton.layer.cornerRadius = 6 + artworkButton.layer.cornerCurve = .continuous + artworkButton.backgroundColor = .secondarySystemFill + artworkButton.accessibilityLabel = "Artwork" + artworkButton.addTarget(self, action: #selector(artworkTapped), for: .touchUpInside) + let artworkHeight = artworkButton.heightAnchor.constraint(equalToConstant: Self.regularArtwork) + artworkHeight.priority = .defaultHigh + artworkHeight.isActive = true + artworkSize = artworkHeight + artworkButton.widthAnchor.constraint(equalTo: artworkButton.heightAnchor).isActive = true + artworkButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 22).isActive = true + playPauseButton.addTarget(self, action: #selector(playPauseTapped), for: .touchUpInside) + playPauseButton.tintColor = .label + playPauseButton.accessibilityLabel = "Play or pause" + playPauseButton.setContentHuggingPriority(.required, for: .horizontal) + playPauseButton.setContentCompressionResistancePriority(.required, for: .horizontal) + playPauseButton.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + playPauseButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + let playHeight = playPauseButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) + playHeight.priority = .defaultHigh + playHeight.isActive = true + var config = UIButton.Configuration.plain() + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6) + playPauseButton.configuration = config + updatePlayImage() + textStack.axis = .vertical + textStack.spacing = 1 + textStack.alignment = .leading + textStack.addArrangedSubview(titleLabel) + textStack.addArrangedSubview(subtitleLabel) + textStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + timeStack.axis = .vertical + timeStack.spacing = 1 + timeStack.alignment = .trailing + timeStack.addArrangedSubview(elapsedLabel) + timeStack.addArrangedSubview(durationLabel) + timeStack.setContentHuggingPriority(.required, for: .horizontal) + timeStack.setContentCompressionResistancePriority(.required, for: .horizontal) + timeStack.isHidden = true + stack.axis = .horizontal + stack.alignment = .center + stack.distribution = .fill + stack.spacing = 10 + stack.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(artworkButton) + stack.addArrangedSubview(textStack) + stack.addArrangedSubview(timeStack) + stack.addArrangedSubview(playPauseButton) + stack.setCustomSpacing(8, after: textStack) + stack.setCustomSpacing(4, after: timeStack) + addSubview(stack) + progressTrack.translatesAutoresizingMaskIntoConstraints = false + progressTrack.isUserInteractionEnabled = false + progressTrack.isHidden = true + progressFill.isUserInteractionEnabled = false + progressTrack.addSubview(progressFill) + addSubview(progressTrack) + applyProgressColors() + let leading = stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16) + let trailing = stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10) + let top = stack.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: Self.minInset) + let bottom = stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -Self.minInset) + let centerY = stack.centerYAnchor.constraint(equalTo: centerYAnchor) + stackLeading = leading + stackTrailing = trailing + stackTop = top + stackBottom = bottom + let artworkCap = artworkButton.heightAnchor.constraint( + lessThanOrEqualTo: heightAnchor, constant: -(Self.minInset * 2) + ) + artworkMax = artworkCap + NSLayoutConstraint.activate([ + leading, trailing, top, bottom, centerY, artworkCap, + playPauseButton.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor), + progressTrack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + progressTrack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + progressTrack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + progressTrack.heightAnchor.constraint(equalToConstant: 2.5), + ]) + let tap = UITapGestureRecognizer(target: self, action: #selector(bodyTapped)) + tap.delegate = self + addGestureRecognizer(tap) + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.delegate = self + pan.cancelsTouchesInView = false + pan.maximumNumberOfTouches = 1 + addGestureRecognizer(pan) + panGesture = pan + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply(_ node: ShellControl) { + titleLabel.text = node.title ?? node.items.first?.content.label + let subtitle = node.subtitle + subtitleLabel.text = subtitle + subtitleLabel.isHidden = isInlineLayout || subtitle?.isEmpty != false + elapsedLabel.text = node.elapsed + durationLabel.text = node.duration + let hasTime = !(node.elapsed?.isEmpty ?? true) || !(node.duration?.isEmpty ?? true) + elapsedLabel.isHidden = node.elapsed?.isEmpty ?? true + durationLabel.isHidden = node.duration?.isEmpty ?? true + timeStack.isHidden = !hasTime + isPlaying = node.items.first?.content.selected == true + || node.items.first?.content.label.lowercased().contains("pause") == true + updatePlayImage() + if let progress = node.progress { + setProgress(CGFloat(progress)) + } + if let color = node.progressColor { + setProgressColor(UIColor.parseCSS(color)) + } + } + + func setProgress(_ value: CGFloat) { + progress = value + progressTrack.isHidden = !value.isFinite || value < 0 + setNeedsLayout() + } + + func setProgressColor(_ color: UIColor?) { + progressColor = color + applyProgressColors() + } + + func setArtwork(_ image: UIImage?) { + artworkButton.setImage(image, for: .normal) + artworkButton.backgroundColor = image == nil ? .secondarySystemFill : .clear + } + + func reset() { + titleLabel.text = nil + subtitleLabel.text = nil + subtitleLabel.isHidden = true + elapsedLabel.text = nil + durationLabel.text = nil + timeStack.isHidden = true + isPlaying = false + currentArtworkUrl = nil + setArtwork(nil) + setProgress(-1) + updatePlayImage() + } + + func setInlineLayout(_ inline: Bool) { + guard inline != isInlineLayout else { return } + isInlineLayout = inline + subtitleLabel.isHidden = inline || subtitleLabel.text?.isEmpty != false + titleLabel.font = inline + ? .preferredFont(forTextStyle: .caption1) + : .preferredFont(forTextStyle: .subheadline).withWeight(.semibold) + artworkSize?.constant = inline ? Self.inlineArtwork : Self.regularArtwork + stack.spacing = inline ? 8 : 10 + stackLeading?.constant = inline ? 14 : 16 + stackTrailing?.constant = inline ? -12 : -10 + stackTop?.constant = Self.minInset + stackBottom?.constant = -Self.minInset + artworkMax?.constant = -(Self.minInset * 2) + var config = UIButton.Configuration.plain() + let pad: CGFloat = inline ? 4 : 6 + config.contentInsets = NSDirectionalEdgeInsets(top: pad, leading: pad, bottom: pad, trailing: pad) + playPauseButton.configuration = config + updatePlayImage() + invalidateIntrinsicContentSize() + setNeedsLayout() + } + + override var intrinsicContentSize: CGSize { + let art = isInlineLayout ? Self.inlineArtwork : Self.regularArtwork + return CGSize(width: UIView.noIntrinsicMetric, height: art + Self.minInset * 2) + } + + override func layoutSubviews() { + super.layoutSubviews() + let width = progressTrack.bounds.width * max(0, min(1, progress)) + progressFill.frame = CGRect(x: 0, y: 0, width: width, height: progressTrack.bounds.height) + let size = min(artworkButton.bounds.width, artworkButton.bounds.height) + if size > 0 { + artworkButton.layer.cornerRadius = size * 0.22 + } + } + + private func applyProgressColors() { + if let progressColor { + progressFill.backgroundColor = progressColor + progressTrack.backgroundColor = progressColor.withAlphaComponent(0.22) + } else { + progressFill.backgroundColor = UIColor.label.withAlphaComponent(0.9) + progressTrack.backgroundColor = UIColor.label.withAlphaComponent(0.18) + } + } + + private func updatePlayImage() { + let name = isPlaying ? "pause.fill" : "play.fill" + let symbol = UIImage.SymbolConfiguration(pointSize: isInlineLayout ? 14 : 16, weight: .semibold) + playPauseButton.setImage(UIImage(systemName: name, withConfiguration: symbol), for: .normal) + } + + @objc private func playPauseTapped() { onPlayPause?() } + + @objc private func artworkTapped() { + if suppressNextTap { + suppressNextTap = false + return + } + onArtwork?() + } + + @objc private func bodyTapped() { + if suppressNextTap { + suppressNextTap = false + return + } + onTap?() + } + + private func resetLift(animated: Bool) { + let restore = { + self.transform = .identity + self.alpha = 1 + } + if animated { + UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseOut, .beginFromCurrentState], animations: restore) + } else { + restore() + } + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + let space = superview ?? self + let translation = gesture.translation(in: space) + switch gesture.state { + case .changed: + let y = min(0, translation.y) + transform = CGAffineTransform(translationX: 0, y: y) + let fadeDistance = max(bounds.height * 2, 120) + alpha = max(0, 1 - abs(y) / fadeDistance) + if y < -12 { suppressNextTap = true } + case .ended, .cancelled: + let velocity = gesture.velocity(in: space) + let isUpwardSwipe = gesture.state == .ended + && (translation.y < -Self.swipeUpThreshold || velocity.y < -280) + && abs(translation.x) < Self.swipeMaxHorizontalDrift + if isUpwardSwipe { + suppressNextTap = true + onTap?() + resetLift(animated: false) + } else { + resetLift(animated: true) + } + default: + break + } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + let playPoint = touch.location(in: playPauseButton) + if playPauseButton.bounds.contains(playPoint) { return false } + if gestureRecognizer is UITapGestureRecognizer { + let artPoint = touch.location(in: artworkButton) + if artworkButton.bounds.contains(artPoint) { return false } + } + return true + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } +} + +@available(iOS 26.0, *) +enum ShellTabAccessory { + static let kind = ShellComponent.tabAccessory +} + +/// Shared UITabAccessory wiring for ordinary tabs and searchable tabs. +@available(iOS 26.0, *) +final class ShellTabAccessoryBinding { + let content = ShellTabAccessoryContentView() + var activate: ((String) -> Void)? + private var playId = "" + private var artworkId = "" + private var tapId = "" + private var artworkLoad: URLSessionDataTask? + + init() { + content.onPlayPause = { [weak self] in + guard let self, !self.playId.isEmpty else { return } + self.activate?(self.playId) + } + content.onArtwork = { [weak self] in + guard let self, !self.artworkId.isEmpty else { return } + self.activate?(self.artworkId) + } + content.onTap = { [weak self] in + guard let self, !self.tapId.isEmpty else { return } + self.activate?(self.tapId) + } + } + + func apply(_ node: ShellControl?, on controller: UITabBarController) { + guard let node else { + playId = "" + artworkId = "" + tapId = "" + artworkLoad?.cancel() + content.reset() + controller.setBottomAccessory(nil, animated: false) + return + } + playId = node.items.first?.id ?? "" + artworkId = node.items.dropFirst().first?.id ?? "" + tapId = node.id + content.apply(node) + loadArtwork(node.artworkUrl) + controller.setBottomAccessory(UITabAccessory(contentView: content), animated: false) + content.setInlineLayout(controller.traitCollection.tabAccessoryEnvironment == .inline) + } + + private func loadArtwork(_ urlString: String?) { + guard let urlString, !urlString.isEmpty else { + artworkLoad?.cancel() + content.currentArtworkUrl = nil + content.setArtwork(nil) + return + } + if urlString == content.currentArtworkUrl, content.hasArtwork { return } + content.currentArtworkUrl = urlString + artworkLoad?.cancel() + if urlString.hasPrefix("data:image"), + let comma = urlString.firstIndex(of: ","), + let data = Data(base64Encoded: String(urlString[urlString.index(after: comma)...])), + let image = UIImage(data: data) { + content.setArtwork(image) + return + } + if urlString.hasPrefix("file://"), let url = URL(string: urlString) { + content.setArtwork(UIImage(contentsOfFile: url.path)) + return + } + guard let url = URL(string: urlString) else { return } + let task = URLSession.shared.dataTask(with: url) { [weak content] data, _, _ in + let image = data.flatMap { UIImage(data: $0) } + DispatchQueue.main.async { + guard content?.currentArtworkUrl == urlString else { return } + content?.setArtwork(image) + } + } + artworkLoad = task + task.resume() + } +} + +private extension UIFont { + func withWeight(_ weight: UIFont.Weight) -> UIFont { + let descriptor = fontDescriptor.addingAttributes([ + .traits: [UIFontDescriptor.TraitKey.weight: weight] + ]) + return UIFont(descriptor: descriptor, size: pointSize) + } +} + +extension UIColor { + static func parseCSS(_ value: String?) -> UIColor? { + guard let value, !value.isEmpty else { return nil } + let numbers = value.components(separatedBy: CharacterSet(charactersIn: "0123456789.").inverted).compactMap(Double.init) + if numbers.count >= 3 { + return UIColor( + red: numbers[0] / 255, + green: numbers[1] / 255, + blue: numbers[2] / 255, + alpha: numbers.count > 3 ? numbers[3] : 1 + ) + } + var hex = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard hex.hasPrefix("#"), hex.count == 7 else { return nil } + hex.removeFirst() + guard let int = UInt64(hex, radix: 16) else { return nil } + return UIColor( + red: CGFloat((int >> 16) & 0xff) / 255, + green: CGFloat((int >> 8) & 0xff) / 255, + blue: CGFloat(int & 0xff) / 255, + alpha: 1 + ) + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabsController.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabsController.swift new file mode 100644 index 00000000..53e16493 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabsController.swift @@ -0,0 +1,289 @@ +import UIKit + +final class ShellTabsHost: UIView { + weak var accessoryContent: UIView? + weak var tabBar: UITabBar? + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil } + if let accessoryContent { + let local = convert(point, to: accessoryContent) + if let hit = accessoryContent.hitTest(local, with: event) { return hit } + if accessoryContent.point(inside: local, with: event) { return accessoryContent } + // UITabAccessory wraps content in a glass platter; keep body taps on the content view. + if let chrome = accessoryContent.superview, chrome !== self, chrome.bounds.height > 0, chrome.bounds.height <= 120 { + let chromeLocal = convert(point, to: chrome) + if chrome.point(inside: chromeLocal, with: event) { + return accessoryContent.hitTest(local, with: event) ?? accessoryContent + } + } + } + if let tabBar { + let local = convert(point, to: tabBar) + if tabBar.point(inside: local, with: event), let hit = tabBar.hitTest(local, with: event) { + return hit + } + } + return nil + } +} + +/// Overlay that owns a real `UITabBarController` so `UITabAccessory` can project. +/// The tab controller's view must be a direct subview of this overlay's view. +@available(iOS 26.0, *) +final class ShellTabsController: UIViewController, UITabBarControllerDelegate { + var activate: ((String) -> Void)? + var selectedID = "" + let surface = ShellTabsHost() + + private let barController = UITabBarController() + private let content = ShellTabAccessoryContentView() + private var ordinary: [String: UITab] = [:] + private var pendingSelection: ShellTabBar.PendingSelection? + private var pendingExpiryWork: DispatchWorkItem? + private var artworkLoad: URLSessionDataTask? + private var playId = "" + private var artworkId = "" + private var tapId = "" + private var tabBar: UITabBar { barController.tabBar } + + override func loadView() { + surface.backgroundColor = .clear + surface.isOpaque = false + view = surface + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.isOpaque = false + barController.delegate = self + barController.view.backgroundColor = .clear + barController.view.isOpaque = false + tabBar.isTranslucent = true + let appearance = UITabBarAppearance() + appearance.configureWithTransparentBackground() + tabBar.standardAppearance = appearance + tabBar.scrollEdgeAppearance = appearance + if #available(iOS 18.0, *) { barController.mode = .tabBar } + barController.tabBarMinimizeBehavior = .never + + addChild(barController) + view.addSubview(barController.view) + barController.view.translatesAutoresizingMaskIntoConstraints = false + barController.didMove(toParent: self) + NSLayoutConstraint.activate([ + barController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + barController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + barController.view.topAnchor.constraint(equalTo: view.topAnchor), + barController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + surface.tabBar = tabBar + surface.accessoryContent = content + content.onPlayPause = { [weak self] in + guard let self, !self.playId.isEmpty else { return } + self.activate?(self.playId) + } + content.onArtwork = { [weak self] in + guard let self, !self.artworkId.isEmpty else { return } + self.activate?(self.artworkId) + } + content.onTap = { [weak self] in + guard let self, !self.tapId.isEmpty else { return } + self.activate?(self.tapId) + } + registerForTraitChanges([UITraitTabAccessoryEnvironment.self]) { [weak self] (_: UITraitEnvironment, _: UITraitCollection) in + self?.applyAccessoryEnvironment() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyAccessoryEnvironment() + } + + func attach(to parent: UIViewController, in container: UIView) { + loadViewIfNeeded() + if self.parent !== parent { + willMove(toParent: nil) + view.removeFromSuperview() + removeFromParent() + parent.addChild(self) + container.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + view.topAnchor.constraint(equalTo: container.topAnchor), + view.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + didMove(toParent: parent) + } else if view.superview !== container { + view.removeFromSuperview() + container.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + view.topAnchor.constraint(equalTo: container.topAnchor), + view.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + } + container.bringSubviewToFront(view) + surface.isHidden = false + view.isHidden = false + } + + func detach() { + pendingExpiryWork?.cancel() + pendingExpiryWork = nil + pendingSelection = nil + artworkLoad?.cancel() + artworkLoad = nil + willMove(toParent: nil) + view.removeFromSuperview() + removeFromParent() + barController.tabs = [] + ordinary.removeAll() + selectedID = "" + playId = "" + tapId = "" + content.reset() + barController.setBottomAccessory(nil, animated: false) + } + + func apply(tabBar node: ShellControl, accessory: ShellControl, rendering: ShellRendering) { + loadViewIfNeeded() + let items = node.items + let ids = items.map(\.id) + for id in Array(ordinary.keys) where !ids.contains(id) { ordinary.removeValue(forKey: id) } + for item in items { + let tab = ordinary[item.id] ?? UITab(title: "", image: nil, identifier: item.id) { _ in + let child = UIViewController() + child.view.backgroundColor = .clear + child.view.isUserInteractionEnabled = false + return child + } + tab.accessibilityIdentifier = item.id + tab.title = item.content.label + tab.image = rendering.image(item.content) + tab.isEnabled = !item.content.disabled + ordinary[item.id] = tab + if item.content.selected { selectedID = item.id } + } + let requested = ids.compactMap { ordinary[$0] } + if barController.tabs.map(\.identifier) != requested.map(\.identifier) { + barController.tabs = requested + } + for item in items { + if let nativeItem = ordinary[item.id]?.viewController?.tabBarItem { + nativeItem.accessibilityLabel = item.content.accessibilityLabel + ShellTabBar.applyTypography(item.content, to: nativeItem) + ShellTabBar.applyBadge(item.content.badge, to: nativeItem, rendering: rendering) + } + } + resolveSelection(items) + playId = accessory.items.first?.id ?? "" + artworkId = accessory.items.dropFirst().first?.id ?? "" + tapId = accessory.id + content.apply(accessory) + loadArtwork(accessory.artworkUrl) + barController.setBottomAccessory(UITabAccessory(contentView: content), animated: false) + applyAccessoryEnvironment() + surface.accessoryContent = content + surface.tabBar = tabBar + surface.isHidden = false + view.isHidden = false + overrideUserInterfaceStyle = node.dark ? .dark : .light + barController.overrideUserInterfaceStyle = overrideUserInterfaceStyle + view.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight + barController.view.semanticContentAttribute = view.semanticContentAttribute + tabBar.semanticContentAttribute = view.semanticContentAttribute + } + + func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool { + guard tab.isEnabled else { return false } + armPendingSelection(tab.identifier) + activate?(tab.identifier) + return true + } + + private func applyAccessoryEnvironment() { + content.setInlineLayout(traitCollection.tabAccessoryEnvironment == .inline) + } + + private func resolveSelection(_ items: [ShellItem]) { + let fallback = items.first { $0.content.selected }.flatMap { ordinary[$0.id] } ?? ordinary[selectedID] + guard let pending = pendingSelection else { + if let fallback, barController.selectedTab !== fallback { barController.selectedTab = fallback } + return + } + let pendingTab = ordinary[pending.id] + let expired = CFAbsoluteTimeGetCurrent() >= pending.until + let unavailable = pendingTab == nil || (items.first { $0.id == pending.id }?.content.disabled ?? true) + if unavailable || expired { + pendingSelection = nil + pendingExpiryWork?.cancel() + pendingExpiryWork = nil + if let fallback { barController.selectedTab = fallback } + } else if selectedID == pending.id { + pendingSelection = nil + pendingExpiryWork?.cancel() + pendingExpiryWork = nil + if let fallback { barController.selectedTab = fallback } + } else if let pendingTab, barController.selectedTab !== pendingTab { + barController.selectedTab = pendingTab + } + } + + private func armPendingSelection(_ id: String) { + pendingSelection = ShellTabBar.PendingSelection.start(id) + pendingExpiryWork?.cancel() + let delay = max(0, (pendingSelection?.until ?? 0) - CFAbsoluteTimeGetCurrent()) + 0.02 + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.pendingExpiryWork = nil + guard let pending = self.pendingSelection, CFAbsoluteTimeGetCurrent() >= pending.until else { return } + self.pendingSelection = nil + if let selected = self.ordinary[self.selectedID], self.barController.selectedTab !== selected { + self.barController.selectedTab = selected + } + } + pendingExpiryWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + + private func loadArtwork(_ urlString: String?) { + guard let urlString, !urlString.isEmpty else { + artworkLoad?.cancel() + content.currentArtworkUrl = nil + content.setArtwork(nil) + return + } + if urlString == content.currentArtworkUrl, content.hasArtwork { return } + content.currentArtworkUrl = urlString + artworkLoad?.cancel() + if urlString.hasPrefix("data:image"), + let comma = urlString.firstIndex(of: ","), + let data = Data(base64Encoded: String(urlString[urlString.index(after: comma)...])), + let image = UIImage(data: data) { + content.setArtwork(image) + return + } + if urlString.hasPrefix("file://"), let url = URL(string: urlString) { + content.setArtwork(UIImage(contentsOfFile: url.path)) + return + } + guard let url = URL(string: urlString) else { return } + let task = URLSession.shared.dataTask(with: url) { [weak content] data, _, _ in + let image = data.flatMap { UIImage(data: $0) } + DispatchQueue.main.async { + guard content?.currentArtworkUrl == urlString else { return } + content?.setArtwork(image) + } + } + artworkLoad = task + task.resume() + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index c5e76e9b..d11dabc6 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -290,21 +290,7 @@ private struct ShellVerticalBarsLegacyToolbar: ViewModifier { @ObservedObject var model: ShellVerticalBarsModel func body(content: Content) -> some View { - content.toolbar { - if let back = model.back { - ToolbarItem(placement: .navigation) { - verticalBarsBackButton(back, model: model) - } - } - ForEach(model.groups) { group in - if group.id != model.groups.first?.id { - ToolbarSpacer(.fixed, placement: .primaryAction) - } - ForEach(group.items) { item in - ShellVerticalBarsButton(model: model, id: item.id, placement: .primaryAction) - } - } - } + content } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 0bac7c88..f3bf36cf 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -12,6 +12,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) ] + private var tabsController: AnyObject? + private var tabsBarId: String? private var host: ShellHost? private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] @@ -45,6 +47,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if !keyboard { self.host?.isHidden = true self.verticalBars?.view.isHidden = true + if #available(iOS 26.0, *) { + (self.tabsController as? ShellTabsController)?.surface.isHidden = true + } } var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true self.searchControllers.values.forEach { controller in @@ -131,6 +136,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func removeControl(_ id: String, duration: TimeInterval = 0) { + if tabsBarId == id { + detachTabsController(duration: duration) + return + } searchControllers.removeValue(forKey: id)?.detach() if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) } fingerprints.removeValue(forKey: id) @@ -142,6 +151,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele Array(controls.keys).forEach { removeControl($0, duration: duration) } host?.removeFromSuperview() host = nil + detachTabsController(duration: duration) verticalBars?.detach() verticalBars = nil rendering.clear() @@ -236,9 +246,15 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if snapshots.isEmpty { self.host?.removeFromSuperview() self.host = nil + self.detachTabsController(duration: duration) call.resolve(["revision": next, "rejectedControls": rejectedControls]) return } + let accessoryNode = self.keyboardVisible ? nil : snapshots.first(where: { $0.kind == .tabAccessory }) + let accessoryBarId = accessoryNode == nil ? nil : snapshots.first(where: { $0.kind == .tabBar && $0.search == nil })?.id + if accessoryBarId == nil { + self.detachTabsController(duration: duration) + } let host = self.host ?? ShellHost() self.host = host host.frame = parent.bounds @@ -265,6 +281,11 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true { reject(); continue } + if node.kind == .tabAccessory { + if self.keyboardVisible { reject() } + continue + } + if node.id == accessoryBarId { continue } let local = node.frame.rect let bounds = webView.convert(CGRect(x: local.minX * scale, y: local.minY * scale, width: local.width * scale, height: local.height * scale), to: parent) @@ -357,11 +378,34 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele previousCover.removeFromSuperview() } controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard + if let searchController = controller as? ShellSearchController { + searchController.applyAccessory(self.keyboardVisible ? nil : accessoryNode) + } } } for (id, control) in self.controls where !existing.contains(id) && self.searchControllers[id] == nil { ShellCrossfade.enter(control, duration: duration) } + if let accessoryNode, let bar = snapshots.first(where: { $0.id == accessoryBarId }), + let owner = self.bridge?.viewController { + let created = self.tabsController == nil + let controller = (self.tabsController as? ShellTabsController) ?? ShellTabsController() + controller.activate = { [weak self] id in self?.activate(id) } + if created, let overlay = self.controls[bar.id], overlay !== controller.surface { + ShellCrossfade.retire(overlay, duration: duration) + } + controller.attach(to: owner, in: owner.view) + controller.apply(tabBar: bar, accessory: accessoryNode, rendering: self.rendering) + self.tabsController = controller + self.tabsBarId = bar.id + self.controls[bar.id] = controller.surface + self.fingerprints[bar.id] = bar + if created { + ShellCrossfade.enter(controller.surface, duration: duration) + } + } else { + self.detachTabsController(duration: duration) + } let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls]) } if let coordinator = searches.first?.0.transitionCoordinator, coordinator.animate(alongsideTransition: nil, completion: { _ in complete() }) { return } @@ -391,6 +435,22 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private func activate(_ id: String) { sequence += 1 notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence]) + } + private func detachTabsController(duration: TimeInterval = 0) { + if #available(iOS 26.0, *) { + if let controller = tabsController as? ShellTabsController { + if let id = tabsBarId, let surface = controls[id], surface === controller.surface { + ShellCrossfade.retire(surface, duration: duration) + controls.removeValue(forKey: id) + fingerprints.removeValue(forKey: id) + pendingTabSelections.removeValue(forKey: id) + pendingTabExpiryWorks.removeValue(forKey: id)?.cancel() + } + controller.detach() + } + } + tabsController = nil + tabsBarId = nil } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index 58b09ec6..dd8abebf 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -4,7 +4,8 @@ import CoreGraphics enum ShellComponent: String, Decodable { case button = "ion-button", buttons = "ion-buttons" case backButton = "ion-back-button", menuButton = "ion-menu-button" - case tabBar = "ion-tab-bar", segment = "ion-segment", fab = "ion-fab" + case tabBar = "ion-tab-bar", tabAccessory = "ion-toolbar" + case segment = "ion-segment", fab = "ion-fab" } struct ShellSnapshot: Decodable { @@ -47,8 +48,18 @@ struct ShellControl: Decodable, Equatable { let rtl: Bool let tabBarAnchor: ShellTabBar.Anchor? let search: ShellSearch? + let title: String? + let subtitle: String? + let artworkUrl: String? + let progress: Double? + let progressColor: String? + let elapsed: String? + let duration: String? - private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search } + private enum CodingKeys: String, CodingKey { + case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search + case title, subtitle, artworkUrl, progress, progressColor, elapsed, duration + } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -62,15 +73,24 @@ struct ShellControl: Decodable, Equatable { rtl = try values.decode(Bool.self, forKey: .rtl) tabBarAnchor = try values.decodeIfPresent(ShellTabBar.Anchor.self, forKey: .tabBarAnchor) search = try values.decodeIfPresent(ShellSearch.self, forKey: .search) + title = try values.decodeIfPresent(String.self, forKey: .title) + subtitle = try values.decodeIfPresent(String.self, forKey: .subtitle) + artworkUrl = try values.decodeIfPresent(String.self, forKey: .artworkUrl) + progress = try values.decodeIfPresent(Double.self, forKey: .progress) + progressColor = try values.decodeIfPresent(String.self, forKey: .progressColor) + elapsed = try values.decodeIfPresent(String.self, forKey: .elapsed) + duration = try values.decodeIfPresent(String.self, forKey: .duration) } var isValid: Bool { ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 && (!ShellButton.kinds.contains(kind) || items.count == 1) && + (kind != .tabAccessory || (items.count >= 1 && items.count <= 2)) && (toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) && Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) && (tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) && - (search.map { kind == .tabBar && $0.isValid } ?? true) + (search.map { kind == .tabBar && $0.isValid } ?? true) && + (progress.map { $0.isFinite } ?? true) } } diff --git a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift index 13fcf74e..ae62d7a7 100644 --- a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +++ b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift @@ -647,4 +647,74 @@ final class ShellSnapshotTests: XCTestCase { XCTAssertFalse(model.focused) } + func testTabAccessoryDecodesOptionalFieldsAndRejectsEmptyItems() throws { + var payload = control([ + "kind": "ion-toolbar", + "title": "Mahamudra", + "subtitle": "Lama Ole", + "artworkUrl": "https://example.test/cover.jpg", + "progress": 0.4, + "progressColor": "rgb(205, 22, 43)", + "elapsed": "1:09:29", + "duration": "3:04:05", + "items": [item(["id": "play", "label": "Pause", "selected": true])], + ]) + let snapshot = try decode([payload]) + XCTAssertTrue(snapshot.isValid) + XCTAssertEqual(snapshot.controls[0].kind, .tabAccessory) + XCTAssertEqual(snapshot.controls[0].title, "Mahamudra") + XCTAssertEqual(snapshot.controls[0].subtitle, "Lama Ole") + XCTAssertEqual(snapshot.controls[0].elapsed, "1:09:29") + XCTAssertEqual(snapshot.controls[0].duration, "3:04:05") + XCTAssertEqual(snapshot.controls[0].progress, 0.4) + payload["progress"] = 0 + XCTAssertEqual(try decode([payload]).controls[0].progress, 0) + payload["items"] = [] as [JSObject] + XCTAssertFalse(try decode([payload]).isValid) + payload["items"] = [ + item(["id": "play", "label": "Pause", "selected": true]), + item(["id": "artwork", "label": "Artwork", "selected": false]), + ] as [JSObject] + XCTAssertTrue(try decode([payload]).isValid) + XCTAssertEqual(try decode([payload]).controls[0].items.count, 2) + payload["items"] = [ + item(["id": "play", "label": "Pause", "selected": true]), + item(["id": "artwork", "label": "Artwork", "selected": false]), + item(["id": "extra", "label": "Extra", "selected": false]), + ] as [JSObject] + XCTAssertFalse(try decode([payload]).isValid) + } + + @MainActor + func testTabAccessoryContentCentersInCompactPlatter() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UITabAccessory") } + func artworkFrame(in root: UIView) throws -> CGRect { + func find(_ view: UIView) -> UIView? { + if view.accessibilityLabel == "Artwork" { return view } + return view.subviews.lazy.compactMap(find).first + } + let art = try XCTUnwrap(find(root)) + return art.convert(art.bounds, to: root) + } + let node = try XCTUnwrap(decode([control([ + "kind": "ion-toolbar", + "title": "Now Playing", + "subtitle": "Demo Artist", + "items": [item(["id": "play", "label": "Pause", "selected": true])], + ])]).controls.first) + let view = ShellTabAccessoryContentView(frame: CGRect(x: 0, y: 0, width: 358, height: 42)) + view.apply(node) + view.layoutIfNeeded() + var frame = try artworkFrame(in: view) + XCTAssertEqual(frame.minY, view.bounds.maxY - frame.maxY, accuracy: 1) + XCTAssertLessThanOrEqual(frame.height, 34) + XCTAssertGreaterThan(frame.height, 20) + view.frame.size.height = 64 + view.setNeedsLayout() + view.layoutIfNeeded() + frame = try artworkFrame(in: view) + XCTAssertEqual(frame.height, 36, accuracy: 0.5) + XCTAssertEqual(frame.minY, view.bounds.maxY - frame.maxY, accuracy: 1) + } + } diff --git a/package.json b/package.json index 811b8df5..ca0ff3b3 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,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:css && npm run build:ts", "prepublishOnly": "npm run build", "test:esm": "rdlabo-check-esm" }, diff --git a/screenshots/mini-player/ios18-capsule.png b/screenshots/mini-player/ios18-capsule.png new file mode 100644 index 00000000..38253628 Binary files /dev/null and b/screenshots/mini-player/ios18-capsule.png differ diff --git a/screenshots/mini-player/ios26-capsule.png b/screenshots/mini-player/ios26-capsule.png new file mode 100644 index 00000000..22cdf470 Binary files /dev/null and b/screenshots/mini-player/ios26-capsule.png differ diff --git a/screenshots/mini-player/ios26-classic.png b/screenshots/mini-player/ios26-classic.png new file mode 100644 index 00000000..eb8b2745 Binary files /dev/null and b/screenshots/mini-player/ios26-classic.png differ diff --git a/screenshots/mini-player/webkit-capsule.png b/screenshots/mini-player/webkit-capsule.png new file mode 100644 index 00000000..b50eed4f Binary files /dev/null and b/screenshots/mini-player/webkit-capsule.png differ diff --git a/screenshots/mini-player/webkit-classic.png b/screenshots/mini-player/webkit-classic.png new file mode 100644 index 00000000..2891bd9a Binary files /dev/null and b/screenshots/mini-player/webkit-classic.png differ diff --git a/src/index.ts b/src/index.ts index fb1783f6..e70375a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { iosPopoverLeaveAnimation as popoverLeaveAnimation, } from '@rdlabo/ionic-theme-utils'; export * from './tab-bar-searchable'; +export * from './tab-accessory'; export * from './searchbar'; export * from './transition/ios.transition'; export { withNativeUIShellTransition } from './native-integration/transition'; diff --git a/src/native/components/index.ts b/src/native/components/index.ts index 6f0388e4..523ca85e 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -4,13 +4,14 @@ import * as buttons from './ion-buttons'; import * as backButton from './ion-back-button'; import * as menuButton from './ion-menu-button'; import * as tabBar from './ion-tab-bar'; +import * as tabAccessory from './ion-tab-accessory'; import * as segment from './ion-segment'; import * as fab from './ion-fab'; import { isDisabledButtonGroupChild, isVerticalBarsSource, visible, withoutPrehide } from '../shared/dom'; import type { Candidate, Identify } from '../shared/candidate'; // Static composition only. Each component declares its own tag, discovery and reader. -export const components = [button, buttons, backButton, menuButton, tabBar, segment, fab] as const; +export const components = [button, buttons, backButton, menuButton, tabBar, tabAccessory, segment, fab] as const; export type NativeUIShellComponent = (typeof components)[number]['tag']; export const selector = components .map((component) => ('selector' in component ? component.selector : component.tag)) @@ -32,22 +33,24 @@ export const isVerticalBarsCandidate = isVerticalBarsSource; export const readCandidate = (element: HTMLElement, id: Identify, options: VerticalControlAreaOptions = {}): Candidate | undefined => { const verticalBars = isVerticalBarsCandidate(element); + const tabAccessory = element.matches('ion-toolbar.tab-accessory, ion-toolbar.ios-theme-tab-accessory'); if ( - (!element.classList.contains('ios') && !verticalBars) || - !visible(element, verticalBars) || + (!element.classList.contains('ios') && !verticalBars && !tabAccessory) || + !visible(element, verticalBars || tabAccessory) || element.closest('ion-popover') || (element.closest('ion-modal') && !verticalBars) ) return; const style = getComputedStyle(element); if ( + !tabAccessory && !isDisabledButtonGroupChild(element) && !style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && !style.getPropertyValue('--ios26-glass-background-rgb').trim() && !verticalBars ) return; - if (element.contains(element.ownerDocument.activeElement)) return; + if (!tabAccessory && element.contains(element.ownerDocument.activeElement)) return; // Read icon and control geometry before restoring prehide, which can collapse the toolbar. return withoutPrehide(element, () => components.find((component) => component.tag === element.localName)?.read(element, id, options)); }; diff --git a/src/native/components/ion-tab-accessory.ts b/src/native/components/ion-tab-accessory.ts new file mode 100644 index 00000000..13543f72 --- /dev/null +++ b/src/native/components/ion-tab-accessory.ts @@ -0,0 +1,114 @@ +import { createCandidate } from '../shared/candidate'; +import type { Candidate, Identify } from '../shared/candidate'; +import { frame, text } from '../shared/dom'; + +export const tag = 'ion-toolbar'; +export const selector = 'ion-toolbar.tab-accessory, ion-toolbar.ios-theme-tab-accessory'; +export const shadowSelector = 'ion-button, ion-label, ion-progress-bar, img, [data-tab-accessory="artwork"], [data-tab-accessory="elapsed"], [data-tab-accessory="duration"]'; +export const tracksMotion = false; + +const playSelector = 'ion-button[data-tab-accessory="play"], ion-button[slot="end"]'; + +const parseProgress = (element: HTMLElement): number | undefined => { + const bar = element.querySelector('ion-progress-bar, [data-tab-accessory="progress"]'); + if (bar) { + const attr = bar.getAttribute('value') ?? (bar as HTMLElement & { value?: number }).value; + const numeric = typeof attr === 'number' ? attr : parseFloat(String(attr ?? '')); + if (Number.isFinite(numeric)) return Math.round(numeric * 100) / 100; + } + const css = parseFloat(getComputedStyle(element).getPropertyValue('--progress')); + return Number.isFinite(css) ? css : undefined; +}; + +const progressColor = (element: HTMLElement): string | undefined => { + const bar = element.querySelector('ion-progress-bar, [data-tab-accessory="progress"]'); + if (!bar) return; + const style = getComputedStyle(bar); + const value = style.getPropertyValue('--progress-background').trim() || style.color; + return value || undefined; +}; + +export const read = (element: HTMLElement, id: Identify): Candidate | undefined => { + if (!element.classList.contains('tab-accessory') && !element.classList.contains('ios-theme-tab-accessory')) return; + if (element.closest('ion-content, ion-modal, ion-popover, ion-menu')) return; + const play = element.querySelector(playSelector); + if (!play) return; + + const candidate = createCandidate(element, tag, id); + const iconName = play.querySelector('ion-icon')?.getAttribute('name') ?? ''; + const selected = /pause/i.test(iconName); + const label = play.getAttribute('aria-label')?.trim() || text(play) || (selected ? 'Pause' : 'Play'); + const playId = id(play); + const playRect = play.getBoundingClientRect(); + const hostRect = element.getBoundingClientRect(); + const playFrame = + playRect.width > 0 && playRect.height > 0 + ? frame(playRect, hostRect) + : { x: Math.max(0, hostRect.width - 44), y: Math.max(0, (hostRect.height - 44) / 2), width: 44, height: 44 }; + candidate.control.items.push({ + id: playId, + ...playFrame, + label, + accessibilityLabel: play.getAttribute('aria-label') ?? label, + disabled: false, + selected, + fontSize: 17, + fontWeight: 400, + color: 'currentColor', + }); + candidate.actions.set(playId, play); + candidate.actions.set(candidate.control.id, element); + + const artworkEl = + element.querySelector('[data-tab-accessory="artwork"]') ?? + element.querySelector('[slot="start"]:not(ion-buttons)'); + if (artworkEl) { + const artworkRect = artworkEl.getBoundingClientRect(); + const artworkId = id(artworkEl); + const artworkFrame = + artworkRect.width > 0 && artworkRect.height > 0 + ? frame(artworkRect, hostRect) + : { x: 12, y: Math.max(0, (hostRect.height - 36) / 2), width: 36, height: 36 }; + candidate.control.items.push({ + id: artworkId, + ...artworkFrame, + label: artworkEl.getAttribute('aria-label')?.trim() || 'Artwork', + accessibilityLabel: artworkEl.getAttribute('aria-label') ?? 'Artwork', + disabled: false, + selected: false, + fontSize: 17, + fontWeight: 400, + color: 'currentColor', + }); + candidate.actions.set(artworkId, artworkEl); + } + + const title = + element.querySelector('[data-tab-accessory="title"]')?.textContent?.trim() || + element.querySelector('ion-label h2')?.textContent?.trim() || + text(element.querySelector('ion-label') ?? element); + const subtitle = + element.querySelector('[data-tab-accessory="subtitle"]')?.textContent?.trim() || + element.querySelector('ion-label p')?.textContent?.trim() || + undefined; + const artwork = + (element.querySelector('[data-tab-accessory="artwork"]') as HTMLImageElement | null)?.currentSrc || + (element.querySelector('[data-tab-accessory="artwork"]') as HTMLImageElement | null)?.src || + (element.querySelector('ion-thumbnail img, img') as HTMLImageElement | null)?.currentSrc || + (element.querySelector('ion-thumbnail img, img') as HTMLImageElement | null)?.src || + undefined; + + const elapsed = element.querySelector('[data-tab-accessory="elapsed"]')?.textContent?.trim() || undefined; + const duration = element.querySelector('[data-tab-accessory="duration"]')?.textContent?.trim() || undefined; + + if (title) candidate.control.title = title; + if (subtitle) candidate.control.subtitle = subtitle; + if (artwork) candidate.control.artworkUrl = artwork; + if (elapsed) candidate.control.elapsed = elapsed; + if (duration) candidate.control.duration = duration; + const progress = parseProgress(element); + if (progress !== undefined) candidate.control.progress = progress; + const color = progressColor(element); + if (color) candidate.control.progressColor = color; + return candidate; +}; diff --git a/src/native/definitions.ts b/src/native/definitions.ts index f36e5601..3457fc8a 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -134,6 +134,16 @@ export interface ShellControl extends Frame { rtl: boolean; tabBarAnchor?: { x: 0 | 0.5 | 1; y: 0 | 1 }; search?: ShellSearch; + /** Mini-player title projected from `ion-toolbar.tab-accessory`. */ + title?: string; + subtitle?: string; + artworkUrl?: string; + /** Playback progress 0–1. Omit or pass a negative value to hide the bar. */ + progress?: number; + progressColor?: string; + /** Elapsed / duration from `[data-tab-accessory="time"]`. */ + elapsed?: string; + duration?: string; } export interface ShellSearch { diff --git a/src/native/runtime.ts b/src/native/runtime.ts index aeb2d7f3..b2213753 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -94,7 +94,7 @@ export const createRuntime = async ( let searchListener: PluginListenerHandle | undefined; let waiters: (() => void)[] = []; const control = (kind: Candidate['control']['kind']): keyof NonNullable => { - if (kind === 'ion-tab-bar') return 'tabs'; + if (kind === 'ion-tab-bar' || kind === 'ion-toolbar') return 'tabs'; if (kind === 'ion-segment') return 'segment'; if (kind === 'ion-fab') return 'fab'; return 'toolbar'; @@ -206,7 +206,7 @@ export const createRuntime = async ( (!isVerticalBarsSource(element) && (Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || Array.from(pages).some((scope) => scope.contains(element)) || - Array.from(moving.keys()).some((surface) => surface.contains(element)))); + Array.from(moving.keys()).some((surface) => surface !== element && surface.contains(element)))); const painted = () => new Promise((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))); const overlayOpen = (includeMenu = true, allowModal = false) => { const modal = topModal(doc); @@ -718,9 +718,12 @@ export const createRuntime = async ( const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner)); const candidate = direct || read().find((candidate) => candidate.actions.has(event.id)); const item = candidate?.control.items.find((item) => item.id === event.id); + // Tab accessory registers the toolbar host id as an action with no matching item. + const hostAction = candidate?.control.id === event.id; const searchAction = candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); - if (!searchAction && (!item || item.disabled || item.visible === false)) return; + if (!searchAction && item && (item.disabled || item.visible === false)) return; + if (!searchAction && !hostAction && !item) return; // The original Ionic host owns form submission, routerLink and selection events. activateProjectedElement(element); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. diff --git a/src/segment/index.ts b/src/segment/index.ts index b245fc6e..798f7b48 100644 --- a/src/segment/index.ts +++ b/src/segment/index.ts @@ -45,11 +45,20 @@ const releaseFrames = [ ]; /** Visual-only enhancement: Ionic retains ownership of selection, gestures and events. */ +const supportsIos27SegmentMotion = (): boolean => + typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('overflow-anchor: auto'); + export const registerSegmentEffect = (targetElement: HTMLElement): registeredEffect | undefined => { const segment = targetElement as HTMLIonSegmentElement; const doc = segment.ownerDocument; const win = doc.defaultView; - if (!segment.classList.contains('ios') || !win || segment.matches('.ios27-enable-gesture, .ios-theme-disabled, .ios26-disabled')) + // iOS 26 CSS (no overflow-anchor) must not run the iOS 27 host scale — it stretches full-width bars. + if ( + !segment.classList.contains('ios') || + !win || + !supportsIos27SegmentMotion() || + segment.matches('.ios27-enable-gesture, .ios-theme-disabled, .ios26-disabled') + ) return undefined; const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)'); if (reducedMotion.matches) return undefined; diff --git a/src/styles/components/ion-segment.scss b/src/styles/components/ion-segment.scss index 3cbe2817..9d65333b 100644 --- a/src/styles/components/ion-segment.scss +++ b/src/styles/components/ion-segment.scss @@ -32,53 +32,57 @@ ion-segment.ios:not(.ios-theme-disabled, .ios26-disabled) { } // UIKit uses a larger control in navigation bars, but not in content. + // Host scale is iOS 27 only (overflow-anchor). iOS 26 CSS keeps a stable bar; lens deforms. &.in-toolbar:not(.segment-expand) { --border-radius: 24px; min-height: 48px; - // UIKit's container spring: grow on press, then briefly shrink below rest on release. - transition: transform 517ms - linear( - 0, - 0.1367 6.38%, - 0.4112 12.96%, - 0.6945 19.34%, - 0.9159 25.73%, - 1 29.01%, - 1.0912 35.4%, - 1.1213 41.97%, - 1.1089 48.36%, - 1.0584 58.03%, - 1 70.99%, - 0.9858 83.75%, - 1 - ); - &:not(.segment-disabled):is( - .ios27-segment-pressed, - :not(.ios27-enable-gesture):is(.segment-activated, :has(ion-segment-button.ion-activated)) - ) { - transform: scale(var(--ios27-segment-scale)); - transition: transform 367ms + ion-segment-button { + min-height: 44px; + margin: 2px 0; + font-size: 0.94rem; + } + + @supports (overflow-anchor: auto) { + // UIKit's container spring: grow on press, then briefly shrink below rest on release. + transition: transform 517ms linear( 0, - 0.1656 8.99%, - 0.4905 18.26%, - 0.7836 27.25%, - 0.974 36.24%, - 1.0625 45.5%, - 1.0802 54.5%, - 1.0634 63.49%, - 1.0368 72.75%, - 1.0147 81.74%, - 1.0005 90.74%, + 0.1367 6.38%, + 0.4112 12.96%, + 0.6945 19.34%, + 0.9159 25.73%, + 1 29.01%, + 1.0912 35.4%, + 1.1213 41.97%, + 1.1089 48.36%, + 1.0584 58.03%, + 1 70.99%, + 0.9858 83.75%, 1 ); - } - ion-segment-button { - min-height: 44px; - margin: 2px 0; - font-size: 0.94rem; + &:not(.segment-disabled):is( + .ios27-segment-pressed, + :not(.ios27-enable-gesture):is(.segment-activated, :has(ion-segment-button.ion-activated)) + ) { + transform: scale(var(--ios27-segment-scale)); + transition: transform 367ms + linear( + 0, + 0.1656 8.99%, + 0.4905 18.26%, + 0.7836 27.25%, + 0.974 36.24%, + 1.0625 45.5%, + 1.0802 54.5%, + 1.0634 63.49%, + 1.0368 72.75%, + 1.0147 81.74%, + 1.0005 90.74%, + 1 + ); + } } } diff --git a/src/styles/components/ion-tab-accessory.scss b/src/styles/components/ion-tab-accessory.scss new file mode 100644 index 00000000..da8b16ba --- /dev/null +++ b/src/styles/components/ion-tab-accessory.scss @@ -0,0 +1,287 @@ +@use '../utils/api'; + +ion-toolbar.ios.tab-accessory:not(.ios-theme-disabled, .ios26-disabled), +ion-toolbar.ios.ios-theme-tab-accessory:not(.ios-theme-disabled, .ios26-disabled) { + --ios-theme-tab-accessory-radius: 28px; + // Web fallback only. Native UITabAccessory owns platter height (~42pt on iOS 26). + --ios-theme-tab-accessory-height: 64px; + --ios-theme-tab-accessory-padding-block: 8px; + --ios-theme-tab-accessory-inset: 16px; + --ios-theme-tab-accessory-thumb-size: 36px; + --ios-theme-tab-accessory-thumb-radius: 6px; + --ios-theme-tab-accessory-bottom: calc( + 62px + 8px + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom, env(safe-area-inset-bottom, 0px))) + ); + --ios-theme-glass-background-rgb: var(--ios26-glass-background-rgb, 255, 255, 255); + --background: rgba(var(--ios-theme-glass-background-rgb, 255, 255, 255), 0.53); + --border-width: 0; + --padding-start: 12px; + --padding-end: 4px; + --padding-top: var(--ios-theme-tab-accessory-padding-block, 8px); + --padding-bottom: var(--ios-theme-tab-accessory-padding-block, 8px); + --min-height: 0; + display: flex; + align-items: center; + position: absolute; + left: calc(var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-left, 0px)); + right: calc(var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-right, 0px)); + margin-inline: auto; + bottom: var(--ios-theme-tab-accessory-bottom); + width: calc( + 100% - (var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-left, 0px)) - + (var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-right, 0px)) + ); + max-width: 471px; + height: var(--ios-theme-tab-accessory-height); + min-height: var(--ios-theme-tab-accessory-height); + contain: layout style; + border-radius: var(--ios-theme-tab-accessory-radius); + overflow: hidden; + z-index: 3; + pointer-events: auto; + + &.tab-bar-hidden { + display: none; + } + + &::part(background) { + background: var(--background); + backdrop-filter: blur(6px) saturate(180%) brightness(1.05); + 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(--ios-theme-tab-accessory-padding-block, 8px); + padding-bottom: var(--ios-theme-tab-accessory-padding-block, 8px); + } + + ion-thumbnail, + [slot='start']:not(ion-buttons) { + --size: var(--ios-theme-tab-accessory-thumb-size); + --border-radius: var(--ios-theme-tab-accessory-thumb-radius); + width: var(--ios-theme-tab-accessory-thumb-size); + height: var(--ios-theme-tab-accessory-thumb-size); + min-width: var(--ios-theme-tab-accessory-thumb-size); + min-height: var(--ios-theme-tab-accessory-thumb-size); + flex-shrink: 0; + overflow: hidden; + border-radius: var(--ios-theme-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(--ios-theme-tab-accessory-thumb-size); + --border-radius: var(--ios-theme-tab-accessory-thumb-radius); + width: 100%; + height: 100%; + margin: 0; + border-radius: inherit; + } + + ion-label { + display: flex !important; + flex-direction: column; + justify-content: center; + box-sizing: border-box; + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + margin: 0; + margin-inline-end: 8px; + align-self: stretch; + height: 100%; + padding-block: var(--ios-theme-tab-accessory-padding-block, 8px); + overflow: hidden; + h2 { + font-size: 0.875rem; + font-weight: 600; + 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: 600; + } + + [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); + } + } + + &.ios-theme-tab-accessory-classic { + --ios-theme-tab-accessory-radius: 12px; + --ios-theme-tab-accessory-inset: 8px; + --background: rgba(var(--ios-theme-glass-background-rgb, var(--ion-background-color-rgb, 255, 255, 255)), 0.72); + + &::part(background) { + box-shadow: inset 0 0 0 0.5px rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.12); + } + } +} + +html.ios .accessory-slide-clip { + --ios-theme-tab-accessory-radius: 28px; + --ios-theme-tab-accessory-height: 64px; + --ios-theme-tab-accessory-padding-block: 8px; + --ios-theme-tab-accessory-inset: 16px; + --ios-theme-tab-accessory-bottom: calc( + 62px + 8px + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom, env(safe-area-inset-bottom, 0px))) + ); + position: absolute; + left: calc(var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-left, 0px)); + right: calc(var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-right, 0px)); + margin-inline: auto; + bottom: var(--ios-theme-tab-accessory-bottom); + width: calc( + 100% - (var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-left, 0px)) - + (var(--ios-theme-tab-accessory-inset) + var(--ion-safe-area-right, 0px)) + ); + max-width: 471px; + height: var(--ios-theme-tab-accessory-height); + min-height: var(--ios-theme-tab-accessory-height); + contain: layout style; + border-radius: var(--ios-theme-tab-accessory-radius); + overflow: hidden; + z-index: 3; + pointer-events: auto; + + &.keyboardOpen, + &.tab-bar-hidden { + display: none; + } + + &.ios-theme-tab-accessory-classic { + --ios-theme-tab-accessory-radius: 12px; + --ios-theme-tab-accessory-inset: 8px; + } + + ion-toolbar.ios.tab-accessory:not(.ios-theme-disabled, .ios26-disabled), + ion-toolbar.ios.ios-theme-tab-accessory:not(.ios-theme-disabled, .ios26-disabled) { + position: relative; + left: auto; + right: auto; + bottom: auto; + width: 100%; + max-width: none; + margin-inline: 0; + height: 100%; + min-height: 100%; + } +} + +// In-flow iOS 15–17.3 tab bar is ~50px, not the 62px content-box island. +@supports not (text-wrap: pretty) { + ion-toolbar.ios.tab-accessory.ios-theme-tab-accessory-classic:not(.ios-theme-disabled, .ios26-disabled), + ion-toolbar.ios.ios-theme-tab-accessory.ios-theme-tab-accessory-classic:not(.ios-theme-disabled, .ios26-disabled), + html.ios .accessory-slide-clip.ios-theme-tab-accessory-classic { + --ios-theme-tab-accessory-bottom: calc(50px + 8px + env(safe-area-inset-bottom, 0px)); + } +} diff --git a/src/styles/ionic-theme-ios27.scss b/src/styles/ionic-theme-ios27.scss index f3010bab..9923adf7 100644 --- a/src/styles/ionic-theme-ios27.scss +++ b/src/styles/ionic-theme-ios27.scss @@ -18,6 +18,7 @@ @use 'components/ion-searchbar'; @use 'components/ion-segment'; @use 'components/ion-tabs'; +@use 'components/ion-tab-accessory'; @use 'components/ion-toast'; @use 'components/ion-toggle'; @use 'components/ion-toolbar'; diff --git a/src/tab-accessory/index.ts b/src/tab-accessory/index.ts new file mode 100644 index 00000000..ac8cd871 --- /dev/null +++ b/src/tab-accessory/index.ts @@ -0,0 +1,182 @@ +import { createGesture } from '@ionic/core'; +import type { Gesture, GestureDetail } from '@ionic/core'; +import { isNativeUIShell } from '../native-integration'; +import type { registeredEffect } from '../sheets-of-glass/interfaces'; + +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 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/classic tab accessory: follow-the-finger lift + fade, then activate + * the host toolbar (click). Matches native `onTap`. Skips Native UI Shell hosts. + * Put `.tab-accessory-lift` or `.ios-theme-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` (or `.ios-theme-tab-accessory-panning-x`) + * while they own a horizontal gesture so swipe-up does not steal it. + */ +export const attachTabAccessory = (toolbar: HTMLElement, options?: TabAccessoryOptions): registeredEffect | undefined => { + if (!toolbar.matches(TAB_ACCESSORY_SELECTOR) || isNativeUIShell(toolbar)) 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()) || isNativeUIShell(toolbar); + + 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 the `ios-theme-tab-accessory` alias) and attaches swipe-up. Call once at app start. */ +export const enableTabAccessory = (root?: ParentNode): registeredEffect => { + 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 || isNativeUIShell(toolbar)) { + effect.destroy(); + live.delete(toolbar); + } + } + for (const toolbar of Array.from(scope.querySelectorAll(TAB_ACCESSORY_SELECTOR))) { + if (live.has(toolbar) || isNativeUIShell(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', 'data-native-ui-shell'], + }); + sync(); + + const handle: registeredEffect = { + destroy: () => { + observer.disconnect(); + for (const effect of live.values()) effect.destroy(); + live.clear(); + enabled.delete(scope); + }, + }; + enabled.set(scope, handle); + return handle; +};