Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
37381e9
feat(demo): add foldable mode toggle
rdlabo Sep 22, 2026
aa568f7
feat(layout): add physical side safe areas
rdlabo Sep 21, 2026
addb1bd
test: cover physical safe areas visually
rdlabo Sep 21, 2026
d5027ae
test: cover safe area boundaries
rdlabo Sep 21, 2026
d230215
test: verify overlay safe area inheritance
rdlabo Sep 21, 2026
202a96d
fix: preserve RTL inset item spacing
rdlabo Sep 21, 2026
f54efab
Revert "fix: preserve RTL inset item spacing"
rdlabo Sep 21, 2026
8fed1c0
test: use CI screenshot baselines
rdlabo Sep 21, 2026
56c4d9e
test: refresh RTL safe area snapshot
rdlabo Sep 21, 2026
5a72121
docs: describe foldable safe area layout
rdlabo Sep 21, 2026
45c13e5
refactor: scope foldable opt-in class
rdlabo Sep 21, 2026
02c0d7c
feat: add Duo foldable safe area defaults
rdlabo Sep 21, 2026
23cf96a
test: refresh foldable layout snapshots for toggle
rdlabo Sep 22, 2026
c714d1a
feat(tabs): add explicit adaptive vertical placement
rdlabo Sep 21, 2026
58033c6
fix(tabs): preserve foldable rail beside split pane
rdlabo Sep 21, 2026
16e1109
test: refresh foldable split snapshots for toggle
rdlabo Sep 22, 2026
e94839a
fix(tabs): derive foldable rail from app mode
rdlabo Sep 22, 2026
0823996
fix(tabs): preserve overlay tab ownership
rdlabo Sep 22, 2026
39ff308
fix(tabs): show the selected foldable label
rdlabo Sep 22, 2026
566ab56
fix(tabs): match SwiftUI foldable rail metrics
rdlabo Sep 22, 2026
4e22481
feat: project foldable back navigation
rdlabo Sep 21, 2026
10f0a40
fix: align foldable projection eligibility
rdlabo Sep 21, 2026
98f230b
fix: guard active foldable toolbar actions
rdlabo Sep 22, 2026
d041b69
test: refresh SwiftUI-matched foldable rail
rdlabo Sep 22, 2026
e67caac
fix: match foldable toolbar control metrics
rdlabo Sep 22, 2026
df6b3e5
fix: center projected toolbar icons
rdlabo Sep 22, 2026
bd5cc74
fix: mirror native foldable toolbar grouping
rdlabo Sep 22, 2026
b08adcb
test: refresh foldable tab drag projection
rdlabo Sep 22, 2026
8dcad24
fix: center foldable toolbar icon frames
rdlabo Sep 22, 2026
9db6845
fix: hide emptied toolbar button groups
rdlabo Sep 22, 2026
6b78fba
fix: project disabled button groups individually
rdlabo Sep 22, 2026
7451754
feat(native): project foldable controls with SwiftUI (#214)
rdlabo Sep 24, 2026
8e473bc
feat: name adaptive side controls vertical bars
rdlabo Sep 24, 2026
494436f
feat: expose standalone vertical control area
rdlabo Sep 24, 2026
57e308f
test: ignore local Ionic Conference App integration
rdlabo Sep 24, 2026
c82bab5
fix(vertical-bars): project back buttons outside fixed toolbars
rdlabo Sep 24, 2026
54c7df8
fix(vertical-bars): align back selection across Web and native
rdlabo Sep 24, 2026
a93c8be
feat(vertical-bars): support Ionic md mode
rdlabo Sep 24, 2026
5124c39
docs(vertical-bars): show app-owned platform opt-in
rdlabo Sep 24, 2026
93ee5af
test(vertical-bars): focus standalone coverage on unique contracts
rdlabo Sep 24, 2026
5aa04f1
feat(vertical-bars): expose native placement with web fallback
rdlabo Sep 24, 2026
739e4db
fix(vertical-bars): require ion-app root and guard shell configuration
rdlabo Sep 24, 2026
db3fc68
fix(demo): follow Duo vertical bar edge
rdlabo Sep 24, 2026
18ef95c
fix(vertical-bars): use measured native safe-area inset
rdlabo Sep 24, 2026
ca99f04
feat(vertical-bars): unify device layout reporting and Duo hinge support
rdlabo Sep 25, 2026
f8a4888
fix(vertical-bars): let expanded tab rail draw and receive touches ov…
rdlabo Sep 25, 2026
2453f43
fix(vertical-bars): skip inset padding on collapse toolbars
rdlabo Sep 25, 2026
7b7524b
refactor(vertical-bars): consolidate shell lifecycle and modernize e2…
rdlabo Sep 25, 2026
4b0ed35
refactor(vertical-bars): tighten native plugin types
rdlabo Sep 25, 2026
6854516
docs: add a dedicated iPhone Duo guide
rdlabo Sep 25, 2026
81908a7
Merge pull request #220 from rdlabo-dev/feat/standalone-vertical-bars
rdlabo Sep 25, 2026
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
56 changes: 56 additions & 0 deletions demo/e2e/adaptive-tab-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test';

const enableVerticalBars = async (page: import('@playwright/test').Page) => {
const toggle = page.getByText('iPhone Duo Mode').locator('..').locator('ion-toggle');
await toggle.click();
await expect(page.locator('ion-app')).toHaveClass(/ios-theme-vertical-bars/);
};

test('verticalBars mode moves tabs into the right rail and reveals labels while dragging', async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 });
await page.goto('/main/index');
await enableVerticalBars(page);

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.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');

const selectedBox = (await buttons.first().boundingBox())!;
const targetBox = (await buttons.nth(1).boundingBox())!;
await page.mouse.move(selectedBox.x + selectedBox.width / 2, selectedBox.y + selectedBox.height / 2);
await page.mouse.down();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 4 });
await expect.poll(() => buttons.first().evaluate((element) => element.matches(':active'))).toBe(true);

await expect(page).toHaveScreenshot('vertical-bars-tab-drag-labels.png', { animations: 'disabled' });
await page.mouse.up();

const overlayDirection = await page.evaluate(async () => {
const menu = document.querySelector('ion-menu')!;
const tabs = document.createElement('ion-tabs');
const overlayBar = document.createElement('ion-tab-bar');
overlayBar.mode = 'ios';
tabs.append(overlayBar);
menu.append(tabs);
await customElements.whenDefined('ion-tab-bar');
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const direction = getComputedStyle(overlayBar).flexDirection;
tabs.remove();
return direction;
});
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 19 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 Expand Up @@ -104,6 +105,17 @@ test.describe('Animation Tests', () => {
await expect(shade).toHaveCount(0);
});

test('verticalBars page transition keeps its shade outside the control rail', async ({ page }) => {
await page.goto('/main/index', { waitUntil: 'networkidle' });
await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars'));
await page.getByRole('button', { name: 'button', exact: true }).click();
const shade = page.locator('.ios-transition-shade');
await expect(shade).toBeVisible();
await page.evaluate(() => document.getAnimations().forEach((animation) => animation.pause()));
await expect(shade).toHaveCSS('clip-path', 'inset(0px 80px 0px 0px)');
await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play()));
});

test('runs and completes the iOS popover animations', async ({ page }) => {
await page.goto('/main/index/popover', { waitUntil: 'networkidle' });
await clearAnimationCalls(page);
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