Skip to content
Merged
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
78 changes: 78 additions & 0 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4847,6 +4847,84 @@ for (const vertical of [false, true]) {
});
}

test('horizontal tabs retain measured native width on Web handoff without feeding it back', async ({ page }) => {
await page.setViewportSize({ width: 402, height: 874 });
await mockNative(page, false, null);
// Keep this iOS 27 regression on the same theme in older test WebKit builds.
await page.route('**/styles.css', async (route) => {
const response = await route.fetch();
await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') });
});
await page.goto('/main/index');
const bar = page.locator('#tab-bar-bottom');
await expect(bar).toHaveAttribute('data-native-ui-projected', '');
const original = (await bar.boundingBox())!.width;
await page.evaluate(() => {
const mock = Capacitor.registerPlugin<NativeUIMock>('NativeUI');
const update = mock.update.bind(mock);
mock.update = async (snapshot) => ({
...(await update(snapshot)),
tabBarWidths: Object.fromEntries(
snapshot.controls.filter((control) => control.kind === 'tab-bar').map((control) => [control.id, 274]),
),
});
document.querySelector('#tab-bar-bottom ion-label')!.textContent = 'Home';
});
await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0);
await page.evaluate(async () => {
const app = document.querySelector('ion-app') as TestAppElement;
app.verticalBarsLease = await app.nativeUI!.suspend();
});
await expect(bar).not.toHaveAttribute('data-native-ui-projected');
expect((await bar.boundingBox())!.width).toBeCloseTo(274, 0);
// A normal Web press updates gesture classes and an inline scale variable.
// Those decorations must not discard the native resting width.
await bar.locator('ion-tab-button').first().click();
await page.waitForTimeout(600);
expect((await bar.boundingBox())!.width).toBeCloseTo(274, 0);
await page.evaluate(async () => {
const app = document.querySelector('ion-app') as TestAppElement;
// Native projection deliberately leaves a focused Web control accessible.
(document.activeElement as HTMLElement | null)?.blur();
await app.verticalBarsLease!.resume();
});
await expect(bar).toHaveAttribute('data-native-ui-projected', '');
expect((await bar.boundingBox())!.width).toBeCloseTo(274, 0);
const requestWidth = () =>
page.evaluate(
() =>
Capacitor.registerPlugin<NativeUIMock>('NativeUI')
.updates.findLast((snapshot) => snapshot.controls.some((control) => control.kind === 'tab-bar'))!
.controls.find((control) => control.kind === 'tab-bar')!.width,
);
await expect.poll(requestWidth).toBeCloseTo(original, 0);
// An ancestor CSS change can change padding while native reports the same width.
await page.addStyleTag({ content: 'html.width-padding #tab-bar-bottom { padding-inline: 8px !important; }' });
await page.locator('html').evaluate((html) => html.classList.add('width-padding'));
await expect(bar).toHaveCSS('padding-left', '8px');
await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0);
await page.locator('html').evaluate((html) => html.classList.remove('width-padding'));
await expect.poll(requestWidth).toBeCloseTo(original, 0);
await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0);
// A phone measurement must not constrain the vertical rail, and returning to
// the phone must measure source CSS before applying the native width again.
await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars'));
await expect(bar).not.toHaveAttribute('data-native-tab-width');
await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(48, 0);
await page.locator('ion-app').evaluate((app) => app.classList.remove('ios-theme-vertical-bars'));
await expect.poll(requestWidth).toBeCloseTo(original, 0);
await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0);
const updates = await page.evaluate(() => Capacitor.registerPlugin<NativeUIMock>('NativeUI').updates.length);
await page.waitForTimeout(300);
expect(await page.evaluate(() => Capacitor.registerPlugin<NativeUIMock>('NativeUI').updates.length)).toBe(updates);
await page.setViewportSize({ width: 440, height: 874 });
await expect.poll(requestWidth).toBeCloseTo(337, 0);
await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0);
await page.evaluate(async () => (document.querySelector('ion-app') as TestAppElement).nativeUI!.destroy());
await expect(bar).not.toHaveAttribute('data-native-tab-width');
expect((await bar.boundingBox())!.width).toBeCloseTo(337, 0);
});

