From c863efafd6e1d1a68e149c7c6d3a89b578928afc Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 23 Oct 2026 17:31:41 +0900 Subject: [PATCH 1/3] fix: apply primary tint to native vertical tabs --- demo/e2e/native-ui.spec.ts | 38 +++++++++++++++++++ .../capacitor-native-ui/docs/native-ui.md | 2 + .../Components/NativeUIVerticalBars.swift | 25 ++++++++++++ .../Shared/NativeUISnapshot.swift | 4 +- .../NativeUISnapshotTests.swift | 34 +++++++++++++++++ .../src/native/components/ion-tab-bar.ts | 12 ++++++ .../src/native/definitions.ts | 2 + 7 files changed, 116 insertions(+), 1 deletion(-) diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index e136a2ce1..40aa20ae3 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4787,3 +4787,41 @@ 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'); }); + +test('Vertical Bars tabs follow the primary palette and retain native color surfaces', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.emulateMedia({ colorScheme: 'light' }); + await mockNative(page); + await page.goto('/main/index'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await page.addStyleTag({ + content: `ion-tab-bar { --ion-color-primary-rgb: 128, 0, 128; } + @media (prefers-color-scheme: dark) { ion-tab-bar { --ion-color-primary-rgb: 255, 170, 0; } }`, + }); + 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 activate(page, 'Docs'); + await expect(page).toHaveURL('/main/docs'); + await expect.poll(tint).toBe('rgb(128, 0, 128)'); + 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).toBe('rgb(128, 0, 128)'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect.poll(tint).toBe('rgb(255, 170, 0)'); + await page.emulateMedia({ colorScheme: 'light' }); + await expect.poll(tint).toBe('rgb(128, 0, 128)'); + await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--ion-color-primary-rgb', 'invalid')); + await expect.poll(tint).toBeUndefined(); + await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--ion-color-primary-rgb', ', ,')); + await expect.poll(tint).toBeUndefined(); + await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--ion-color-primary-rgb', '')); + await expect.poll(tint).toBe('rgb(128, 0, 128)'); +}); diff --git a/packages/capacitor-native-ui/docs/native-ui.md b/packages/capacitor-native-ui/docs/native-ui.md index 5d6c1cc00..292fefeb3 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. +Vertical Bars tabs use Ionic's `--ion-color-primary-rgb` as the native selection tint, including light/dark palette updates. Without a valid token, they use the system tint. Normal and disabled colors, labels and glass backgrounds follow the native design; per-tab CSS colors and 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/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..bbfd49596 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -5,6 +5,40 @@ import Capacitor @testable import NativeUIPlugin final class NativeUISnapshotTests: XCTestCase { + @MainActor func testVerticalTabPrimaryTintUpdatesAndRestoresSystemAppearance() 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..a66b9240e 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 @@ -1,5 +1,6 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; +import { isVerticalBarsSource } from '../shared/dom'; export const tag = 'ion-tab-bar'; export const shadowSelector = 'ion-tab-button'; @@ -22,6 +23,17 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined const candidate = createCandidate(element, tag, id); const style = getComputedStyle(element); + if (isVerticalBarsSource(element)) { + // Ionic's RGB token is shared by its light/dark palettes. Keep the system + // appearance for normal/disabled tabs instead of reproducing Web CSS states. + const primary = style + .getPropertyValue('--ion-color-primary-rgb') + .split(',') + .map((value) => (value.trim() ? Number(value) : NaN)); + if (primary.length === 3 && primary.every((value) => Number.isFinite(value) && value >= 0 && value <= 255)) { + candidate.control.tabTint = `rgb(${primary.join(', ')})`; + } + } const rtl = style.direction === 'rtl'; const position = ['start', 'center', 'end'].find((value) => element.classList.contains(`tab-bar-position-${value}`)); candidate.control.tabBarAnchor = { diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts index 7a7e13082..0b3296043 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; + /** App primary tint for native Vertical Bars tabs; other tab appearance remains system-owned. */ + tabTint?: string; tabBarAnchor?: { x: 0 | 0.5 | 1; y: 0 | 1 }; search?: NativeUISearch; } From a51b246ed8052069d62a07b8c3a0123df05bcb6c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 23 Oct 2026 17:40:04 +0900 Subject: [PATCH 2/3] fix: derive vertical tab tint from applied selection color --- demo/e2e/native-ui.spec.ts | 45 +++++++++++++------ .../capacitor-native-ui/docs/native-ui.md | 2 +- .../NativeUISnapshotTests.swift | 2 +- .../src/native/components/ion-tab-bar.ts | 16 +++---- .../src/native/definitions.ts | 2 +- 5 files changed, 39 insertions(+), 28 deletions(-) diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index 40aa20ae3..c34c743b2 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4788,15 +4788,17 @@ test('vertical group resizing preserves the following surface and its action ide await expect(page.locator('#following-group')).toHaveAttribute('data-clicked', 'true'); }); -test('Vertical Bars tabs follow the primary palette and retain native color surfaces', async ({ page }) => { +test('Vertical Bars tabs use the applied selection color across overrides and dark mode', async ({ page }) => { await page.setViewportSize({ width: 700, height: 900 }); await page.emulateMedia({ colorScheme: 'light' }); await mockNative(page); await page.goto('/main/index'); await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); await page.addStyleTag({ - content: `ion-tab-bar { --ion-color-primary-rgb: 128, 0, 128; } - @media (prefers-color-scheme: dark) { ion-tab-bar { --ion-color-primary-rgb: 255, 170, 0; } }`, + 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( @@ -4806,22 +4808,37 @@ test('Vertical Bars tabs follow the primary palette and retain native color surf ?.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(128, 0, 128)'); + 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).toBe('rgb(128, 0, 128)'); - await page.emulateMedia({ colorScheme: 'dark' }); - await expect.poll(tint).toBe('rgb(255, 170, 0)'); - await page.emulateMedia({ colorScheme: 'light' }); - await expect.poll(tint).toBe('rgb(128, 0, 128)'); - await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--ion-color-primary-rgb', 'invalid')); await expect.poll(tint).toBeUndefined(); - await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--ion-color-primary-rgb', ', ,')); - await expect.poll(tint).toBeUndefined(); - await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--ion-color-primary-rgb', '')); - await expect.poll(tint).toBe('rgb(128, 0, 128)'); + 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 292fefeb3..2b9f80360 100644 --- a/packages/capacitor-native-ui/docs/native-ui.md +++ b/packages/capacitor-native-ui/docs/native-ui.md @@ -282,7 +282,7 @@ 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. -Vertical Bars tabs use Ionic's `--ion-color-primary-rgb` as the native selection tint, including light/dark palette updates. Without a valid token, they use the system tint. Normal and disabled colors, labels and glass backgrounds follow the native design; per-tab CSS colors and colored Web backgrounds are not reproduced. +Vertical Bars tabs 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 diff --git a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index bbfd49596..06093ee4e 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -5,7 +5,7 @@ import Capacitor @testable import NativeUIPlugin final class NativeUISnapshotTests: XCTestCase { - @MainActor func testVerticalTabPrimaryTintUpdatesAndRestoresSystemAppearance() async throws { + @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) 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 a66b9240e..97e761b71 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 @@ -23,17 +23,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined const candidate = createCandidate(element, tag, id); const style = getComputedStyle(element); - if (isVerticalBarsSource(element)) { - // Ionic's RGB token is shared by its light/dark palettes. Keep the system - // appearance for normal/disabled tabs instead of reproducing Web CSS states. - const primary = style - .getPropertyValue('--ion-color-primary-rgb') - .split(',') - .map((value) => (value.trim() ? Number(value) : NaN)); - if (primary.length === 3 && primary.every((value) => Number.isFinite(value) && value >= 0 && value <= 255)) { - candidate.control.tabTint = `rgb(${primary.join(', ')})`; - } - } + // Palette contrast colors assume a colored Web background; keep native tint there. + const projectSelectionColor = isVerticalBarsSource(element) && !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 = { @@ -44,6 +35,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 0b3296043..4d1139ba6 100644 --- a/packages/capacitor-native-ui/src/native/definitions.ts +++ b/packages/capacitor-native-ui/src/native/definitions.ts @@ -191,7 +191,7 @@ export interface NativeUIControl extends Frame { items: NativeUIItem[]; dark: boolean; rtl: boolean; - /** App primary tint for native Vertical Bars tabs; other tab appearance remains system-owned. */ + /** Applied selection tint for native Vertical Bars tabs; other tab appearance remains system-owned. */ tabTint?: string; tabBarAnchor?: { x: 0 | 0.5 | 1; y: 0 | 1 }; search?: NativeUISearch; From 4948da82248991084eb88fd5d16ee2f6d2d877ee Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 23 Oct 2026 17:56:37 +0900 Subject: [PATCH 3/3] fix: apply selected tab colors to horizontal native tabs --- demo/e2e/native-ui.spec.ts | 102 +++++++++--------- .../capacitor-native-ui/docs/native-ui.md | 2 +- .../Components/NativeUISearchController.swift | 1 + .../Components/NativeUITabBar.swift | 1 + .../NativeUISnapshotTests.swift | 34 ++++++ .../src/native/components/ion-tab-bar.ts | 3 +- .../src/native/definitions.ts | 2 +- 7 files changed, 92 insertions(+), 53 deletions(-) diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index c34c743b2..2acc4ff46 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4788,57 +4788,61 @@ test('vertical group resizing preserves the following surface and its action ide await expect(page.locator('#following-group')).toHaveAttribute('data-clicked', 'true'); }); -test('Vertical Bars tabs use the applied selection color across overrides and dark mode', async ({ page }) => { - await page.setViewportSize({ width: 700, height: 900 }); - await page.emulateMedia({ colorScheme: 'light' }); - await mockNative(page); - await page.goto('/main/index'); - await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); - await page.addStyleTag({ - content: `ion-tab-bar { +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; } + }); + 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)'); }); - 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 2b9f80360..2433daa01 100644 --- a/packages/capacitor-native-ui/docs/native-ui.md +++ b/packages/capacitor-native-ui/docs/native-ui.md @@ -282,7 +282,7 @@ 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. -Vertical Bars tabs 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. +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 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/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 06093ee4e..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,40 @@ 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() 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 97e761b71..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 @@ -1,6 +1,5 @@ import { createCandidate, appendItem } from '../shared/candidate'; import type { Candidate, Identify } from '../shared/candidate'; -import { isVerticalBarsSource } from '../shared/dom'; export const tag = 'ion-tab-bar'; export const shadowSelector = 'ion-tab-button'; @@ -24,7 +23,7 @@ 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 = isVerticalBarsSource(element) && !element.classList.contains('ion-color'); + 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 = { diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts index 4d1139ba6..3b6f119f7 100644 --- a/packages/capacitor-native-ui/src/native/definitions.ts +++ b/packages/capacitor-native-ui/src/native/definitions.ts @@ -191,7 +191,7 @@ export interface NativeUIControl extends Frame { items: NativeUIItem[]; dark: boolean; rtl: boolean; - /** Applied selection tint for native Vertical Bars tabs; other tab appearance remains system-owned. */ + /** 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;