Skip to content
Closed
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
55 changes: 55 additions & 0 deletions demo/e2e/native-ui-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,61 @@ test('all demo pages keep projection consistent through consecutive navigation',
expect(errors).toEqual([]);
});

test('a native rail popover hands toolbar controls to Web and returns them after dismissal', async ({ page }) => {
await page.setViewportSize({ width: 440, height: 636 });
await mockNative(page);
await page.goto('/main/index?verticalBarsOnly');
await page.getByText('iPhone Duo Mode', { exact: true }).click();
await page.getByText('popover', { exact: true }).click();
const source = page.locator('#click-trigger-right-buttons ion-button');
await expect(source).toHaveAttribute('data-native-ui-shell', '');
const action = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection');
await expect(action).toHaveCount(0);
const frames = { action: { x: 390, y: 235, width: 38, height: 38 }, back: { x: 390, y: 170, width: 38, height: 38 } };
await page.evaluate((frames) => {
const mock = Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell');
const snapshot = mock.updates.at(-1)!;
const action = snapshot.controls.flatMap((control) => control.items).find((item) => item.accessibilityLabel === 'Open popover')!;
const back = snapshot.controls.find((control) => control.kind === 'ion-back-button')!.items[0];
mock.notifyListeners('activate', {
id: action.id,
revision: snapshot.revision,
sequence: ++mock.sequence,
projectionFrame: frames.action,
projectionFrames: { [action.id]: frames.action, [back.id]: frames.back },
});
}, frames);
const popover = page.locator('ion-popover[trigger="click-trigger-right-buttons"]');
await expect(popover.getByText('Hello World!')).toBeVisible();
await expect(action).toBeVisible();
await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toBeVisible();
await expect.poll(() => action.boundingBox()).toEqual(frames.action);
await expect.poll(() => action.locator('[part~="native"]').boundingBox()).toEqual(frames.action);
await expect
.poll(() => page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection').boundingBox())
.toEqual(frames.back);
await expect(popover.locator('[part~="content"]')).toHaveCSS('opacity', '1');
const content = (await popover.locator('[part~="content"]').boundingBox())!;
expect(content.y + content.height / 2).toBeCloseTo(frames.action.y + frames.action.height / 2, 0);
await expect
.poll(async () => {
const bounds = (await popover.locator('[part~="content"]').boundingBox())!;
return bounds.x + bounds.width;
})
.toBeLessThanOrEqual(frames.action.x + 1);
await expect(source.locator('..')).toBeHidden();
await expect(page.locator('app-popover > ion-header > ion-toolbar').nth(1)).toBeHidden();
await expect
.poll(() => page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.at(-1)!.controls.length))
.toBe(0);
await popover.evaluate((element: HTMLIonPopoverElement) => element.dismiss());
await expect(action).toHaveCount(0);
await expect(source).toHaveAttribute('data-native-ui-shell', '');
await expect
.poll(() => page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.at(-1)!.controls.length))
.toBeGreaterThan(0);
});