test('Duo tab handoff recovers from one update failure without replaying the tab click', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index/native-ui');
Expand Down
97 changes: 97 additions & 0 deletions demo/e2e/tab-bar-width.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test';
import { compile } from 'sass';
import { resolve } from 'node:path';

const css =
compile(resolve(__dirname, '../../packages/theme-ios27/src/styles/components/ion-tabs.scss')).css +
compile(resolve(__dirname, '../../packages/theme-ios27/src/styles/vertical-bars.scss')).css;

// iPhone / iOS 27 measurements: short two/three-tab platters are 188/274pt;
// four tabs fill the available phone width. Long labels can expand compact bars.
for (const [viewport, three, four] of [
[320, 217, 217],
[402, 274, 299],
[440, 274, 337],
[768, 274, 336],
]) {
test(`tab widths adapt to content within ${viewport}px`, async ({ page }) => {
await page.setViewportSize({ width: viewport, height: 900 });
// Exercise the iOS 27 theme even when the test WebKit lacks its capability
// marker; the demo otherwise deliberately serves the separate iOS 26 theme.
await page.route('**/styles.css', async (route) => {
const response = await route.fetch();
await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') });
});
await page.goto('/main/index');
await page.addStyleTag({ content: css });
const bar = page.locator('#tab-bar-bottom');
await expect(bar).toBeVisible();
const setLabels = async (labels: string[]) => {
await bar.evaluate((element, labels) => {
// Construct fresh Ionic elements: cloning hydrated labels duplicates their text.
element.replaceChildren(
...labels.map((text, index) => {
const button = document.createElement('ion-tab-button');
button.mode = 'ios';
button.tab = `width-${index}`;
button.innerHTML = '<ion-icon name="documents"></ion-icon>';
const label = document.createElement('ion-label');
label.textContent = text;
button.append(label);
return button;
}),
);
}, labels);
await expect(bar.locator('ion-tab-button:not(.ion-cloned-element):not(.hydrated)')).toHaveCount(0);
};
const width = () => bar.evaluate((element) => element.getBoundingClientRect().width);
await setLabels(['Index', 'Docs', 'Library', 'Settings']);
await expect.poll(width).toBeCloseTo(four, 0);
await setLabels(['Index', 'Docs']);
await expect.poll(width).toBeCloseTo(188, 0);
await setLabels(['Index', 'Docs', 'Library']);
await expect.poll(width).toBeCloseTo(three, 0);
await setLabels(['Reservations', 'Customers', 'Settings']);
const long = await width();
// System fonts differ between macOS and Linux CI. Check content-driven
// expansion and viewport bounds; native pixel targets are tested on iOS.
if (viewport === 320) expect(long).toBeCloseTo(three, 0);
else expect(long).toBeGreaterThan(three);
expect(long).toBeLessThanOrEqual(viewport - 103);
const itemWidths = await bar
.locator('ion-tab-button:not(.ion-cloned-element)')
.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().width));
expect(Math.max(...itemWidths) - Math.min(...itemWidths)).toBeLessThan(0.1);
await page.locator('html').evaluate((html) => html.setAttribute('dir', 'rtl'));
await expect.poll(width).toBeCloseTo(long, 0);
await setLabels(['Reservations and customer management '.repeat(4), 'Docs', 'Library']);
await expect.poll(width).toBeCloseTo(viewport - 103, 0);
await setLabels(['Index', 'Docs', 'Library']);
await expect.poll(width).toBeCloseTo(three, 0);
await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars'));
await expect.poll(width).toBeCloseTo(48, 0);
const rail = await bar.boundingBox();
const railItems = await bar
.locator('ion-tab-button:not(.ion-cloned-element)')
.evaluateAll((items) => items.map((item) => ({ x: item.getBoundingClientRect().x, width: item.getBoundingClientRect().width })));
for (const item of railItems) {
expect(item.x).toBeCloseTo(rail!.x + 2, 0);
expect(item.width).toBeCloseTo(44, 0);
}
await page.locator('ion-app').evaluate((app) => app.classList.remove('ios-theme-vertical-bars'));
await expect.poll(width).toBeCloseTo(three, 0);
});
}

test('compact tabs retain their measured width beside a visible split-pane menu', async ({ page }) => {
await page.setViewportSize({ width: 1200, height: 720 });
await page.goto('/main/index/tabs');
await page.addStyleTag({
content: css + compile(resolve(__dirname, '../../packages/theme-ios27/src/styles/components/ion-menu.scss')).css,
});
await expect(page.locator('ion-split-pane')).toHaveClass(/split-pane-visible/);
const bars = page.locator('app-tabs ion-tab-bar');
for (const [index, width] of [274, 336, 414].entries()) {
await expect.poll(() => bars.nth(index).evaluate((bar) => bar.getBoundingClientRect().width)).toBeCloseTo(width, 0);
}
});
9 changes: 9 additions & 0 deletions demo/e2e/vertical-bars-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { expect, test } from '@playwright/test';

// This suite verifies the iOS 27 layout. Older test WebKit versions otherwise
// load the iOS 26 sheet, whose 1px borders change the full-width modal geometry.
test.beforeEach(async ({ page }) => {
await page.route('**/styles.css', async (route) => {
const response = await route.fetch();
await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') });
});
});

