diff --git a/packages/react-native-web-docs/src/pages/docs/apis/appearance.md b/packages/react-native-web-docs/src/pages/docs/apis/appearance.md index 4afb93cf92..b1a8158b68 100644 --- a/packages/react-native-web-docs/src/pages/docs/apis/appearance.md +++ b/packages/react-native-web-docs/src/pages/docs/apis/appearance.md @@ -30,3 +30,7 @@ Add an event handler that is called with `{colorScheme: "dark" | "light"}` when {% call macro.prop('getColorScheme', '() => ("dark" | "light")') %} You can use the Appearance module to determine if the user prefers a dark color scheme. Although the color scheme is available immediately, this may change (e.g. scheduled color scheme change at sunrise or sunset). Any rendering logic or styles that depend on the user preferred color scheme should try to call this function on every render, rather than caching the value. {% endcall %} + +{% call macro.prop('setColorScheme', '("dark" | "light" | "auto") => void') %} +Override the color scheme for the application. Use `"auto"` to remove the override and follow the user's preferred color scheme again. +{% endcall %} diff --git a/packages/react-native-web/src/exports/Appearance/__tests__/index-test.js b/packages/react-native-web/src/exports/Appearance/__tests__/index-test.js new file mode 100644 index 0000000000..7f12db3038 --- /dev/null +++ b/packages/react-native-web/src/exports/Appearance/__tests__/index-test.js @@ -0,0 +1,120 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +function createMediaQueryList(initialMatches = false) { + let matches = initialMatches; + const listeners = new Set(); + + return { + addListener(listener) { + listeners.add(listener); + }, + emit(nextMatches) { + matches = nextMatches; + listeners.forEach((listener) => listener({ matches })); + }, + get matches() { + return matches; + }, + removeListener(listener) { + listeners.delete(listener); + } + }; +} + +function loadAppearance(mediaQueryList) { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: jest.fn(() => mediaQueryList) + }); + + let Appearance; + jest.isolateModules(() => { + const module = require('..'); + Appearance = module.default ?? module; + }); + return Appearance; +} + +const mediaQueryList = createMediaQueryList(false); +const Appearance = loadAppearance(mediaQueryList); + +describe('Appearance', () => { + afterEach(() => { + if (Appearance.setColorScheme) { + Appearance.setColorScheme('auto'); + } + mediaQueryList.emit(false); + }); + + test('overrides the system scheme and restores automatic behavior', () => { + expect(Appearance.getColorScheme()).toBe('light'); + + Appearance.setColorScheme('dark'); + expect(Appearance.getColorScheme()).toBe('dark'); + + mediaQueryList.emit(true); + Appearance.setColorScheme('light'); + expect(Appearance.getColorScheme()).toBe('light'); + + Appearance.setColorScheme('auto'); + expect(Appearance.getColorScheme()).toBe('dark'); + + Appearance.setColorScheme('light'); + Appearance.setColorScheme('unspecified'); + expect(Appearance.getColorScheme()).toBe('dark'); + + Appearance.setColorScheme('light'); + Appearance.setColorScheme(null); + expect(Appearance.getColorScheme()).toBe('dark'); + }); + + test('notifies listeners when the effective scheme changes', () => { + const listener = jest.fn(); + const subscription = Appearance.addChangeListener(listener); + + expect(Appearance.getColorScheme()).toBe('light'); + Appearance.setColorScheme('dark'); + expect(listener).toHaveBeenLastCalledWith({ colorScheme: 'dark' }); + + mediaQueryList.emit(true); + mediaQueryList.emit(false); + expect(listener).toHaveBeenCalledTimes(1); + + Appearance.setColorScheme('auto'); + expect(listener).toHaveBeenLastCalledWith({ colorScheme: 'light' }); + + mediaQueryList.emit(true); + expect(listener).toHaveBeenLastCalledWith({ colorScheme: 'dark' }); + + Appearance.setColorScheme('dark'); + expect(listener).toHaveBeenCalledTimes(3); + + subscription.remove(); + Appearance.setColorScheme('light'); + expect(listener).toHaveBeenCalledTimes(3); + }); + + test('uses a stable listener snapshot during override changes', () => { + const secondListener = jest.fn(); + let removeSecondListener = () => {}; + const firstListener = jest.fn(() => { + removeSecondListener(); + }); + const firstSubscription = Appearance.addChangeListener(firstListener); + const secondSubscription = Appearance.addChangeListener(secondListener); + removeSecondListener = secondSubscription.remove; + + Appearance.setColorScheme('dark'); + + expect(firstListener).toHaveBeenCalledTimes(1); + expect(secondListener).toHaveBeenCalledTimes(1); + firstSubscription.remove(); + }); +}); diff --git a/packages/react-native-web/src/exports/Appearance/index.js b/packages/react-native-web/src/exports/Appearance/index.js index 3942859775..77a296016e 100644 --- a/packages/react-native-web/src/exports/Appearance/index.js +++ b/packages/react-native-web/src/exports/Appearance/index.js @@ -18,6 +18,7 @@ export type AppearancePreferences = {| colorScheme: ColorSchemeName |}; +type ColorSchemeSetting = ColorSchemeName | 'auto' | 'unspecified' | null; type AppearanceListener = (preferences: AppearancePreferences) => void; type DOMAppearanceListener = (ev: MediaQueryListEvent) => any; @@ -28,21 +29,47 @@ function getQuery(): MediaQueryList | null { } const query = getQuery(); +let colorSchemeOverride: ColorSchemeName | null = null; +const appearanceListeners = new Set(); const listenerMapping = new WeakMap< AppearanceListener, DOMAppearanceListener >(); +function getSystemColorScheme(): ColorSchemeName { + return query && query.matches ? 'dark' : 'light'; +} + +function notifyListeners(colorScheme: ColorSchemeName): void { + Array.from(appearanceListeners).forEach((listener) => { + listener({ colorScheme }); + }); +} + const Appearance = { getColorScheme(): ColorSchemeName { - return query && query.matches ? 'dark' : 'light'; + return colorSchemeOverride ?? getSystemColorScheme(); + }, + + setColorScheme(colorScheme: ColorSchemeSetting): void { + const previousColorScheme = Appearance.getColorScheme(); + colorSchemeOverride = + colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : null; + const nextColorScheme = Appearance.getColorScheme(); + + if (nextColorScheme !== previousColorScheme) { + notifyListeners(nextColorScheme); + } }, addChangeListener(listener: AppearanceListener): { remove: () => void } { + appearanceListeners.add(listener); let mappedListener = listenerMapping.get(listener); if (!mappedListener) { - mappedListener = ({ matches }: MediaQueryListEvent) => { - listener({ colorScheme: matches ? 'dark' : 'light' }); + mappedListener = () => { + if (colorSchemeOverride == null) { + listener({ colorScheme: getSystemColorScheme() }); + } }; listenerMapping.set(listener, mappedListener); } @@ -55,6 +82,7 @@ const Appearance = { if (query && mappedListener) { query.removeListener(mappedListener); } + appearanceListeners.delete(listener); listenerMapping.delete(listener); }