From 686eb5da1395a83eafc5b7ef42200a637038048e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 00:00:25 +0900 Subject: [PATCH 1/4] fix: keep web tab widths aligned with native geometry --- demo/e2e/native-ui.spec.ts | 70 ++++++++++++++++ demo/e2e/tab-bar-width.spec.ts | 80 +++++++++++++++++++ .../capacitor-native-ui/docs/native-ui.md | 15 ++-- .../Components/NativeUISearchController.swift | 6 ++ .../Components/NativeUITabBar.swift | 2 +- .../NativeUIPlugin/NativeUIPlugin.swift | 5 +- .../NativeUISnapshotTests.swift | 35 ++++++++ .../src/native/definitions.ts | 10 ++- .../capacitor-native-ui/src/native/runtime.ts | 9 ++- .../src/native/shared/tab-bar-width.ts | 66 +++++++++++++++ .../src/styles/components/ion-tabs.scss | 36 +++++++-- .../theme-ios27/src/styles/vertical-bars.scss | 1 + 12 files changed, 317 insertions(+), 18 deletions(-) create mode 100644 demo/e2e/tab-bar-width.spec.ts create mode 100644 packages/capacitor-native-ui/src/native/shared/tab-bar-width.ts diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index 2acc4ff46..e8b0f3dd1 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4846,3 +4846,73 @@ for (const vertical of [false, true]) { await expect.poll(tint).toBe('rgba(120, 40, 80, 0.8)'); }); } + +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('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('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); + const updates = await page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.length); + await page.waitForTimeout(300); + expect(await page.evaluate(() => Capacitor.registerPlugin('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); +}); diff --git a/demo/e2e/tab-bar-width.spec.ts b/demo/e2e/tab-bar-width.spec.ts new file mode 100644 index 000000000..55078e56b --- /dev/null +++ b/demo/e2e/tab-bar-width.spec.ts @@ -0,0 +1,80 @@ +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 = ''; + 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(); + // Browser and UIKit text rasterization can differ by a subpixel per item. + expect(long).toBeGreaterThanOrEqual(three); + expect(Math.abs(long - (viewport === 320 ? 217 : 295))).toBeLessThan(2); + 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(['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); + }); +} diff --git a/packages/capacitor-native-ui/docs/native-ui.md b/packages/capacitor-native-ui/docs/native-ui.md index 30113bc1b..bd7d833f6 100644 --- a/packages/capacitor-native-ui/docs/native-ui.md +++ b/packages/capacitor-native-ui/docs/native-ui.md @@ -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 | | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | @@ -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. diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift index 447ebc2a1..aa408adb9 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift @@ -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 } @@ -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! @@ -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 { @@ -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 @@ -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. diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift index da15b3b60..462ac4e80 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift @@ -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 } } diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift index b0ef511d0..4fb500efd 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift @@ -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 } @@ -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 } @@ -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 } } @@ -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) } diff --git a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index ed4ea314b..23ab7945e 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -5,6 +5,41 @@ import Capacitor @testable import NativeUIPlugin final class NativeUISnapshotTests: XCTestCase { + @MainActor func testMeasuredTabWidthTracksNativeContentWithAndWithoutSearch() async throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") } + let owner = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = owner + window.makeKeyAndVisible() + let rendering = NativeUIControlRendering() + let bar = UITabBar() + NativeUITabBar.configureLayout(bar) + owner.view.addSubview(bar) + let controller = NativeUISearchController() + controller.attach(to: owner, in: owner.view) + defer { controller.detach(); window.isHidden = true } + let image = UIImage(systemName: "circle.fill")!.pngData()!.base64EncodedString() + let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]), + "closeId": "close", "active": false, "available": true, "focused": false, + "value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0] + for width in [299.0, 217.0, 299.0] { + let items = ["Index", "Docs", "Library"].enumerated().map { index, label in item([ + "id": "tab-\(index)", "label": label, "selected": index == 0, + "icon": image, "iconWidth": 26.0, "iconHeight": 26.0, "fontSize": 9.44, "fontWeight": 500.0]) } + let node = try XCTUnwrap(decode([control(["kind": "tab-bar", "width": width, "height": 62.0, + "search": search, "items": items])]).controls.first) + let y = owner.view.bounds.height - 100 + let bounds = CGRect(x: 16, y: y, width: width, height: 62) + NativeUITabBar.update(bar, node: node, rendering: rendering) + XCTAssertTrue(NativeUITabBar.fit(bar, node: node, bounds: bounds)) + XCTAssertEqual(try XCTUnwrap(NativeUITabBar.contentFrame(bar)).width, min(width, 274), accuracy: 0.5) + XCTAssertTrue(controller.apply(node, webFrame: owner.view.bounds, barFrame: bounds, + triggerFrame: CGRect(x: 330, y: y, width: 56, height: 56), rendering: rendering)) + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(try XCTUnwrap(controller.restingTabWidth), width, accuracy: 0.5) + } + } + @MainActor func testHorizontalTabSelectionTintUpdatesAndResetsWithAndWithoutSearch() throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") } let rendering = NativeUIControlRendering() diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts index 3b6f119f7..7423f4937 100644 --- a/packages/capacitor-native-ui/src/native/definitions.ts +++ b/packages/capacitor-native-ui/src/native/definitions.ts @@ -261,9 +261,13 @@ export interface NativeUIPlugin { dismissOverlay(options: { id: string; animated: boolean }): Promise; closeOverlay(options: { id: string }): Promise; stopOverlays(): Promise; - update( - snapshot: NativeUISnapshot, - ): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[]; toolbarTransition?: number }>; + update(snapshot: NativeUISnapshot): Promise<{ + revision: number; + rejectedSearches?: string[]; + rejectedControls?: string[]; + tabBarWidths?: Record; + toolbarTransition?: number; + }>; setToolbarTransitionProgress(options: { id: number; progress: number }): Promise; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: NativeUIActivation) => void): Promise; diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 5cb45b5c4..e732ac449 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { createTabBarWidths } from './shared/tab-bar-width'; import { createVerticalNavigation } from './shared/vertical-navigation'; import { createHorizontalNavigation, horizontalToolbar, navigationMarker } from './shared/horizontal-navigation'; import { assignToolbarTransitionIds } from './shared/transition-identity'; @@ -100,6 +101,7 @@ export const createRuntime = async ( let nextId = 0; let revision = 0; const tabGeometry = new Map void>(); + const tabWidths = createTabBarWidths(doc); let acceptedRevision = 0; let updatingRevision: number | undefined; let pendingActivations: NativeUIActivation[] = []; @@ -306,7 +308,7 @@ export const createRuntime = async ( { control: candidate.control, icons: candidate.icons.map(({ source }) => source), assets: candidate.assets?.signature }, includeTransitionIds ? undefined : (key, value) => (key === 'transitionId' ? undefined : value), ); - const read = (): Candidate[] => { + const readSources = (): Candidate[] => { if (verticalGesture && (overlayOpen(true, true) || !verticalGesture.scopes.every(inVerticalBarsSurface))) verticalGesture = undefined; search.keepSearchTabsVisible(); for (const page of pages) if (!page.isConnected) pages.delete(page); @@ -373,6 +375,7 @@ export const createRuntime = async ( assignToolbarTransitionIds(result); return navigation.retain(result); }; + const read = (): Candidate[] => tabWidths.measure(readSources); const observe = () => { const wanted = new Set(); for (const element of Array.from(scope.querySelectorAll(discovery))) { @@ -727,6 +730,9 @@ export const createRuntime = async ( verticalBarsMembers.add(element); } for (const candidate of accepted) { + const width = result.tabBarWidths?.[candidate.control.id]; + if (candidate.control.kind === 'tab-bar' && candidate.control.placement !== 'vertical-bars' && width !== undefined) + tabWidths.apply(candidate.element, width); const instant = handoffInstant || (committingNavigation && horizontalToolbar(candidate)); for (const element of candidateSources(candidate)) { const newlyProjected = !sources.has(element) || !element.hasAttribute(marker); @@ -1104,6 +1110,7 @@ export const createRuntime = async ( destruction = Promise.resolve().then(async () => { tabGeometry.forEach((restore) => restore()); tabGeometry.clear(); + tabWidths.clear(); setNativeUIIntegration(doc); win.cancelAnimationFrame(frame); listeners.abort(); diff --git a/packages/capacitor-native-ui/src/native/shared/tab-bar-width.ts b/packages/capacitor-native-ui/src/native/shared/tab-bar-width.ts new file mode 100644 index 000000000..a0cc232f8 --- /dev/null +++ b/packages/capacitor-native-ui/src/native/shared/tab-bar-width.ts @@ -0,0 +1,66 @@ +/** Keep the Web fallback at the acknowledged native width, without feeding that + * width back as the next layout request (which can progressively shrink a bar). */ +export const createTabBarWidths = (doc: Document) => { + const entries = new Map(); + let next = 0; + const key = (bar: HTMLElement) => { + const computed = doc.defaultView!.getComputedStyle(bar); + return JSON.stringify([ + doc.defaultView!.innerWidth, + doc.defaultView!.innerHeight, + bar.closest('ion-app')?.className, + computed.width, + computed.boxSizing, + computed.paddingLeft, + computed.paddingRight, + computed.borderLeftWidth, + computed.borderRightWidth, + Array.from(bar.children) + .filter((child) => !child.classList.contains('ion-cloned-element')) + .map((child) => child.textContent), + ]); + }; + const remove = (bar: HTMLElement) => { + entries.get(bar)?.style.remove(); + entries.delete(bar); + bar.removeAttribute('data-native-tab-width'); + }; + return { + measure(read: () => T): T { + // Compare layout with the source CSS. Gesture classes and transforms must + // not discard the measured resting width during a Web handoff. + for (const { style } of entries.values()) if (style.sheet) style.sheet.disabled = true; + for (const [bar, entry] of entries) if (!bar.isConnected || entry.key !== key(bar)) remove(bar); + try { + return read(); + } finally { + for (const { style } of entries.values()) if (style.sheet) style.sheet.disabled = false; + } + }, + apply(bar: HTMLElement, width: number) { + if (!Number.isFinite(width) || width <= 0) return; + const previous = entries.get(bar); + const computed = doc.defaultView!.getComputedStyle(bar); + const inset = + computed.boxSizing === 'border-box' + ? 0 + : (['paddingLeft', 'paddingRight', 'borderLeftWidth', 'borderRightWidth'] as const).reduce( + (total, property) => total + (parseFloat(computed[property]) || 0), + 0, + ); + const cssWidth = Math.max(0, width - inset); + if (previous && Math.abs(previous.width - width) < 0.1 && Math.abs(previous.cssWidth - cssWidth) < 0.1) return; + remove(bar); + const sourceKey = key(bar); + const token = `tab-${++next}`; + const style = doc.createElement('style'); + bar.setAttribute('data-native-tab-width', token); + style.textContent = `[data-native-tab-width="${token}"]{width:${cssWidth}px!important;min-width:${cssWidth}px!important;max-width:${cssWidth}px!important}`; + doc.head.append(style); + entries.set(bar, { style, key: sourceKey, width, cssWidth }); + }, + clear() { + for (const bar of entries.keys()) remove(bar); + }, + }; +}; diff --git a/packages/theme-ios27/src/styles/components/ion-tabs.scss b/packages/theme-ios27/src/styles/components/ion-tabs.scss index f754c71a3..f5457540f 100644 --- a/packages/theme-ios27/src/styles/components/ion-tabs.scss +++ b/packages/theme-ios27/src/styles/components/ion-tabs.scss @@ -69,9 +69,6 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { max-width: 471px; height: 54px; min-height: 54px; - &:has(> ion-tab-button:nth-child(2):last-child) { - width: var(--ios-theme-side-tab-content-width, 180px); // 188px including the 4px inset on each side. - } &:has(:nth-child(5)) { width: var( --ios-theme-side-tab-content-width, @@ -81,7 +78,7 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { } padding: 4px; - > ion-tab-button:not(.ion-cloned-element) ~ ion-tab-button:not(.ion-cloned-element) { + > :where(ion-tab-button:not(.ion-cloned-element)) ~ :where(ion-tab-button:not(.ion-cloned-element)) { margin-inline-start: calc(-1 * var(--ios-theme-tab-overlap)); } @@ -89,11 +86,40 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { --ios-theme-tab-overlap: 8px; } + // UIKit sizes two or three tabs from the widest label, with equal item + // widths; four or more tabs continue to use the available phone width. + @each $count in (2, 3) { + &:where( + :has( + > ion-tab-button:nth-child(#{$count} of ion-tab-button:not(.ion-cloned-element)):nth-last-child( + 1 of ion-tab-button:not(.ion-cloned-element) + ) + ) + ) { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + contain: layout style; + width: max-content; + min-width: min(#{$count * 86px}, calc(100% - 119px - var(--ion-safe-area-left, 0px) - var(--ion-safe-area-right, 0px))); + max-width: calc(100% - 119px - var(--ion-safe-area-left, 0px) - var(--ion-safe-area-right, 0px)); + padding-inline: 8px; + + > ion-tab-button:not(.ion-cloned-element) { + min-width: 0; + max-width: none; + margin-inline: -4px; + --padding-start: 20px; + --padding-end: 20px; + } + } + } + // iPad's stacked UIKit tabs use a compact platter instead of filling the // available width. Keep the phone sizing and constrain only roomy layouts. @media (min-width: 768px) { --ios-theme-tab-overlap: 16px; - @each $count, $width in (2: 188px, 3: 274px, 4: 336px, 5: 414px) { + @each $count, $width in (4: 336px, 5: 414px) { &:has( > ion-tab-button:nth-child(#{$count} of ion-tab-button:not(.ion-cloned-element)):nth-last-child( 1 of ion-tab-button:not(.ion-cloned-element) diff --git a/packages/theme-ios27/src/styles/vertical-bars.scss b/packages/theme-ios27/src/styles/vertical-bars.scss index 71ff0f78d..1f0c94013 100644 --- a/packages/theme-ios27/src/styles/vertical-bars.scss +++ b/packages/theme-ios27/src/styles/vertical-bars.scss @@ -208,6 +208,7 @@ ion-app.ios-theme-vertical-bars flex: none; width: 44px; min-width: 44px; + margin-inline: 0; height: 58px; min-height: 58px; border-radius: 22px; From 419c30f6b430d4dfc28ac3416a000dbf5fc1494c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 00:20:52 +0900 Subject: [PATCH 2/4] fix: preserve compact tab widths beside split-pane menus --- demo/e2e/tab-bar-width.spec.ts | 23 ++++++++++++++++--- .../src/styles/components/ion-menu.scss | 9 +++++--- .../src/styles/components/ion-tabs.scss | 2 ++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/demo/e2e/tab-bar-width.spec.ts b/demo/e2e/tab-bar-width.spec.ts index 55078e56b..1a500b2a8 100644 --- a/demo/e2e/tab-bar-width.spec.ts +++ b/demo/e2e/tab-bar-width.spec.ts @@ -53,15 +53,19 @@ for (const [viewport, three, four] of [ await expect.poll(width).toBeCloseTo(three, 0); await setLabels(['Reservations', 'Customers', 'Settings']); const long = await width(); - // Browser and UIKit text rasterization can differ by a subpixel per item. - expect(long).toBeGreaterThanOrEqual(three); - expect(Math.abs(long - (viewport === 320 ? 217 : 295))).toBeLessThan(2); + // 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')); @@ -78,3 +82,16 @@ for (const [viewport, three, four] of [ 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); + } +}); diff --git a/packages/theme-ios27/src/styles/components/ion-menu.scss b/packages/theme-ios27/src/styles/components/ion-menu.scss index 7fd1f9d9c..c7d3b255e 100644 --- a/packages/theme-ios27/src/styles/components/ion-menu.scss +++ b/packages/theme-ios27/src/styles/components/ion-menu.scss @@ -5,9 +5,12 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled) { ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible:has(ion-menu.menu-pane-visible) { .split-pane-main { ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(.ios-theme-vertical-bars *)) { - width: calc( - 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 60px - - 12px - var(--ios-theme-menu-width, var(--ios26-menu-width)) + width: var( + --ios-theme-compact-tab-width, + calc( + 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 60px - + 12px - var(--ios-theme-menu-width, var(--ios26-menu-width)) + ) ); &:has(:nth-child(5)) { diff --git a/packages/theme-ios27/src/styles/components/ion-tabs.scss b/packages/theme-ios27/src/styles/components/ion-tabs.scss index f5457540f..a23bebd35 100644 --- a/packages/theme-ios27/src/styles/components/ion-tabs.scss +++ b/packages/theme-ios27/src/styles/components/ion-tabs.scss @@ -100,6 +100,8 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); contain: layout style; + // Keep intrinsic sizing when a visible split-pane menu sets the bar width. + --ios-theme-compact-tab-width: max-content; width: max-content; min-width: min(#{$count * 86px}, calc(100% - 119px - var(--ion-safe-area-left, 0px) - var(--ion-safe-area-right, 0px))); max-width: calc(100% - 119px - var(--ion-safe-area-left, 0px) - var(--ion-safe-area-right, 0px)); From 4152c48a3b69a18f1e9568c39a7ab58c4237263b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 00:25:44 +0900 Subject: [PATCH 3/4] test: load iOS 27 theme for vertical modal layouts --- demo/e2e/vertical-bars-layout.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/demo/e2e/vertical-bars-layout.spec.ts b/demo/e2e/vertical-bars-layout.spec.ts index a264873b6..082cee828 100644 --- a/demo/e2e/vertical-bars-layout.spec.ts +++ b/demo/e2e/vertical-bars-layout.spec.ts @@ -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 }); From 5ff5f2a9437cee182b485015979733e431bb4937 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 07:50:18 +0900 Subject: [PATCH 4/4] test: verify measured tab widths across Duo mode changes --- demo/e2e/native-ui.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index e8b0f3dd1..8d49cbe14 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4906,6 +4906,14 @@ test('horizontal tabs retain measured native width on Web handoff without feedin 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('NativeUI').updates.length); await page.waitForTimeout(300); expect(await page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.length)).toBe(updates);