test('demo overlay variants retire native controls and restore them after dismissal', async ({ page }) => {
test.setTimeout(180000);
await mockNative(page);
Expand Down
98 changes: 98 additions & 0 deletions demo/e2e/vertical-bars-popover.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { expect, test } from '@playwright/test';

for (const { edge, offset, scale } of [
{ edge: 'left', offset: false, scale: 1 },
{ edge: 'right', offset: false, scale: 1 },
{ edge: 'right', offset: true, scale: 1 },
{ edge: 'right', offset: true, scale: 0.8 },
]) {
for (const reference of ['trigger', 'event']) {
test(`popover uses its ${edge} rail action with ${reference} positioning${offset ? ' in an offset pane' : ''}${scale !== 1 ? ' with scale' : ''}`, async ({
page,
}) => {
await page.setViewportSize({ width: 700, height: 900 });
await page.goto('/main/index/popover');
const popover = page.locator('ion-popover[trigger="click-trigger-right-buttons"]');
await popover.waitFor({ state: 'attached' });
if (offset) {
await page.locator('app-popover').evaluate((element: HTMLElement) => {
element.style.left = '160px';
element.style.top = '40px';
});
}
if (scale !== 1) {
await page.locator('app-popover').evaluate((element: HTMLElement, value) => {
element.style.transformOrigin = '0 0';
element.style.transform = `scale(${value})`;
}, scale);
}
await popover.evaluate((element: HTMLIonPopoverElement, value) => (element.reference = value), reference as 'trigger' | 'event');
await page.locator('ion-app').evaluate((element, value) => {
// Exercise safe-area handling as well as the ordinary zero-inset browser case.
element.ownerDocument.documentElement.style.setProperty('--ion-safe-area-right', '56px');
element.classList.add('ios-theme-vertical-bars');
element.classList.toggle('ios-theme-vertical-bars-left', value === 'left');
}, edge);
const action = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection');
await expect(action).toBeVisible();
const anchor = (await action.boundingBox())!;
await action.click();
await expect(popover).toBeVisible();
await expect(popover.getByText('Hello World!')).toBeVisible();
await expect(popover.locator('[part~="content"]')).toHaveCSS('opacity', '1');
await expect
.poll(() =>
popover.evaluate((element) => {
const content = element.shadowRoot!.querySelector<HTMLElement>('[part~="content"]')!.getBoundingClientRect();
const surface = element.shadowRoot!.querySelector<HTMLElement>('[part~="callout-glass"]')!.getBoundingClientRect();
return Math.abs(content.x - (surface.x + 32));
}),
)
.toBeLessThan(2);
const content = await popover.evaluate((element) =>
element.shadowRoot!.querySelector<HTMLElement>('[part~="content"]')!.getBoundingClientRect().toJSON(),
);
expect(Math.abs(content.y + content.height / 2 - anchor.y - anchor.height / 2)).toBeLessThan(1);
// A scaled pane can clamp the surface inward; its animation must still originate at the rail action.
await expect
.poll(() =>
popover.evaluate((element, anchor) => {
const content = element.shadowRoot!.querySelector<HTMLElement>('[part~="content"]')!;
const [x, y] = getComputedStyle(content).transformOrigin.split(' ').map(parseFloat);
const bounds = content.getBoundingClientRect();
return Math.max(Math.abs(bounds.x + x - anchor.x - anchor.width / 2), Math.abs(bounds.y + y - anchor.y - anchor.height / 2));
}, anchor),
)
.toBeLessThan(1);
if (edge === 'right')
expect(content.x + content.width).toBeLessThanOrEqual(anchor.x + (reference === 'event' ? anchor.width / 2 : 0) + 1);
else expect(content.x).toBeGreaterThanOrEqual(anchor.x + (reference === 'event' ? anchor.width / 2 : anchor.width) - 1);
await popover.evaluate((element: HTMLIonPopoverElement) => element.dismiss());
await expect(action).toBeVisible();
});
}
}

test('a projected action respects source disabled state before its clone resynchronizes', async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 });
await page.goto('/main/index/popover');
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
await expect(page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection')).toBeVisible();
const counts = await page.locator('#click-trigger-right-buttons ion-button').evaluate((source: HTMLIonButtonElement) => {
let clicks = 0;
source.addEventListener('click', () => clicks++);
const clone = document.querySelector<HTMLElement>('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection')!;
source.disabled = true;
clone.click();
const hostDisabled = clicks;
source.disabled = false;
const native = source.shadowRoot!.querySelector<HTMLButtonElement>('[part~="native"]')!;
native.disabled = true;
clone.click();
const nativeDisabled = clicks;
native.disabled = false;
clone.click();
return [hostDisabled, nativeDisabled, clicks];
});
expect(counts).toEqual([0, 0, 1]);
});
10 changes: 5 additions & 5 deletions demo/src/app/index/pages/popover/popover.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
</ion-buttons>
<ion-title>popover</ion-title>
</ion-toolbar>
<ion-toolbar>
<ion-buttons id="click-trigger-right-buttons" slot="end">
<ion-button aria-label="Open popover"><ion-icon name="close-outline" slot="icon-only"></ion-icon></ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true" color="light">
Expand All @@ -23,11 +28,6 @@ <h2>popover</h2>
</ion-item-group>
</ion-list>

<ion-toolbar>
<ion-buttons id="click-trigger-right-buttons" slot="end">
<ion-button><ion-icon name="close-outline" slot="icon-only"></ion-icon></ion-button>
</ion-buttons>
</ion-toolbar>
<ion-list [inset]="true">
<ion-list-header><ion-label>ion-button/buttons</ion-label></ion-list-header>
<ion-note>ion-button/ion-buttons has animation to replace ion-popover.</ion-note>
Expand Down
8 changes: 8 additions & 0 deletions docs/vertical-bars.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ The rule applies to individual buttons and buttons inside `ion-buttons`, on ordi

