Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
dist/
/integration/ionic-conference-app/
# Generated by @rdlabo/ionic-theme-utils before Sass compilation.
/src/styles/utils/structured-list.scss

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ Use this markup to preview the inset grouped list look. For the list structure t

## Optional setups

### Support iPhone Duo without the iOS 27 theme

Import only `@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css`, add `ios-theme-vertical-bars` to `ion-app`, and call `enableVerticalControlArea()` from `@rdlabo/ionic-theme-ios27/vertical-bars` at startup. This uses Ionic's standard appearance outside the Vertical Control Area; on supported iOS, only controls moved into that area are projected natively. To read only the hinge posture — for example to adapt an `ion-split-pane` — subscribe to the plugin's `deviceLayoutChange` event without starting any runtime. See [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) for the complete setup.

### Use only the iOS 27 theme

Install only `@rdlabo/ionic-theme-ios27` and import its styles unconditionally in your global stylesheet:
Expand Down Expand Up @@ -207,6 +211,7 @@ For Ionic 9 Angular, import `isPlatform` and `provideIonicAngular` from `@ionic/
- [ESLint](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/eslint) — check list structure with ESLint rules.
- [Features](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/features) — CSS variables, Liquid Glass, selective imports, and dark mode.
- [Native UI Shell (Experimental)](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell) — project supported Ionic controls, text, and icons into UIKit.
- [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo) — vertical system rail, hinge posture, and split-pane layout; usable without the theme or the shell.
- [Animation](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/animation) — tab, segment, and searchable effects.
- [Migration from iOS 26](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/migration) — upgrade an existing app, including stylesheet, class, and CSS variable changes.
- [iOS 26 migration history](https://docs.rdlabo.dev/projects/ionic-theme-ios26/docs/migration) — earlier major-version changes for the previous package.
Expand Down
12 changes: 11 additions & 1 deletion demo/e2e/adaptive-tab-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ test('verticalBars mode moves tabs into the right rail and reveals labels while

const bar = page.locator('#tab-bar-bottom');
const buttons = bar.locator('ion-tab-button');
await expect.poll(async () => (await bar.boundingBox())?.x).toBeGreaterThan(620);
const barBox = (await bar.boundingBox())!;
expect(barBox.x).toBeGreaterThan(620);
expect(barBox.width).toBeCloseTo(50, 0);
await expect(buttons.first().locator('ion-label')).toHaveCSS('position', 'absolute');
await expect(buttons.nth(1).locator('ion-label')).toHaveCSS('position', 'absolute');
Expand Down Expand Up @@ -44,3 +44,13 @@ test('verticalBars mode moves tabs into the right rail and reveals labels while
});
expect(overlayDirection).toBe('row');
});

test('manual classes place the Web rail on the left in Chrome', async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 });
await page.goto('/main/index');
await page.locator('ion-app').evaluate((root) => root.classList.add('ios-theme-vertical-bars', 'ios-theme-vertical-bars-left'));

const bar = page.locator('#tab-bar-bottom');
await expect.poll(async () => (await bar.boundingBox())?.x).toBeLessThan(35);
await expect(bar).toHaveScreenshot('vertical-bars-left.png', { animations: 'disabled' });
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 8 additions & 7 deletions demo/e2e/animation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { expect, test, type Page } from '@playwright/test';
import type { AnimationCall } from './native-shell-mock';

const installAnimationObserver = async (page: Page) => {
await page.addInitScript(() => {
const originalAnimate = Element.prototype.animate;

(window as any).__IONIC_ANIMATION_CALLS__ = [];
document.__IONIC_ANIMATION_CALLS__ = [];
Element.prototype.animate = function (keyframes, options) {
const animation = originalAnimate.call(this, keyframes, options);
const properties = Array.isArray(keyframes)
Expand All @@ -16,7 +17,7 @@ const installAnimationObserver = async (page: Page) => {
: Object.keys(keyframes ?? {}).filter((key) => !['offset', 'easing', 'composite'].includes(key));
const duration = typeof options === 'number' ? options : typeof options?.duration === 'number' ? options.duration : 0;

(window as any).__IONIC_ANIMATION_CALLS__.push({
document.__IONIC_ANIMATION_CALLS__!.push({
animation,
duration,
properties,
Expand All @@ -30,13 +31,13 @@ const installAnimationObserver = async (page: Page) => {
};

const clearAnimationCalls = async (page: Page) => {
await page.evaluate(() => ((window as any).__IONIC_ANIMATION_CALLS__ = []));
await page.evaluate(() => (document.__IONIC_ANIMATION_CALLS__ = []));
};

const hasRunningAnimation = (page: Page, targetClass?: string) => {
return page.evaluate((expectedClass) => {
return (window as any).__IONIC_ANIMATION_CALLS__.some(
(call: { animation: Animation; duration: number; properties: string[]; targetClass: string }) =>
return document.__IONIC_ANIMATION_CALLS__!.some(
(call: AnimationCall) =>
call.animation.playState === 'running' &&
call.duration > 0 &&
call.properties.includes('transform') &&
Expand All @@ -47,8 +48,8 @@ const hasRunningAnimation = (page: Page, targetClass?: string) => {

const hasAnimationCall = (page: Page, targetClass: string) => {
return page.evaluate((expectedClass) => {
return (window as any).__IONIC_ANIMATION_CALLS__.some(
(call: { duration: number; properties: string[]; targetClass: string }) =>
return document.__IONIC_ANIMATION_CALLS__!.some(
(call: AnimationCall) =>
call.duration > 0 && call.properties.includes('transform') && call.targetClass.split(' ').includes(expectedClass),
);
}, targetClass);
Expand Down
6 changes: 5 additions & 1 deletion demo/e2e/card-opt-out.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ for (const disabled of ['ios-theme-disabled', 'ios26-disabled']) {
return card;
};
const cards = [create(false), create(true)];
await Promise.all(cards.flatMap((card) => [card, ...card.querySelectorAll('*')]).map((el: any) => el.componentOnReady?.()));
await Promise.all(
cards
.flatMap((card) => [card, ...card.querySelectorAll('*')])
.map((el: Element & { componentOnReady?: () => Promise<unknown> }) => el.componentOnReady?.()),
);
const result = cards.map((card) =>
[...card.querySelectorAll(tag)].map((el) => {
const s = getComputedStyle(el);
Expand Down
81 changes: 0 additions & 81 deletions demo/e2e/glass.spec.ts

This file was deleted.

77 changes: 77 additions & 0 deletions demo/e2e/native-shell-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { NativeUIShellHandle, NativeUIShellSuspension, ShellControl, ShellSnapshot } from '../../src/native/definitions';

/**
* State carried by the fake native plugin that `mockNative` installs.
*
* The init script intercepts the `window.Capacitor` assignment during
* `@capacitor/core` initialisation and patches `registerPlugin`, so the app's
* `registerPlugin<NativeUIShellPlugin>('IonicNativeUIShell')` receives the mock
* object itself. Specs reach it the same way the application does:
*
* Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell')
*/
export interface ShellMockCore {
/** update()/clear() snapshots received by the plugin. */
updates: ShellSnapshot[];
/** Monotonic sequence counter for emitted events. */
sequence: number;
/** Listener registry mirroring Capacitor's WebPlugin semantics. */
listeners: Record<string, ((event: never) => void)[]>;
/** Emits a native event to every registered listener. */
notifyListeners(eventName: string, data: unknown): void;
}

export interface AnimationCall {
animation: Animation;
duration: number;
properties: string[];
targetClass: string;
targetTag: string;
}

/** Test probes attached to the demo's <ion-app> element instead of `window`. */
export interface TestAppElement extends HTMLElement {
nativeUIShell?: NativeUIShellHandle;
verticalBarsLease?: NativeUIShellSuspension;
verticalBarsBackButtonReads?: number;
verticalBarsBackCloneMoved?: boolean;
fabClicks?: number;
fabRetired?: boolean;
menuExtra?: boolean;
retiredSearchbar?: HTMLIonSearchbarElement;
searchEvents?: [string, unknown][];
placement?: {
element: Element;
parent: HTMLElement | null;
next: ChildNode | null;
slot: string;
zone: Element;
};
searchPlacement?: {
page: HTMLElement;
footer: Element;
fab: Element;
buttons: Element;
toolbar: Element;
};
}

declare global {
const Capacitor: {
getPlatform(): string;
registerPlugin<T>(name: string, implementations?: Record<string, unknown>): T;
Plugins: Record<string, unknown>;
[key: string]: unknown;
};

interface Window {
Capacitor?: typeof Capacitor;
CapacitorCustomPlatform?: { name: string };
}

interface Document {
/** Set by e2e init scripts to force the app into its animation-free mode. */
IONIC_E2E_TESTING?: boolean;
__IONIC_ANIMATION_CALLS__?: AnimationCall[];
}
}
Loading