From 22f90243f9a143a52e6b487ada69f21e9dd1f932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gon=C3=A7alves?= Date: Fri, 16 Oct 2026 19:15:32 +0100 Subject: [PATCH 1/2] test(modal): pin native status bar wrapper behavior Add spec tests for the internal status bar wrapper that the iOS card modal uses. They drive the wrapper against a fake window.Capacitor and assert which plugin receives which call and what getStyle resolves to: - StatusBar plugin available: setStyle forwards exactly { style }, getStyle returns the plugin's getInfo().style. - Capacitor present without the plugin, or the plugin registered but not available: setStyle calls nothing, getStyle resolves to DEFAULT. - No window.Capacitor: setStyle does not throw, getStyle resolves to DEFAULT. No production code changes. --- core/src/utils/native/test/status-bar.spec.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 core/src/utils/native/test/status-bar.spec.ts diff --git a/core/src/utils/native/test/status-bar.spec.ts b/core/src/utils/native/test/status-bar.spec.ts new file mode 100644 index 00000000000..e8e2e9dc7b0 --- /dev/null +++ b/core/src/utils/native/test/status-bar.spec.ts @@ -0,0 +1,97 @@ +import { StatusBar, Style } from '../status-bar'; + +const createStatusBarPlugin = () => ({ + setStyle: jest.fn(), + getInfo: jest.fn().mockResolvedValue({ style: Style.Light, visible: true, overlays: false }), +}); + +/** + * Installs a fake window.Capacitor. `plugins` are registered under + * `Capacitor.Plugins`, and `isPluginAvailable` reports only the + * names listed in `available`. + */ +const mockCapacitor = ({ plugins, available }: { plugins: Record; available: string[] }) => { + (window as any).Capacitor = { + isPluginAvailable: (name: string) => available.includes(name), + Plugins: plugins, + }; +}; + +/** + * Assertions use raw style strings rather than the Style enum so they + * pin the values the native plugins actually receive. + */ +describe('StatusBar', () => { + afterEach(() => { + delete (window as any).Capacitor; + }); + + describe('when the StatusBar plugin is available', () => { + let statusBarPlugin: ReturnType; + + beforeEach(() => { + statusBarPlugin = createStatusBarPlugin(); + mockCapacitor({ plugins: { StatusBar: statusBarPlugin }, available: ['StatusBar'] }); + }); + + it('should forward the style to the StatusBar plugin', () => { + StatusBar.setStyle({ style: Style.Dark }); + + expect(statusBarPlugin.setStyle.mock.calls).toStrictEqual([[{ style: 'DARK' }]]); + }); + + it('should resolve getStyle to the style reported by the StatusBar plugin', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('LIGHT'); + }); + }); + + describe('when Capacitor is present but the StatusBar plugin is not registered', () => { + beforeEach(() => { + mockCapacitor({ plugins: {}, available: [] }); + }); + + it('should not throw when setting the style', () => { + expect(() => StatusBar.setStyle({ style: Style.Dark })).not.toThrow(); + }); + + it('should resolve getStyle to DEFAULT', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + }); + }); + + describe('when the StatusBar plugin is registered but not available', () => { + let statusBarPlugin: ReturnType; + + beforeEach(() => { + /** + * The plugin is registered under Plugins but not available natively, + * so the wrapper must rely on isPluginAvailable rather than on the + * presence of Plugins.StatusBar. + */ + statusBarPlugin = createStatusBarPlugin(); + mockCapacitor({ plugins: { StatusBar: statusBarPlugin }, available: [] }); + }); + + it('should not call the StatusBar plugin when setting the style', () => { + expect(() => StatusBar.setStyle({ style: Style.Dark })).not.toThrow(); + + expect(statusBarPlugin.setStyle).not.toHaveBeenCalled(); + }); + + it('should resolve getStyle to DEFAULT without querying the StatusBar plugin', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + + expect(statusBarPlugin.getInfo).not.toHaveBeenCalled(); + }); + }); + + describe('when Capacitor is not present', () => { + it('should not throw when setting the style', () => { + expect(() => StatusBar.setStyle({ style: Style.Dark })).not.toThrow(); + }); + + it('should resolve getStyle to DEFAULT', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + }); + }); +}); From 1897d0ecd124dc51025128d99a5fa1e971f600e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gon=C3=A7alves?= Date: Fri, 16 Oct 2026 19:24:28 +0100 Subject: [PATCH 2/2] feat(modal): support Capacitor SystemBars plugin for card modal status bar style The native status bar wrapper now resolves its engine in order: the @capacitor/status-bar plugin if available, else Capacitor's built-in SystemBars plugin (Capacitor 8+), else none. On the SystemBars path, setStyle targets only the status bar ({ style, bar: 'StatusBar' }) and getStyle resolves to DEFAULT, since SystemBars has no getter. SystemBars types come from a type-only import; runtime code uses string literals, so no runtime @capacitor/core import is added. Card modal code is unchanged. --- core/src/utils/native/status-bar.ts | 59 +++++++++++++++---- core/src/utils/native/test/status-bar.spec.ts | 41 +++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/core/src/utils/native/status-bar.ts b/core/src/utils/native/status-bar.ts index c673a55394f..7505763ca99 100644 --- a/core/src/utils/native/status-bar.ts +++ b/core/src/utils/native/status-bar.ts @@ -1,7 +1,11 @@ +import type { SystemBars } from '@capacitor/core'; import type { StatusBarPlugin, Style as StatusBarStyle } from '@capacitor/status-bar'; import { getCapacitor } from './capacitor'; +type SystemBarsPlugin = typeof SystemBars; +type SystemBarsStyleOptions = Parameters[0]; + interface StyleOptions { style: StatusBarStyle; } @@ -12,29 +16,60 @@ export enum Style { Default = 'DEFAULT', } -export const StatusBar = { - getEngine(): StatusBarPlugin | undefined { - const capacitor = getCapacitor(); +/** + * The external @capacitor/status-bar plugin is preferred because it can + * report the current style. Capacitor 8+ ships the built-in SystemBars + * plugin, which is used when the external plugin is not installed. + */ +type ResolvedEngine = + | { kind: 'StatusBar'; plugin: StatusBarPlugin } + | { kind: 'SystemBars'; plugin: SystemBarsPlugin } + | undefined; - if (capacitor?.isPluginAvailable('StatusBar')) { - return capacitor.Plugins.StatusBar as StatusBarPlugin; - } - return undefined; +const resolveEngine = (): ResolvedEngine => { + const capacitor = getCapacitor(); + + if (capacitor?.isPluginAvailable('StatusBar')) { + return { kind: 'StatusBar', plugin: capacitor.Plugins.StatusBar as StatusBarPlugin }; + } + if (capacitor?.isPluginAvailable('SystemBars')) { + return { kind: 'SystemBars', plugin: capacitor.Plugins.SystemBars as SystemBarsPlugin }; + } + return undefined; +}; + +export const StatusBar = { + getEngine(): StatusBarPlugin | SystemBarsPlugin | undefined { + return resolveEngine()?.plugin; }, setStyle(options: StyleOptions) { - const engine = this.getEngine(); + const engine = resolveEngine(); if (!engine) { return; } - engine.setStyle(options); + if (engine.kind === 'SystemBars') { + /** + * Only restyle the status bar so the navigation/gesture bar is + * left untouched. String literals are used instead of Capacitor's + * runtime enums to avoid importing @capacitor/core at runtime. + */ + engine.plugin.setStyle({ style: options.style, bar: 'StatusBar' } as unknown as SystemBarsStyleOptions); + return; + } + + engine.plugin.setStyle(options); }, getStyle: async function (): Promise { - const engine = this.getEngine(); - if (!engine) { + const engine = resolveEngine(); + /** + * SystemBars has no getter, so DEFAULT (device appearance) is the + * style restored on dismiss. + */ + if (engine?.kind !== 'StatusBar') { return Style.Default; } - const { style } = await engine.getInfo(); + const { style } = await engine.plugin.getInfo(); return style; }, }; diff --git a/core/src/utils/native/test/status-bar.spec.ts b/core/src/utils/native/test/status-bar.spec.ts index e8e2e9dc7b0..7cf77222670 100644 --- a/core/src/utils/native/test/status-bar.spec.ts +++ b/core/src/utils/native/test/status-bar.spec.ts @@ -85,6 +85,47 @@ describe('StatusBar', () => { }); }); + describe('when only the SystemBars plugin is available', () => { + let systemBarsPlugin: { setStyle: jest.Mock }; + + beforeEach(() => { + systemBarsPlugin = { setStyle: jest.fn() }; + mockCapacitor({ plugins: { SystemBars: systemBarsPlugin }, available: ['SystemBars'] }); + }); + + it('should style only the status bar through the SystemBars plugin', () => { + StatusBar.setStyle({ style: Style.Dark }); + + expect(systemBarsPlugin.setStyle.mock.calls).toStrictEqual([[{ style: 'DARK', bar: 'StatusBar' }]]); + }); + + it('should resolve getStyle to DEFAULT', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + }); + }); + + describe('when both the StatusBar and SystemBars plugins are available', () => { + let statusBarPlugin: ReturnType; + let systemBarsPlugin: { setStyle: jest.Mock }; + + beforeEach(() => { + statusBarPlugin = createStatusBarPlugin(); + systemBarsPlugin = { setStyle: jest.fn() }; + mockCapacitor({ + plugins: { StatusBar: statusBarPlugin, SystemBars: systemBarsPlugin }, + available: ['StatusBar', 'SystemBars'], + }); + }); + + it('should only call the StatusBar plugin', async () => { + StatusBar.setStyle({ style: Style.Dark }); + + await expect(StatusBar.getStyle()).resolves.toBe('LIGHT'); + expect(statusBarPlugin.setStyle.mock.calls).toStrictEqual([[{ style: 'DARK' }]]); + expect(systemBarsPlugin.setStyle).not.toHaveBeenCalled(); + }); + }); + describe('when Capacitor is not present', () => { it('should not throw when setting the style', () => { expect(() => StatusBar.setStyle({ style: Style.Dark })).not.toThrow();