A toolbar whose content has all moved into the rail collapses while projection is active. Toolbars containing a title, direct text, other content or a horizontal-only control remain visible. Removing projection restores the original toolbar.

### Popovers opened from the rail

Configure this package's `popoverEnterAnimation` as Ionic's `popoverEnter` animation, as shown in the [setup example](../README.md#configure-animations). It positions a popover opened by a projected toolbar button using the visible button's bounds, for both `reference="trigger"` and `reference="event"`.

The default bottom placement opens toward the page from a rail button, with the arrow aligned to the button. Other explicit sides are retained. While the overlay hides native controls, their Web replacements keep the last native button bounds; dismissal restores native projection.

The forwarded click still targets the original button. When the final projection's bounds are available, its `ProjectedClickEvent.projectionFrame` contains `x`, `y`, `width` and `height` in Web viewport CSS pixels. Rail actions also supply `projectionEdge` (`left` or `right`, in physical coordinates). If the bounds cannot be obtained, the existing click behavior is retained. Stock Ionic animations and custom animations do not automatically consume this additional information.

### Choose button appearance

`buttonProjection` and the local projection settings below are available in `1.2.0`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
private let rendering = ShellRendering()
private var revision = 0
private var sequence = 0
private var projectionScale: CGFloat = 1
private var keyboardVisible = false
private var pendingTabSelections: [String: ShellTabBar.PendingSelection] = [:]
private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
Expand Down Expand Up @@ -203,6 +204,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
call.resolve(["revision": next]); return
}
let scale = webView.bounds.width / width
self.projectionScale = scale
let retained = Set(snapshots.map(\.id))
for id in Array(self.controls.keys) where !retained.contains(id) {
self.removeControl(id, duration: duration)
Expand Down Expand Up @@ -390,7 +392,26 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele

private func activate(_ id: String) {
sequence += 1
notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence])
var data: JSObject = ["id": id, "revision": revision, "sequence": sequence]
let frames = projectedFrames()
if let frame = frames[id] { data["projectionFrame"] = frame }
data["projectionFrames"] = frames
notifyListeners("activate", data: data)
}

private func projectedFrames() -> JSObject {
guard let webView = bridge?.webView else { return [:] }
var frames: JSObject = [:]
func collect(_ view: UIView) {
if let id = view.accessibilityIdentifier, frames[id] == nil {
let frame = webView.convert(view.bounds, from: view)
frames[id] = ["x": Double(frame.minX / projectionScale), "y": Double(frame.minY / projectionScale),
"width": Double(frame.width / projectionScale), "height": Double(frame.height / projectionScale)]
}
view.subviews.forEach(collect)
}
let roots = (verticalBars.map { [$0.view] } ?? []) + Array(controls.values)
roots.forEach(collect)
return frames
}
}
7 changes: 3 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { registeredEffect } from './sheets-of-glass/interfaces';
import { registerEffect } from './sheets-of-glass';
export * from './sheets-of-glass/interfaces';
export {
iosPopoverEnterAnimation as popoverEnterAnimation,
iosPopoverLeaveAnimation as popoverLeaveAnimation,
} from '@rdlabo/ionic-theme-utils';
export { iosPopoverLeaveAnimation as popoverLeaveAnimation } from '@rdlabo/ionic-theme-utils';
export { popoverEnterAnimation } from './projection/popover';
export type { ProjectedClickEvent } from './projection/click';
export * from './tab-bar-searchable';
export * from './searchbar';
export * from './transition/ios.transition';
Expand Down
4 changes: 4 additions & 0 deletions src/native/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export interface ShellActivation {
revision: number;
id: string;
sequence: number;
/** Final projected control bounds in Web viewport CSS pixels, when available. */
projectionFrame?: Frame;
/** Native action bounds retained by the Web rail while an overlay is open. */
projectionFrames?: Record<string, Frame>;
}

