Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions demo/.browserslistrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
Chrome >=107
Firefox >=106
Edge >=107
Safari >=16.1
iOS >=16.1
Safari >=15
iOS >=15
65 changes: 65 additions & 0 deletions demo/e2e/tab-accessory.spec.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
});
});
10 changes: 10 additions & 0 deletions demo/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
25 changes: 25 additions & 0 deletions demo/src/app/tabs/tabs.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@

<section id="main-content">
<ion-tabs>
<ion-toolbar
class="ios tab-accessory ios-theme-tab-accessory"
mode="ios"
[class.tab-bar-hidden]="!showAccessory"
[class.ios-theme-tab-accessory-classic]="classicAccessory"
(click)="onAccessoryActivate()"
>
<ion-thumbnail slot="start">
<img data-tab-accessory="artwork" src="https://picsum.photos/128/128?id=12" alt="" />
</ion-thumbnail>
<ion-label>
<h2 data-tab-accessory="title">Now Playing</h2>
<p data-tab-accessory="subtitle">Demo Artist</p>
</ion-label>
<ion-buttons slot="end">
<span data-tab-accessory="time">
<span data-tab-accessory="elapsed">1:24</span>
<span data-tab-accessory="duration">3:45</span>
</span>
<ion-button data-tab-accessory="play" fill="clear" (click)="togglePlay($event)" [attr.aria-label]="playing ? 'Pause' : 'Play'">
<ion-icon slot="icon-only" [name]="playing ? 'pause' : 'play'" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
<ion-progress-bar value="0.4"></ion-progress-bar>
</ion-toolbar>
<ion-tab-bar id="tab-bar-bottom" slot="bottom">
<ion-tab-button tab="index" href="/main/index">
<ion-icon aria-hidden="true" name="documents"></ion-icon>
Expand Down
30 changes: 30 additions & 0 deletions demo/src/app/tabs/tabs.page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
33 changes: 31 additions & 2 deletions demo/src/app/tabs/tabs.page.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core';
import {
IonButton,
IonButtons,
IonContent,
IonIcon,
IonItem,
IonItemGroup,
IonLabel,
IonList,
IonMenu,
IonProgressBar,
IonSplitPane,
IonTabBar,
IonTabButton,
IonTabs,
IonThumbnail,
IonToolbar,
ViewDidEnter,
ViewDidLeave,
} from '@demo/ionic';
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';
Expand All @@ -39,6 +44,11 @@ import { Capacitor } from '@capacitor/core';
IonList,
IonItem,
IonItemGroup,
IonToolbar,
IonThumbnail,
IonButton,
IonButtons,
IonProgressBar,
RouterLink,
],
})
Expand All @@ -49,6 +59,10 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter,
#hingeListener?: { remove(): Promise<void> };
readonly #destroyRef = inject(DestroyRef);
readonly registeredGestures: registeredEffect[] = [];
playing = true;
showAccessory = false;
classicAccessory = false;
accessoryActivated = false;
ngOnInit() {
this.#router.events
.pipe(
Expand All @@ -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'));
});
}

Expand Down Expand Up @@ -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<HTMLElement>('ion-tab-bar')!);
if (registerGesture) {
this.registeredGestures.push(registerGesture);
}
this.registeredGestures.push(enableTabAccessory());
}

ionViewDidLeave() {
Expand Down
14 changes: 14 additions & 0 deletions demo/src/glass-mixins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
3 changes: 3 additions & 0 deletions demo/src/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading