diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index e136a2ce1..2acc4ff46 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4787,3 +4787,62 @@ test('vertical group resizing preserves the following surface and its action ide await activate(page, 'Following'); await expect(page.locator('#following-group')).toHaveAttribute('data-clicked', 'true'); }); + +for (const vertical of [false, true]) { + test(`${vertical ? 'Vertical Bars' : 'Horizontal'} tabs use the applied selection color across overrides and dark mode`, async ({ + page, + }) => { + await page.setViewportSize({ width: vertical ? 700 : 402, height: 900 }); + await page.emulateMedia({ colorScheme: 'light' }); + await mockNative(page, false, vertical ? 'trailing' : null); + await page.goto('/main/index'); + if (vertical) await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await page.addStyleTag({ + content: `ion-tab-bar { + --ion-color-primary: #800080; --ion-color-primary-rgb: 1, 2, 3; + --ion-tab-bar-color-selected: var(--ion-color-primary); + }`, + }); + const tint = () => + page.evaluate( + () => + Capacitor.registerPlugin('NativeUI') + .updates.at(-1) + ?.controls.find((control) => control.kind === 'tab-bar')?.tabTint, + ); + await expect.poll(tint).toBe('rgb(128, 0, 128)'); + await page.addStyleTag({ + content: `ion-tab-bar { --color-selected: orange; } + ion-tab-button[tab="docs"] { color-scheme: light dark; --color-selected: light-dark(blue, yellow); }`, + }); + await expect.poll(tint).toBe('rgb(255, 165, 0)'); + await activate(page, 'Docs'); + await expect(page).toHaveURL('/main/docs'); + await expect.poll(tint).toBe('rgb(0, 0, 255)'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect.poll(tint).toBe('rgb(255, 255, 0)'); + await page.emulateMedia({ colorScheme: 'light' }); + await expect.poll(tint).toBe('rgb(0, 0, 255)'); + await page.locator('ion-tab-button[tab="docs"]').evaluate((tab) => { + tab.style.setProperty('--color-selected', 'currentColor'); + }); + await page.locator('ion-tab-bar').evaluate((bar) => { + bar.style.color = 'rgb(200, 0, 0)'; + }); + await expect.poll(tint).toBe('rgb(200, 0, 0)'); + await activate(page, 'Index'); + await expect(page).toHaveURL('/main/index'); + await expect.poll(tint).toBe('rgb(255, 165, 0)'); + await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--color-selected', 'rgba(120, 40, 80, 0.8)')); + await expect.poll(tint).toBe('rgba(120, 40, 80, 0.8)'); + await page.locator('ion-tab-bar').evaluate((bar: HTMLIonTabBarElement) => { + bar.color = 'primary'; + }); + await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-projected', ''); + await expect.poll(tint).toBeUndefined(); + await page.locator('ion-tab-bar').evaluate((bar: HTMLIonTabBarElement) => { + bar.color = undefined; + }); + await expect.poll(tint).toBe('rgba(120, 40, 80, 0.8)'); + }); +} diff --git a/packages/capacitor-native-ui/docs/native-ui.md b/packages/capacitor-native-ui/docs/native-ui.md index 5d6c1cc00..2433daa01 100644 --- a/packages/capacitor-native-ui/docs/native-ui.md +++ b/packages/capacitor-native-ui/docs/native-ui.md @@ -282,6 +282,8 @@ While enabled, Native UI suppresses the WebView’s top scroll-edge effect becau The native search controller remains visible over its own keyboard. Other projected controls are hidden while a Web input opens the keyboard. UIKit's own accessibility and Reduce Motion behavior apply to the standard controls; full VoiceOver traversal is not a verified parity guarantee. +Native tabs, including searchable tabs and Vertical Bars, use the selected Ionic tab button's computed foreground color as the native selection tint. This follows `--color-selected`, per-tab overrides and light/dark theme changes without requiring a separate RGB palette token. Without a selected tab or a supported computed RGB color, they use the system tint. Tab bars with an Ionic `color` palette also keep the system tint: their Web contrast color assumes a colored background that the native glass does not reproduce. Normal and disabled colors, labels and glass backgrounds follow the native design; colored Web backgrounds are not reproduced. + ## FAB behavior FABs retain Ionic's `activated`, per-child `show`, `close()` and original click handlers. Multiple lists, initial expansion, small buttons and `edge` use each button's measured layout. The native side reflects Ionic's staggered visibility without adding another timer or open/close controller. Main-button icon changes crossfade using the resolved `closeIcon`; Reduce Motion disables this crossfade. Native FAB button instances persist across open/close updates. The source FAB stays projected throughout ordinary opening and closing. 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 2192490ce..447ebc2a1 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift @@ -248,6 +248,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele let available = configuration.available let active = available && configuration.active let wanted = wantedSession(active: active, focused: configuration.focused) + tabBar.tintColor = snapshot.tabTint.map { rendering.color($0) } let items = snapshot.items let ids = items.map(\.id) for id in Array(ordinary.keys) where !ids.contains(id) { ordinary.removeValue(forKey: id) } 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 4e46005f3..da15b3b60 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift @@ -56,6 +56,7 @@ enum NativeUITabBar { static func update(_ tabBar: UITabBar, node: NativeUIControl, rendering: NativeUIControlRendering, pendingSelection: inout PendingSelection?) { + tabBar.tintColor = node.tabTint.map { rendering.color($0) } let items = node.items let ids = items.map(\.id) var needsTitleWarmup = false diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift index 8cd90841f..cb74276c0 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift @@ -45,6 +45,7 @@ final class NativeUIVerticalBarsModel: ObservableObject { @Published var navigation: [String] = [] private var pageGroups: [String: [Group]] = [:] @Published var groups: [Group] = [] + @Published var tabTint: String? @Published var tabs: [Item] = [] @Published var selection = "" var activate: (String) -> Void = { _ in } @@ -82,6 +83,7 @@ final class NativeUIVerticalBarsModel: ObservableObject { } // Keep all snapshot publications in this transaction. A later unanimated // accessory/tab publication can cancel the toolbar animation. + tabTint = controls.first(where: { $0.kind == .tabBar })?.tabTint tabs = controls.first(where: { $0.kind == .tabBar })?.items.map { item($0) } ?? [] reconcileSelection(now: now) } @@ -429,10 +431,12 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling { private final class TransparentHostingController: UIHostingController { var railEdge = "right" var layoutSearch: (() -> Void)? + var layoutTabs: (() -> Void)? override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() makeFullSizeSurfacesTransparent(in: view) layoutSearch?() + layoutTabs?() } private func makeFullSizeSurfacesTransparent(in surface: UIView) { @@ -542,6 +546,27 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling { controller.view.backgroundColor = .clear controller.view.isOpaque = false controller.layoutSearch = { [weak self] in self?.model.search?.configureField() } + controller.layoutTabs = { [weak self] in self?.applyTabTint() } + } + + private weak var tintedTabBar: UITabBar? + private var appliedTabTint: String? + + private func applyTabTint() { + let tint = model.tabTint.map { NativeUIRendering.color($0) } + func visit(_ owner: UIViewController) { + if let tabs = owner as? UITabBarController { + // Scope the accent to tabs, without recoloring toolbars or search. + if tintedTabBar !== tabs.tabBar || appliedTabTint != model.tabTint || (tint != nil && tabs.tabBar.tintColor != tint) { + tabs.tabBar.tintColor = tint + tintedTabBar = tabs.tabBar + appliedTabTint = model.tabTint + } + return + } + owner.children.forEach(visit) + } + visit(controller) } @MainActor func attach(to owner: UIViewController, in parent: UIView) { diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift index 2de0a3f6f..0322f2e27 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift @@ -81,12 +81,13 @@ struct NativeUIControl: Decodable, Equatable { let items: [NativeUIItem] let dark: Bool let rtl: Bool + let tabTint: String? let tabBarAnchor: NativeUITabBar.Anchor? let search: NativeUISearch? let extensionContent: NativeUIExtensionContent? private enum CodingKeys: String, CodingKey { - case id, kind, placement, toolbarSlot, transitionId, items, dark, rtl, tabBarAnchor, search + case id, kind, placement, toolbarSlot, transitionId, items, dark, rtl, tabBarAnchor, tabTint, search case extensionContent = "extension" } @@ -101,6 +102,7 @@ struct NativeUIControl: Decodable, Equatable { items = try values.decode([NativeUIItem].self, forKey: .items) dark = try values.decode(Bool.self, forKey: .dark) rtl = try values.decode(Bool.self, forKey: .rtl) + tabTint = try values.decodeIfPresent(String.self, forKey: .tabTint) tabBarAnchor = try values.decodeIfPresent(NativeUITabBar.Anchor.self, forKey: .tabBarAnchor) search = try values.decodeIfPresent(NativeUISearch.self, forKey: .search) extensionContent = try values.decodeIfPresent(NativeUIExtensionContent.self, forKey: .extensionContent) diff --git a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 63eb8571f..1bb40cc6e 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -5,6 +5,74 @@ import Capacitor @testable import NativeUIPlugin final class NativeUISnapshotTests: XCTestCase { + @MainActor func testHorizontalTabSelectionTintUpdatesAndResetsWithAndWithoutSearch() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") } + let rendering = NativeUIControlRendering() + let bar = UITabBar() + let controller = NativeUISearchController() + controller.loadViewIfNeeded() + let plainSystemTint = bar.tintColor + let searchSystemTint = controller.tabBar.tintColor + let frame = CGRect(x: 0, y: 0, width: 390, height: 844) + 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 (index, tint) in (["rgb(255, 0, 0)", "rgb(128, 0, 128)", nil] as [String?]).enumerated() { + var value = control(["kind": "tab-bar", "search": search, "items": [ + item(["id": "first", "selected": index % 2 == 0]), + item(["id": "second", "selected": index % 2 != 0]), + item(["id": "disabled", "disabled": true])]]) + value["tabTint"] = tint + let node = try XCTUnwrap(decode([value]).controls.first) + NativeUITabBar.update(bar, node: node, rendering: rendering) + _ = controller.apply(node, webFrame: frame, + barFrame: CGRect(x: 18, y: 730, width: 280, height: 62), + triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering) + XCTAssertEqual(bar.tintColor, tint.map { rendering.color($0) } ?? plainSystemTint) + XCTAssertEqual(controller.tabBar.tintColor, tint.map { rendering.color($0) } ?? searchSystemTint) + XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, index % 2 == 0 ? "first" : "second") + XCTAssertEqual(controller.selectedTab?.identifier, index % 2 == 0 ? "first" : "second") + XCTAssertEqual(bar.items?.last?.isEnabled, false) + XCTAssertEqual(controller.tabs.first { $0.identifier == "disabled" }?.isEnabled, false) + XCTAssertNil(bar.items?.first?.standardAppearance) + XCTAssertNotEqual(controller.view.tintColor, UIColor.red, "Do not tint the search field or content") + } + } + + @MainActor func testVerticalTabSelectionTintUpdatesAndRestoresSystemAppearance() async throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI tabs") } + let owner = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = owner + window.makeKeyAndVisible() + let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 }) + rail.attach(to: owner, in: owner.view) + defer { rail.detach(); window.isHidden = true } + func tabController(_ parent: UIViewController) -> UITabBarController? { + if let tabs = parent as? UITabBarController { return tabs } + return parent.children.lazy.compactMap { tabController($0) }.first + } + var systemTint: UIColor? + for (index, tint) in ([nil, "rgb(255, 0, 0)", "rgb(255, 0, 0)", "rgb(128, 0, 128)", nil] as [String?]).enumerated() { + var value = control(["kind": "tab-bar", "placement": "vertical-bars", "items": [ + item(["id": "first", "label": "First", "selected": index % 2 == 0]), + item(["id": "second", "label": "Second", "selected": index % 2 != 0]), + item(["id": "disabled", "label": "Disabled", "disabled": true])]]) + value["tabTint"] = tint + value["dark"] = index == 3 + window.overrideUserInterfaceStyle = index == 3 ? .dark : .light + rail.apply(try decode([value]).controls, rendering: NativeUIControlRendering(), edge: "right", animated: false) + try await Task.sleep(nanoseconds: 500_000_000) + let tabs = try XCTUnwrap(tabController(owner)) + if index == 0 { systemTint = tabs.tabBar.tintColor } + XCTAssertEqual(tabs.tabBar.tintColor, tint.map { NativeUIRendering.color($0) } ?? systemTint) + XCTAssertEqual(tabs.selectedIndex, index % 2) + XCTAssertEqual(tabs.traitCollection.userInterfaceStyle, index == 3 ? .dark : .light) + XCTAssertNil(tabs.tabBar.items?.first?.standardAppearance, "Keep the native background and state appearances") + XCTAssertNotEqual(owner.view.tintColor, UIColor.red, "The tab accent must not tint the surrounding controls") + } + } + @MainActor func testPopoverReopensAfterPresentationIdentityKeepsReplacedSource() async throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI toolbar") } let owner = UIViewController() diff --git a/packages/capacitor-native-ui/src/native/components/ion-tab-bar.ts b/packages/capacitor-native-ui/src/native/components/ion-tab-bar.ts index cf4ea4387..47f31bacc 100644 --- a/packages/capacitor-native-ui/src/native/components/ion-tab-bar.ts +++ b/packages/capacitor-native-ui/src/native/components/ion-tab-bar.ts @@ -22,6 +22,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined const candidate = createCandidate(element, tag, id); const style = getComputedStyle(element); + // Palette contrast colors assume a colored Web background; keep native tint there. + const projectSelectionColor = !element.classList.contains('ion-color'); const rtl = style.direction === 'rtl'; const position = ['start', 'center', 'end'].find((value) => element.classList.contains(`tab-bar-position-${value}`)); candidate.control.tabBarAnchor = { @@ -32,6 +34,9 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined for (const child of children) { const item = appendItem(candidate, child, id); if (!item) return; + // Read Ionic's applied selection color, after CSS inheritance and theme + // resolution, rather than requiring a particular palette token or format. + if (projectSelectionColor && item.selected && /^rgba?\(/.test(item.color)) candidate.control.tabTint = item.color; const svg = child.querySelector('svg') ?? child.querySelector('ion-icon')?.shadowRoot?.querySelector('svg'); if (svg) { item.iconPosition = 'top'; diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts index 7a7e13082..3b6f119f7 100644 --- a/packages/capacitor-native-ui/src/native/definitions.ts +++ b/packages/capacitor-native-ui/src/native/definitions.ts @@ -191,6 +191,8 @@ export interface NativeUIControl extends Frame { items: NativeUIItem[]; dark: boolean; rtl: boolean; + /** Applied selection tint for native tabs; other tab appearance remains system-owned. */ + tabTint?: string; tabBarAnchor?: { x: 0 | 0.5 | 1; y: 0 | 1 }; search?: NativeUISearch; }