export interface WebViewMetrics {
Expand Down
7 changes: 6 additions & 1 deletion src/native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,12 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true);
runtime = combine(
native,
createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'),
createVerticalBarsWebProjection(
document,
options,
() => !nativeVerticalBars() || native.getStatus().state === 'stopped' || native.isOverlayOpen(),
native.projectionFrame,
),
);
return manage(runtime, {
suspend: () => prehide?.suspend(),
Expand Down
16 changes: 13 additions & 3 deletions src/native/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
NativeUIShellOptions,
NativeUIShellPlugin,
NativeUIShellStatus,
Frame,
} from './definitions';
import { readCandidate, selector, shadowSelector, motionSelector, isVerticalBarsCandidate } from './components';
import {
Expand Down Expand Up @@ -47,7 +48,7 @@ export const createRuntime = async (
options: NativeUIShellOptions = {},
nativeVerticalBars: () => boolean = () => true,
verticalBarsOnly = false,
): Promise<NativeUIShellHandle> => {
): Promise<NativeUIShellHandle & { isOverlayOpen(): boolean; projectionFrame(element: HTMLElement): Frame | undefined }> => {
const win = doc.defaultView!;
const icons = createIconRenderer();
const crossfade = createCrossfade(win);
Expand All @@ -65,6 +66,7 @@ export const createRuntime = async (
const observed = new Set<Element | ShadowRoot>();
const listeners = new AbortController();
let actions = new Map<string, HTMLElement>();
let projectionFrames = new WeakMap<HTMLElement, Frame>();
let nextId = 0;
let revision = 0;
let acceptedRevision = 0;
Expand Down Expand Up @@ -587,6 +589,7 @@ export const createRuntime = async (
});
on(doc, `ion${name}DidDismiss`, (event) => {
presented.delete(event.target as HTMLElement);
if (!overlayOpen(false, true)) projectionFrames = new WeakMap();
schedule();
});
}
Expand Down Expand Up @@ -637,7 +640,9 @@ export const createRuntime = async (
on(win.visualViewport, 'resize', refreshLayout);
on(win.visualViewport, 'scroll');
}
const handle: NativeUIShellHandle = {
const handle: NativeUIShellHandle & { isOverlayOpen(): boolean; projectionFrame(element: HTMLElement): Frame | undefined } = {
isOverlayOpen: () => overlayOpen(false, true),
projectionFrame: (element) => (overlayOpen(false, true) ? projectionFrames.get(element) : undefined),
getStatus: (): NativeUIShellStatus => ({
state: stopped ? 'stopped' : sources.size ? 'native' : 'web',
projected: sources.size,
Expand Down Expand Up @@ -721,8 +726,13 @@ export const createRuntime = async (
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;
projectionFrames = new WeakMap();
for (const [id, frame] of Object.entries(event.projectionFrames ?? {})) {
const source = actions.get(id);
if (source) projectionFrames.set(source, frame);
}
// The original Ionic host owns form submission, routerLink and selection events.
activateProjectedElement(element);
activateProjectedElement(element, event.projectionFrame);
lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection.
schedule();
};
Expand Down
13 changes: 11 additions & 2 deletions src/native/shared/dom.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fadeMarker } from './crossfade';
import { modalUsesVerticalBars } from './modal';
import { projectedClick } from '../../projection/click';

import type { Frame } from '../definitions';

Expand Down Expand Up @@ -207,11 +208,19 @@ export const syncToolbarText = (doc: Document): void => {
}
};

export const activateProjectedElement = (element: HTMLElement): void => {
export const activateProjectedElement = (element: HTMLElement, frame?: Frame): void => {
if ((element as HTMLElement & { disabled?: boolean }).disabled) return;
const target = element.matches('ion-button, ion-back-button, ion-menu-button')
? element.shadowRoot?.querySelector<HTMLElement>('[part~="native"]')
: undefined;
(target ?? element).click();
if (frame) {
const edge = isVerticalBarsSource(element)
? element.closest('ion-app')?.classList.contains('ios-theme-vertical-bars-left')
? 'left'
: 'right'
: undefined;
projectedClick(target ?? element, frame, edge);
} else (target ?? element).click();
};

export const unprojected = <T>(elements: Iterable<HTMLElement>, read: () => T): T => {
Expand Down
18 changes: 18 additions & 0 deletions src/native/shared/projection-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Frame } from '../definitions';

/** Keep the Web replacement at the native action's last visible bounds. */
export const placeProjection = (element: HTMLElement, frame: Frame, position = 'fixed'): void => {
element.setAttribute('data-native-ui-shell-frame', '');
Object.assign(element.style, {
position,
left: `${frame.x}px`,
top: `${frame.y}px`,
right: 'auto',
bottom: 'auto',
width: `${frame.width}px`,
minWidth: '0',
height: `${frame.height}px`,
minHeight: '0',
margin: '0',
});
};
Loading