@sigx/lynx-appearance 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/android/com/sigx/appearance/AppearanceModule.kt +160 -0
- package/android/com/sigx/appearance/AppearancePublisher.kt +113 -0
- package/dist/globals.d.ts +21 -0
- package/dist/globals.d.ts.map +1 -0
- package/dist/globals.js +25 -0
- package/dist/globals.js.map +1 -0
- package/dist/hooks.d.ts +25 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +38 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/injectable.d.ts +16 -0
- package/dist/injectable.d.ts.map +1 -0
- package/dist/injectable.js +10 -0
- package/dist/injectable.js.map +1 -0
- package/dist/provider.d.ts +27 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +59 -0
- package/dist/provider.js.map +1 -0
- package/dist/setters.d.ts +58 -0
- package/dist/setters.d.ts.map +1 -0
- package/dist/setters.js +87 -0
- package/dist/setters.js.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/ios/AppearanceModule.swift +130 -0
- package/ios/AppearancePublisher.swift +86 -0
- package/package.json +61 -0
- package/signalx-module.json +22 -0
- package/src/globals.ts +40 -0
- package/src/hooks.ts +46 -0
- package/src/index.ts +33 -0
- package/src/injectable.ts +20 -0
- package/src/provider.tsx +89 -0
- package/src/setters.ts +87 -0
- package/src/types.ts +25 -0
package/dist/setters.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { callAsync, isModuleAvailable } from '@sigx/lynx-core';
|
|
2
|
+
const MODULE = 'Appearance';
|
|
3
|
+
const UNSUPPORTED = { ok: false, reason: 'unsupported' };
|
|
4
|
+
/**
|
|
5
|
+
* Set the status-bar *content* tint (clock + icons).
|
|
6
|
+
*
|
|
7
|
+
* - `'light'` = light content (white-ish icons) — use when behind a dark theme.
|
|
8
|
+
* - `'dark'` = dark content — use when behind a light theme.
|
|
9
|
+
*
|
|
10
|
+
* On iOS the host VC must forward `preferredStatusBarStyle` to
|
|
11
|
+
* `AppearanceModule.preferredStatusBarStyle` for this to take effect — the
|
|
12
|
+
* lynx-cli iOS template wires that automatically.
|
|
13
|
+
*
|
|
14
|
+
* Resolves `{ ok: false, reason: 'unsupported' }` (never rejects) when the
|
|
15
|
+
* native module isn't registered — typical in web preview, SSR, tests, or
|
|
16
|
+
* apps that don't link the module. Fire-and-forget callers can therefore
|
|
17
|
+
* `void setStatusBarStyle(...)` without risking unhandled rejections.
|
|
18
|
+
*/
|
|
19
|
+
export function setStatusBarStyle(style) {
|
|
20
|
+
if (!isAvailable())
|
|
21
|
+
return Promise.resolve(UNSUPPORTED);
|
|
22
|
+
return callAsync(MODULE, 'setStatusBarStyle', { style });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Android only — set the status-bar background color. Pass `null` (or
|
|
26
|
+
* omit / pass `'transparent'`) to clear. iOS resolves `{ ok: false,
|
|
27
|
+
* reason: 'unsupported' }` since iOS has no separate status-bar background.
|
|
28
|
+
*
|
|
29
|
+
* On Android 15+ (API 35) edge-to-edge is enforced and this call is a no-op
|
|
30
|
+
* at the system level — callers should overlay their own background view
|
|
31
|
+
* inside the safe-area top padding.
|
|
32
|
+
*
|
|
33
|
+
* Resolves `{ ok: false, reason: 'unsupported' }` (never rejects) when the
|
|
34
|
+
* native module isn't registered. See `setStatusBarStyle` for the rationale.
|
|
35
|
+
*/
|
|
36
|
+
export function setStatusBarBackgroundColor(color) {
|
|
37
|
+
if (!isAvailable())
|
|
38
|
+
return Promise.resolve(UNSUPPORTED);
|
|
39
|
+
return callAsync(MODULE, 'setStatusBarBackgroundColor', { color });
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Android only — set the navigation-bar content tint + optional background.
|
|
43
|
+
* iOS resolves `{ ok: false, reason: 'unsupported' }` since there's no
|
|
44
|
+
* separate navigation bar.
|
|
45
|
+
*
|
|
46
|
+
* Resolves `{ ok: false, reason: 'unsupported' }` (never rejects) when the
|
|
47
|
+
* native module isn't registered. See `setStatusBarStyle` for the rationale.
|
|
48
|
+
*/
|
|
49
|
+
export function setNavigationBarStyle(opts) {
|
|
50
|
+
if (!isAvailable())
|
|
51
|
+
return Promise.resolve(UNSUPPORTED);
|
|
52
|
+
return callAsync(MODULE, 'setNavigationBarStyle', opts);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Convenience: apply status-bar tint + (optionally) status-bar background +
|
|
56
|
+
* nav-bar tint in one call. Resolves to the aggregate result — `ok: false`
|
|
57
|
+
* if any leg returned a non-`unsupported` failure, with the first such
|
|
58
|
+
* failure's reason. `unsupported` legs (e.g. status-bar background on iOS)
|
|
59
|
+
* are intentionally ignored so a partially-supported platform can still
|
|
60
|
+
* report success for the legs that do apply.
|
|
61
|
+
*
|
|
62
|
+
* Fields are optional; omitting any leaves that surface untouched. Order is
|
|
63
|
+
* deterministic (statusBar → statusBarBackground → navigationBar) so the
|
|
64
|
+
* resolved reason on partial failure is unambiguous.
|
|
65
|
+
*/
|
|
66
|
+
export async function setSystemBarsStyle(opts) {
|
|
67
|
+
if (!isAvailable())
|
|
68
|
+
return UNSUPPORTED;
|
|
69
|
+
let firstFailure;
|
|
70
|
+
const record = (r) => {
|
|
71
|
+
if (!r.ok && !firstFailure && r.reason !== 'unsupported')
|
|
72
|
+
firstFailure = r;
|
|
73
|
+
};
|
|
74
|
+
if (opts.statusBar)
|
|
75
|
+
record(await setStatusBarStyle(opts.statusBar));
|
|
76
|
+
if (opts.statusBarBackground !== undefined) {
|
|
77
|
+
record(await setStatusBarBackgroundColor(opts.statusBarBackground));
|
|
78
|
+
}
|
|
79
|
+
if (opts.navigationBar)
|
|
80
|
+
record(await setNavigationBarStyle(opts.navigationBar));
|
|
81
|
+
return firstFailure ?? { ok: true };
|
|
82
|
+
}
|
|
83
|
+
/** Quick check whether the native Appearance module is registered. */
|
|
84
|
+
export function isAvailable() {
|
|
85
|
+
return isModuleAvailable(MODULE);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=setters.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setters.js","sourceRoot":"","sources":["../src/setters.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAG/D,MAAM,MAAM,GAAG,YAAY,CAAC;AAE5B,MAAM,WAAW,GAAiB,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;AAEvE;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAqB;IACrD,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACxD,OAAO,SAAS,CAAe,MAAM,EAAE,mBAAmB,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,2BAA2B,CAAC,KAAoB;IAC9D,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACxD,OAAO,SAAS,CAAe,MAAM,EAAE,6BAA6B,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;AACnF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAA+C;IACnF,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACxD,OAAO,SAAS,CAAe,MAAM,EAAE,uBAAuB,EAAE,IAAI,CAAC,CAAC;AACxE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,IAA0B;IACjE,IAAI,CAAC,WAAW,EAAE;QAAE,OAAO,WAAW,CAAC;IACvC,IAAI,YAAsC,CAAC;IAC3C,MAAM,MAAM,GAAG,CAAC,CAAe,EAAQ,EAAE;QACvC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC,MAAM,KAAK,aAAa;YAAE,YAAY,GAAG,CAAC,CAAC;IAC7E,CAAC,CAAC;IACF,IAAI,IAAI,CAAC,SAAS;QAAE,MAAM,CAAC,MAAM,iBAAiB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC;IACpE,IAAI,IAAI,CAAC,mBAAmB,KAAK,SAAS,EAAE,CAAC;QAC3C,MAAM,CAAC,MAAM,2BAA2B,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACtE,CAAC;IACD,IAAI,IAAI,CAAC,aAAa;QAAE,MAAM,CAAC,MAAM,qBAAqB,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC;IAChF,OAAO,YAAY,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACtC,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,WAAW;IACzB,OAAO,iBAAiB,CAAC,MAAM,CAAC,CAAC;AACnC,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The two color schemes the platform reports. iOS `unspecified` and
|
|
2
|
+
* Android `UI_MODE_NIGHT_UNDEFINED` both collapse to `'light'` at the
|
|
3
|
+
* native publisher boundary so JS only ever sees these two values. */
|
|
4
|
+
export type ColorScheme = 'light' | 'dark';
|
|
5
|
+
/** Tint of system-bar *content* (clock, icons), not its background. */
|
|
6
|
+
export type SystemBarStyle = 'light' | 'dark';
|
|
7
|
+
/** Result envelope returned by every native setter. */
|
|
8
|
+
export interface SetterResult {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
/** Present when `ok === false` — `'unsupported'` on iOS for nav-bar and
|
|
11
|
+
* status-bar-background calls, or an Android failure message. */
|
|
12
|
+
reason?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Argument to `setSystemBarsStyle` — partial; omitted fields are left alone. */
|
|
15
|
+
export interface SystemBarsStyleInput {
|
|
16
|
+
/** Status-bar content tint. `'light'` = light icons (legible on dark bg). */
|
|
17
|
+
statusBar?: SystemBarStyle;
|
|
18
|
+
/** Android only — status-bar background color. `null` clears (transparent). */
|
|
19
|
+
statusBarBackground?: string | null;
|
|
20
|
+
/** Android only — navigation-bar tint + optional background. */
|
|
21
|
+
navigationBar?: {
|
|
22
|
+
style: SystemBarStyle;
|
|
23
|
+
color?: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;uEAEuE;AACvE,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;AAE3C,uEAAuE;AACvE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,MAAM,CAAC;AAE9C,uDAAuD;AACvD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ;sEACkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,iFAAiF;AACjF,MAAM,WAAW,oBAAoB;IACnC,6EAA6E;IAC7E,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,+EAA+E;IAC/E,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,gEAAgE;IAChE,aAAa,CAAC,EAAE;QAAE,KAAK,EAAE,cAAc,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3D"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import Lynx
|
|
3
|
+
|
|
4
|
+
/// System-bar appearance setters + a sync getter for the current color scheme.
|
|
5
|
+
/// JS usage:
|
|
6
|
+
/// NativeModules.Appearance.setStatusBarStyle({ "style": "light" }, callback)
|
|
7
|
+
/// NativeModules.Appearance.getColorScheme(callback)
|
|
8
|
+
///
|
|
9
|
+
/// iOS exposes two routes to control status-bar style; we try both so apps
|
|
10
|
+
/// don't have to wire boilerplate:
|
|
11
|
+
///
|
|
12
|
+
/// 1. **VC-controlled (modern, default)** — iOS reads
|
|
13
|
+
/// `preferredStatusBarStyle` from the root view controller. The host app
|
|
14
|
+
/// overrides that getter to return `AppearanceModule.preferredStatusBarStyle`
|
|
15
|
+
/// (the static we update). We call `setNeedsStatusBarAppearanceUpdate()` to
|
|
16
|
+
/// force a re-evaluation.
|
|
17
|
+
///
|
|
18
|
+
/// 2. **UIApplication-controlled (legacy)** — if the host's Info.plist has
|
|
19
|
+
/// `UIViewControllerBasedStatusBarAppearance = NO`, iOS reads style from
|
|
20
|
+
/// `UIApplication.shared` directly. We invoke the deprecated setter via
|
|
21
|
+
/// `perform(_:with:with:)` so SwiftUI hosts (which can't easily override
|
|
22
|
+
/// their hidden UIHostingController) get a working path with one plist
|
|
23
|
+
/// change. Apps that leave the plist default (YES) get the silent no-op
|
|
24
|
+
/// for this leg — Path 1 covers them.
|
|
25
|
+
///
|
|
26
|
+
/// `setStatusBarBackgroundColor` and `setNavigationBarStyle` are Android-only
|
|
27
|
+
/// in practice. iOS resolves the system-bar background from the underlying
|
|
28
|
+
/// view's color and has no separate navigation bar. We return
|
|
29
|
+
/// `{ ok: false, reason: "unsupported" }` so JS callers can decide whether to
|
|
30
|
+
/// treat that as an error.
|
|
31
|
+
class AppearanceModule: NSObject, LynxModule {
|
|
32
|
+
|
|
33
|
+
/// Last requested status-bar style; the host VC's
|
|
34
|
+
/// `preferredStatusBarStyle` should return this.
|
|
35
|
+
@objc public static var preferredStatusBarStyle: UIStatusBarStyle = .default
|
|
36
|
+
|
|
37
|
+
@objc static var name: String { "Appearance" }
|
|
38
|
+
|
|
39
|
+
@objc static var methodLookup: [String: String] {
|
|
40
|
+
[
|
|
41
|
+
"setStatusBarStyle": NSStringFromSelector(#selector(setStatusBarStyle(_:callback:))),
|
|
42
|
+
"setStatusBarBackgroundColor": NSStringFromSelector(#selector(setStatusBarBackgroundColor(_:callback:))),
|
|
43
|
+
"setNavigationBarStyle": NSStringFromSelector(#selector(setNavigationBarStyle(_:callback:))),
|
|
44
|
+
"getColorScheme": NSStringFromSelector(#selector(getColorScheme(_:))),
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
required override init() { super.init() }
|
|
49
|
+
required init(param: Any) { super.init() }
|
|
50
|
+
|
|
51
|
+
@objc func setStatusBarStyle(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
|
|
52
|
+
let style = (params?["style"] as? String) ?? "default"
|
|
53
|
+
// 'light' style means "light content" — i.e. white icons on a dark
|
|
54
|
+
// background. 'dark' means dark icons on a light background.
|
|
55
|
+
// iOS uses lightContent / darkContent to express the *content* color.
|
|
56
|
+
let resolved: UIStatusBarStyle
|
|
57
|
+
switch style {
|
|
58
|
+
case "light":
|
|
59
|
+
resolved = .lightContent
|
|
60
|
+
case "dark":
|
|
61
|
+
resolved = .darkContent
|
|
62
|
+
default:
|
|
63
|
+
resolved = .default
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
DispatchQueue.main.async {
|
|
67
|
+
AppearanceModule.preferredStatusBarStyle = resolved
|
|
68
|
+
|
|
69
|
+
// Path 1 — VC-controlled (the modern, recommended path):
|
|
70
|
+
// re-evaluate `preferredStatusBarStyle` on every key window's
|
|
71
|
+
// root view controller. The host app's VC must override
|
|
72
|
+
// `preferredStatusBarStyle` to return
|
|
73
|
+
// `AppearanceModule.preferredStatusBarStyle` for this to take
|
|
74
|
+
// effect.
|
|
75
|
+
for scene in UIApplication.shared.connectedScenes {
|
|
76
|
+
guard let windowScene = scene as? UIWindowScene else { continue }
|
|
77
|
+
for window in windowScene.windows {
|
|
78
|
+
window.rootViewController?.setNeedsStatusBarAppearanceUpdate()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Path 2 — UIApplication-controlled (legacy, deprecated but
|
|
83
|
+
// still functional when `UIViewControllerBasedStatusBarAppearance`
|
|
84
|
+
// is `false` in Info.plist). For SwiftUI hosts where overriding
|
|
85
|
+
// a UIHostingController's preferredStatusBarStyle is awkward,
|
|
86
|
+
// setting that plist key to NO and letting this path do the work
|
|
87
|
+
// is the path of least resistance. No-op on apps that leave the
|
|
88
|
+
// plist key default (true) — iOS silently ignores the call.
|
|
89
|
+
//
|
|
90
|
+
// Invoked via `perform(_:with:with:)` so the deprecation warning
|
|
91
|
+
// doesn't fire at this single intentional call site.
|
|
92
|
+
let plist = Bundle.main.infoDictionary
|
|
93
|
+
let vcBased = (plist?["UIViewControllerBasedStatusBarAppearance"] as? Bool) ?? true
|
|
94
|
+
if !vcBased {
|
|
95
|
+
let app = UIApplication.shared
|
|
96
|
+
let sel = NSSelectorFromString("setStatusBarStyle:animated:")
|
|
97
|
+
if app.responds(to: sel) {
|
|
98
|
+
app.perform(sel, with: resolved.rawValue, with: true)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
callback?(["ok": true])
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@objc func setStatusBarBackgroundColor(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
|
|
107
|
+
// No-op on iOS — the status bar background is the underlying view's
|
|
108
|
+
// color, not a system property we can set.
|
|
109
|
+
_ = params
|
|
110
|
+
callback?(["ok": false, "reason": "unsupported"])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@objc func setNavigationBarStyle(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
|
|
114
|
+
// No-op on iOS — there's no separate navigation bar to style.
|
|
115
|
+
_ = params
|
|
116
|
+
callback?(["ok": false, "reason": "unsupported"])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@objc func getColorScheme(_ callback: LynxCallbackBlock?) {
|
|
120
|
+
// Read from the active key window so we pick up the user's preference
|
|
121
|
+
// even if no LynxView has fired its publisher yet.
|
|
122
|
+
let style = UIApplication.shared.connectedScenes
|
|
123
|
+
.compactMap { $0 as? UIWindowScene }
|
|
124
|
+
.first(where: { $0.activationState == .foregroundActive })?
|
|
125
|
+
.windows.first?.traitCollection.userInterfaceStyle
|
|
126
|
+
?? UITraitCollection.current.userInterfaceStyle
|
|
127
|
+
let scheme = style == .dark ? "dark" : "light"
|
|
128
|
+
callback?(["colorScheme": scheme])
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import Lynx
|
|
3
|
+
|
|
4
|
+
/// Publishes the host's current system color scheme (light / dark) to JS via:
|
|
5
|
+
///
|
|
6
|
+
/// 1. **`lynx.__globalProps.appearance`** — populated synchronously via
|
|
7
|
+
/// `LynxView.updateGlobalProps(withDictionary:)` before MT first paint, so
|
|
8
|
+
/// apps can render the correct theme on cold start without a flash.
|
|
9
|
+
///
|
|
10
|
+
/// 2. **`appearanceChanged` global event** — fired via
|
|
11
|
+
/// `LynxView.sendGlobalEvent(_:withParams:)` whenever the trait collection
|
|
12
|
+
/// flips light/dark. JS-side `<AppearanceProvider>` subscribes via
|
|
13
|
+
/// `lynx.getJSModule("GlobalEventEmitter").addListener` and updates its
|
|
14
|
+
/// reactive signal so consumers (e.g. `<ThemeProvider>` with `followSystem`)
|
|
15
|
+
/// swap themes live.
|
|
16
|
+
///
|
|
17
|
+
/// One publisher per `LynxView`. The autolinker wires
|
|
18
|
+
/// `GeneratedLifecyclePublishers.attachAll(to: lynxView)` to instantiate this
|
|
19
|
+
/// after each LynxView is built; the host coordinator retains the instance for
|
|
20
|
+
/// the LynxView's lifetime.
|
|
21
|
+
final class AppearancePublisher {
|
|
22
|
+
private weak var lynxView: LynxView?
|
|
23
|
+
private var lastScheme: String = ""
|
|
24
|
+
|
|
25
|
+
init(lynxView: LynxView) {
|
|
26
|
+
self.lynxView = lynxView
|
|
27
|
+
|
|
28
|
+
// Observe trait-collection changes via the window — handles the
|
|
29
|
+
// Settings → Display & Brightness → Appearance flip in real time.
|
|
30
|
+
// UIScreen.traitCollectionDidChange isn't reliable here (only fires
|
|
31
|
+
// when the screen *itself* changes), so we listen for the
|
|
32
|
+
// app-foreground notification (catches changes made while the app was
|
|
33
|
+
// backgrounded) and rely on the LynxContainerView's trait observer to
|
|
34
|
+
// call back into us via `republish(scheme:)` on every real-time flip.
|
|
35
|
+
NotificationCenter.default.addObserver(
|
|
36
|
+
self,
|
|
37
|
+
selector: #selector(handleAppearanceMayHaveChanged),
|
|
38
|
+
name: UIApplication.didBecomeActiveNotification,
|
|
39
|
+
object: nil
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Seed on the next runloop tick — the LynxView's window may not be
|
|
43
|
+
// attached yet at construction time, so reading `traitCollection`
|
|
44
|
+
// immediately can yield the unspecified style.
|
|
45
|
+
DispatchQueue.main.async { [weak self] in
|
|
46
|
+
self?.publish()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
deinit {
|
|
51
|
+
NotificationCenter.default.removeObserver(self)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@objc private func handleAppearanceMayHaveChanged() {
|
|
55
|
+
publish()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Force a republish. Called from the seed tick, the app-foreground
|
|
59
|
+
/// observer, and (optionally) from a host-side trait-collection observer
|
|
60
|
+
/// that calls into `republish(scheme:)` when the real-time flip happens.
|
|
61
|
+
@discardableResult
|
|
62
|
+
@objc func publish() -> Bool {
|
|
63
|
+
guard let view = lynxView else { return false }
|
|
64
|
+
let style = view.traitCollection.userInterfaceStyle
|
|
65
|
+
let scheme = style == .dark ? "dark" : "light"
|
|
66
|
+
return republish(scheme: scheme)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@discardableResult
|
|
70
|
+
func republish(scheme: String) -> Bool {
|
|
71
|
+
guard let view = lynxView else { return false }
|
|
72
|
+
if scheme == lastScheme { return false }
|
|
73
|
+
lastScheme = scheme
|
|
74
|
+
|
|
75
|
+
let map: [String: Any] = ["colorScheme": scheme]
|
|
76
|
+
|
|
77
|
+
// Channel 1: __globalProps — sync MT read at first paint.
|
|
78
|
+
view.updateGlobalProps(with: ["appearance": map])
|
|
79
|
+
|
|
80
|
+
// Channel 2: appearanceChanged event — live update for the BG-side
|
|
81
|
+
// <AppearanceProvider> listener. First param is the map itself;
|
|
82
|
+
// GlobalEventEmitter delivers it as the listener's first argument.
|
|
83
|
+
view.sendGlobalEvent("appearanceChanged", withParams: [map])
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sigx/lynx-appearance",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "System appearance (color scheme observation + status/navigation bar tint setters) for sigx-lynx",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./signalx-module.json": "./signalx-module.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"dist",
|
|
18
|
+
"ios",
|
|
19
|
+
"android",
|
|
20
|
+
"signalx-module.json"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"sigx",
|
|
24
|
+
"lynx",
|
|
25
|
+
"appearance",
|
|
26
|
+
"color-scheme",
|
|
27
|
+
"dark-mode",
|
|
28
|
+
"status-bar"
|
|
29
|
+
],
|
|
30
|
+
"author": "Andreas Ekdahl",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@sigx/reactivity": "^0.4.8",
|
|
34
|
+
"@sigx/lynx": "^0.4.1",
|
|
35
|
+
"@sigx/lynx-core": "^0.4.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@typescript/native-preview": "7.0.0-dev.20260521.1",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"@sigx/lynx-plugin": "^0.4.1",
|
|
41
|
+
"@sigx/lynx-testing": "^0.4.1",
|
|
42
|
+
"@sigx/lynx-runtime-main": "^0.4.1"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/signalxjs/lynx.git",
|
|
47
|
+
"directory": "packages/lynx-appearance"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-appearance",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/signalxjs/lynx/issues"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "node ../../scripts/clean.mjs dist && tsgo",
|
|
58
|
+
"dev": "tsgo --watch",
|
|
59
|
+
"clean": "node ../../scripts/clean.mjs dist .turbo"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Appearance",
|
|
3
|
+
"package": "@sigx/lynx-appearance",
|
|
4
|
+
"description": "System color-scheme observer + status-bar / navigation-bar tint setters",
|
|
5
|
+
"platforms": ["android", "ios"],
|
|
6
|
+
"ios": {
|
|
7
|
+
"moduleClass": "AppearanceModule",
|
|
8
|
+
"publisherClass": "AppearancePublisher",
|
|
9
|
+
"sourceDir": "ios",
|
|
10
|
+
"methods": [
|
|
11
|
+
"setStatusBarStyle",
|
|
12
|
+
"setStatusBarBackgroundColor",
|
|
13
|
+
"setNavigationBarStyle",
|
|
14
|
+
"getColorScheme"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"android": {
|
|
18
|
+
"moduleClass": "com.sigx.appearance.AppearanceModule",
|
|
19
|
+
"publisherClass": "com.sigx.appearance.AppearancePublisher",
|
|
20
|
+
"sourceDir": "android"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/globals.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ColorScheme } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Key under `lynx.__globalProps` where the native publisher writes the
|
|
5
|
+
* appearance map. Single string shared by iOS / Android publishers, the JS
|
|
6
|
+
* reader, and tests.
|
|
7
|
+
*/
|
|
8
|
+
export const GLOBAL_PROPS_KEY = 'appearance';
|
|
9
|
+
|
|
10
|
+
export interface RawAppearanceProps {
|
|
11
|
+
colorScheme?: ColorScheme;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface LynxGlobalLike {
|
|
15
|
+
__globalProps?: { [k: string]: unknown };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Closure-injected identifier from
|
|
19
|
+
// `@lynx-js/runtime-wrapper-webpack-plugin`'s `__init_card_bundle__` wrapper;
|
|
20
|
+
// declared locally so the package doesn't need to depend on lynx-runtime-internal
|
|
21
|
+
// just for the ambient (same pattern as lynx-safe-area).
|
|
22
|
+
declare const lynx: unknown | undefined;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Synchronously read the current system color scheme from
|
|
26
|
+
* `lynx.__globalProps`. Returns `null` when the publisher hasn't populated
|
|
27
|
+
* yet (early cold start) or when running outside a Lynx host (web preview,
|
|
28
|
+
* SSR, tests). Callers should treat `null` as "unknown — fall back to your
|
|
29
|
+
* default theme".
|
|
30
|
+
*
|
|
31
|
+
* Safe on both BG and MT threads — `__globalProps` is mirrored across both.
|
|
32
|
+
*/
|
|
33
|
+
export function readGlobalColorScheme(): ColorScheme | null {
|
|
34
|
+
const lynxObj: LynxGlobalLike | undefined = typeof lynx !== 'undefined'
|
|
35
|
+
? (lynx as unknown as LynxGlobalLike)
|
|
36
|
+
: undefined;
|
|
37
|
+
const raw = lynxObj?.__globalProps?.[GLOBAL_PROPS_KEY] as RawAppearanceProps | undefined;
|
|
38
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
39
|
+
return raw.colorScheme === 'dark' ? 'dark' : raw.colorScheme === 'light' ? 'light' : null;
|
|
40
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { signal, type Computed, type PrimitiveSignal } from '@sigx/reactivity';
|
|
2
|
+
import { useAppearanceContext } from './injectable.js';
|
|
3
|
+
import { readGlobalColorScheme } from './globals.js';
|
|
4
|
+
import type { ColorScheme } from './types.js';
|
|
5
|
+
|
|
6
|
+
type ColorSchemeRead = PrimitiveSignal<ColorScheme> | Computed<ColorScheme>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* BG-side reactive read of the current system color scheme. Returns the
|
|
10
|
+
* live signal — components calling this re-render when the user flips dark
|
|
11
|
+
* mode in system settings.
|
|
12
|
+
*
|
|
13
|
+
* If no `<AppearanceProvider>` is in scope, returns a fallback signal seeded
|
|
14
|
+
* from `lynx.__globalProps` once at first call (still gives the correct
|
|
15
|
+
* value on cold start; just doesn't update live). This lets ThemeProvider
|
|
16
|
+
* use the hook even when its consumer hasn't mounted `<AppearanceProvider>`.
|
|
17
|
+
*/
|
|
18
|
+
export function useSystemColorScheme(): ColorSchemeRead {
|
|
19
|
+
const ctx = useAppearanceContext();
|
|
20
|
+
if (ctx) return ctx.colorScheme;
|
|
21
|
+
return fallbackSignal();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* MT-thread synchronous read of the current system color scheme. For use
|
|
26
|
+
* inside `'main thread'`-marked worklet bodies. Reads `lynx.__globalProps`
|
|
27
|
+
* directly — no subscription, callers re-evaluate per worklet invocation.
|
|
28
|
+
*
|
|
29
|
+
* Returns `'light'` when the publisher hasn't populated yet (cold start before
|
|
30
|
+
* first publish, or non-Lynx hosts).
|
|
31
|
+
*/
|
|
32
|
+
export function useSystemColorSchemeMT(): ColorScheme {
|
|
33
|
+
return readGlobalColorScheme() ?? 'light';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let _fallback: PrimitiveSignal<ColorScheme> | undefined;
|
|
37
|
+
function fallbackSignal(): PrimitiveSignal<ColorScheme> {
|
|
38
|
+
if (!_fallback) {
|
|
39
|
+
_fallback = signal<ColorScheme>(readGlobalColorScheme() ?? 'light');
|
|
40
|
+
}
|
|
41
|
+
return _fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Re-export a typed PrimitiveSignal/Computed pair so consumers can write
|
|
45
|
+
// `useSystemColorScheme().value` without importing from @sigx/reactivity.
|
|
46
|
+
export type { PrimitiveSignal, Computed } from '@sigx/reactivity';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Public API for @sigx/lynx-appearance.
|
|
2
|
+
|
|
3
|
+
export { AppearanceProvider, APPEARANCE_EVENT } from './provider.js';
|
|
4
|
+
export type { AppearanceProviderProps } from './provider.js';
|
|
5
|
+
|
|
6
|
+
export { useAppearanceContext } from './injectable.js';
|
|
7
|
+
export type { AppearanceContextValue } from './injectable.js';
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
useSystemColorScheme,
|
|
11
|
+
useSystemColorSchemeMT,
|
|
12
|
+
} from './hooks.js';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
setStatusBarStyle,
|
|
16
|
+
setStatusBarBackgroundColor,
|
|
17
|
+
setNavigationBarStyle,
|
|
18
|
+
setSystemBarsStyle,
|
|
19
|
+
isAvailable,
|
|
20
|
+
} from './setters.js';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
readGlobalColorScheme,
|
|
24
|
+
GLOBAL_PROPS_KEY,
|
|
25
|
+
} from './globals.js';
|
|
26
|
+
export type { RawAppearanceProps } from './globals.js';
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
ColorScheme,
|
|
30
|
+
SystemBarStyle,
|
|
31
|
+
SystemBarsStyleInput,
|
|
32
|
+
SetterResult,
|
|
33
|
+
} from './types.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineInjectable } from '@sigx/lynx';
|
|
2
|
+
import type { PrimitiveSignal } from '@sigx/reactivity';
|
|
3
|
+
import type { ColorScheme } from './types.js';
|
|
4
|
+
|
|
5
|
+
/** DI shape exposed by `<AppearanceProvider>`. */
|
|
6
|
+
export interface AppearanceContextValue {
|
|
7
|
+
/** BG-side reactive color scheme. Re-renders consumers on system flip. */
|
|
8
|
+
readonly colorScheme: PrimitiveSignal<ColorScheme>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The DI handle for the appearance context.
|
|
13
|
+
*
|
|
14
|
+
* Factory returns `null` so consumers outside a provider get a clear signal
|
|
15
|
+
* (vs. a phantom `'light'` signal that silently never updates). Hooks in
|
|
16
|
+
* `./hooks.ts` wrap this with the null-check + fallback signal.
|
|
17
|
+
*/
|
|
18
|
+
export const useAppearanceContext = defineInjectable<AppearanceContextValue | null>(
|
|
19
|
+
() => null,
|
|
20
|
+
);
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
component,
|
|
3
|
+
defineProvide,
|
|
4
|
+
signal,
|
|
5
|
+
onMounted,
|
|
6
|
+
onUnmounted,
|
|
7
|
+
type Define,
|
|
8
|
+
} from '@sigx/lynx';
|
|
9
|
+
import { useAppearanceContext, type AppearanceContextValue } from './injectable.js';
|
|
10
|
+
import { readGlobalColorScheme } from './globals.js';
|
|
11
|
+
import type { ColorScheme } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Event name fired by the native publisher (iOS `AppearancePublisher.swift`,
|
|
15
|
+
* Android `AppearancePublisher.kt`) via `GlobalEventEmitter` every time the
|
|
16
|
+
* host's system color scheme flips. Payload mirrors the same map stored under
|
|
17
|
+
* `lynx.__globalProps.appearance`.
|
|
18
|
+
*
|
|
19
|
+
* Kept as a constant so iOS/Android publishers and the JS listener agree on
|
|
20
|
+
* a single string.
|
|
21
|
+
*/
|
|
22
|
+
export const APPEARANCE_EVENT = 'appearanceChanged';
|
|
23
|
+
|
|
24
|
+
interface GlobalEventEmitterLike {
|
|
25
|
+
addListener: (name: string, fn: (...a: unknown[]) => void) => void;
|
|
26
|
+
removeListener: (name: string, fn: (...a: unknown[]) => void) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LynxLike {
|
|
30
|
+
getJSModule?: (name: string) => GlobalEventEmitterLike | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare const lynx: unknown | undefined;
|
|
34
|
+
|
|
35
|
+
export type AppearanceProviderProps =
|
|
36
|
+
& Define.Prop<'class', string, false>
|
|
37
|
+
& Define.Prop<'style', Record<string, string | number>, false>
|
|
38
|
+
& Define.Slot<'default'>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mount near the root of an app (above any consumer of `useSystemColorScheme`).
|
|
42
|
+
* Cheap — just one BG signal + one GlobalEventEmitter subscription. The
|
|
43
|
+
* native publisher writes `lynx.__globalProps.appearance` before MT first
|
|
44
|
+
* paint, so the initial value is correct on cold start with no flash.
|
|
45
|
+
*
|
|
46
|
+
* On platforms where the publisher isn't wired (web preview, tests),
|
|
47
|
+
* `readGlobalColorScheme()` returns `null` and we seed `'light'` as a safe
|
|
48
|
+
* default. Consumers can detect the unwired case via the live-update
|
|
49
|
+
* subscription never firing.
|
|
50
|
+
*/
|
|
51
|
+
export const AppearanceProvider = component<AppearanceProviderProps>(({ props, slots }) => {
|
|
52
|
+
const initial: ColorScheme = readGlobalColorScheme() ?? 'light';
|
|
53
|
+
const colorScheme = signal<ColorScheme>(initial);
|
|
54
|
+
|
|
55
|
+
const ctx: AppearanceContextValue = { colorScheme };
|
|
56
|
+
defineProvide(useAppearanceContext, () => ctx);
|
|
57
|
+
|
|
58
|
+
let listener: ((...a: unknown[]) => void) | undefined;
|
|
59
|
+
let emitter: GlobalEventEmitterLike | undefined;
|
|
60
|
+
|
|
61
|
+
onMounted(() => {
|
|
62
|
+
const lynxObj: LynxLike | undefined = typeof lynx !== 'undefined'
|
|
63
|
+
? (lynx as unknown as LynxLike)
|
|
64
|
+
: undefined;
|
|
65
|
+
emitter = lynxObj?.getJSModule?.('GlobalEventEmitter');
|
|
66
|
+
if (!emitter) return;
|
|
67
|
+
listener = (raw: unknown) => {
|
|
68
|
+
const next = normaliseScheme(raw);
|
|
69
|
+
if (next && next !== colorScheme.value) colorScheme.value = next;
|
|
70
|
+
};
|
|
71
|
+
emitter.addListener(APPEARANCE_EVENT, listener);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
onUnmounted(() => {
|
|
75
|
+
if (emitter && listener) emitter.removeListener(APPEARANCE_EVENT, listener);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return () => (
|
|
79
|
+
<view class={props.class} style={props.style}>
|
|
80
|
+
{slots.default?.()}
|
|
81
|
+
</view>
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function normaliseScheme(raw: unknown): ColorScheme | null {
|
|
86
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
87
|
+
const v = (raw as Record<string, unknown>)['colorScheme'];
|
|
88
|
+
return v === 'dark' ? 'dark' : v === 'light' ? 'light' : null;
|
|
89
|
+
}
|