for (const direction of ['ltr', 'rtl'] as const) {
test(`menus respect verticalBars safe-area insets in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
Expand Down
15 changes: 8 additions & 7 deletions packages/capacitor-native-ui/docs/native-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Menu buttons use Ionic's resolved default/configured icon or a supported slot ic

### Tab geometry and state updates

The DOM owns labels, SVG content, placement, selected values and application behavior. `ion-tab-bar` itself is the placement reference; UIKit owns internal margins and platter sizing.
The DOM owns labels, SVG content, placement, selected values and application behavior. `ion-tab-bar` itself is the placement reference; UIKit owns internal margins and platter sizing. After projection, the runtime applies the measured native width to the Web bar and retains it during Web fallback. Layout requests still use the original CSS width, preventing repeated projection from progressively shrinking the bar. Viewport, placement and source changes invalidate the measurement; destroying the runtime restores the original CSS. Older native binaries that do not report widths retain the theme's sizing.

| Source or viewport | Native behavior |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
Expand All @@ -220,12 +220,13 @@ The DOM owns labels, SVG content, placement, selected values and application beh

The Web theme's standard appearance targets are:

| Measurement | Target |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| Viewport at least 768px wide, 2 / 3 / 4 / 5 tabs | Platter widths near 188 / 274 / 336 / 414pt respectively. Smaller viewports retain phone sizing. |
| Bar height | 62pt |
| Selection height / inset | 54pt / 4pt |
| Adjacent buttons | Overlap like UIKit controls. |
| Measurement | Target |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| 2 / 3 tabs | Minimum platter widths near 188 / 274pt, expanding for long labels within the available viewport width. |
| Viewport at least 768px wide, 4 / 5 tabs | Platter widths near 336 / 414pt respectively. Smaller viewports retain phone sizing. |
| Bar height | 62pt |
| Selection height / inset | 54pt / 4pt |
| Adjacent buttons | Overlap like UIKit controls. |

These targets do not guarantee identical custom fonts, icons or labels; UIKit determines intrinsic width. Icon and label alignment is checked against simulator screenshots. The plugin measures the native tab subtree without private class names or fixed inset corrections.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ final class NativeUISearchInputDelegate: NSObject, UITextFieldDelegate {
protocol NativeUISearchControlling: AnyObject {
var surface: NativeUISearchHost { get }
var ownsKeyboard: Bool { get }
var restingTabWidth: CGFloat? { get }
var ownsKeyboardChrome: Bool { get }
var transitionCoordinator: UIViewControllerTransitionCoordinator? { get }
var activate: ((String) -> Void)? { get set }
Expand All @@ -68,6 +69,8 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele
private var restingBarFrame: CGRect?
private var restingBarAnchor: NativeUITabBar.Anchor?
private var fittingTabBar = false
private var measuredTabWidth: CGFloat?
var restingTabWidth: CGFloat? { session == .idle ? measuredTabWidth : nil }
private let search = UISearchController(searchResultsController: nil)
private let inputDelegate = NativeUISearchInputDelegate()
private var searchTab: UISearchTab!
Expand Down Expand Up @@ -356,6 +359,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele
fittingTabBar = true
defer { fittingTabBar = false }
tabBar.layoutIfNeeded()
weak var ordinaryRegion: UIView?
for _ in 0..<2 {
let regions = NativeUITabBar.contentViews(tabBar)
func controlCount(_ view: UIView) -> Int {
Expand All @@ -370,6 +374,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele
return rtl ? $0.frame.midX < $1.frame.midX : $0.frame.midX > $1.frame.midX
}) else { return false }
let content = region.frame
ordinaryRegion = region
let contents = regions.map { $0.frame }
let measured = tabBar.convert(content, to: surface)
let width = bounds.width - measured.width
Expand All @@ -390,6 +395,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele
view.setNeedsLayout()
view.layoutIfNeeded()
}
measuredTabWidth = ordinaryRegion?.frame.width
// Fitting the ordinary platter can move UIKit's separate Search control
// outside the WebView, especially for centered or trailing source bars.
// Reject that layout so Search keeps its usable Web fallback.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ enum NativeUITabBar {
}
}

private static func contentFrame(_ tabBar: UITabBar) -> CGRect? {
static func contentFrame(_ tabBar: UITabBar) -> CGRect? {
contentFrames(tabBar).reduce(nil) { (result: CGRect?, frame) in result.map { $0.union(frame) } ?? frame }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
}
self.notifyWebViewMetricsChange()
let duration = NativeUICrossfade.duration(snapshot.transitionDuration)
var tabBarWidths: [String: CGFloat] = [:]
var existing = Set(self.controls.keys)
let pendingVerticalNavigation = snapshot.verticalBarNavigationPhase != nil && self.verticalBars != nil
let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
Expand Down Expand Up @@ -570,6 +571,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
guard let control = self.controls[id] else { reject(); continue }
if let tabBar = control as? UITabBar {
guard NativeUITabBar.fit(tabBar, node: node, bounds: bounds) else { reject(); continue }
if let content = NativeUITabBar.contentFrame(tabBar) { tabBarWidths[id] = content.width / scale }
} else if control.frame != bounds && !(self.toolbarTransition?.controlIds.contains(id) ?? false) {
control.frame = bounds
}
Expand Down Expand Up @@ -606,6 +608,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
if created, let previousCover, previousCover !== controller.surface {
previousCover.removeFromSuperview()
}
if let measured = controller.restingTabWidth { tabBarWidths[id] = measured / scale }
controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard
}
}
Expand All @@ -620,7 +623,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
} else if let transition = self.toolbarTransition { transition.setProgress(transition.progress) }
}
let complete = {
var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls]
var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths]
if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id }
call.resolve(result)
}
Expand Down
Loading
Loading