@natsuneko-laboratory/react-native-desktop-navigation 0.1.0-alpha.12
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/DesktopNavigation.podspec +18 -0
- package/LICENSE +21 -0
- package/NATIVE.md +300 -0
- package/README.md +127 -0
- package/dist/core/NavigationContainer.d.ts +23 -0
- package/dist/core/NavigationContainer.js +68 -0
- package/dist/core/NavigationItem.d.ts +27 -0
- package/dist/core/NavigationItem.js +71 -0
- package/dist/core/Scene.d.ts +19 -0
- package/dist/core/Scene.js +124 -0
- package/dist/core/SelectionNavigator.d.ts +38 -0
- package/dist/core/SelectionNavigator.js +194 -0
- package/dist/core/builder.d.ts +40 -0
- package/dist/core/builder.js +81 -0
- package/dist/core/context.d.ts +26 -0
- package/dist/core/context.js +36 -0
- package/dist/core/focus.d.ts +14 -0
- package/dist/core/focus.js +39 -0
- package/dist/core/hooks.d.ts +8 -0
- package/dist/core/hooks.js +48 -0
- package/dist/core/navigation.d.ts +6 -0
- package/dist/core/navigation.js +57 -0
- package/dist/core/store.d.ts +39 -0
- package/dist/core/store.js +391 -0
- package/dist/core/types.d.ts +155 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +46 -0
- package/dist/native/host.d.ts +100 -0
- package/dist/native/host.js +190 -0
- package/dist/native/icons.d.ts +56 -0
- package/dist/native/icons.js +55 -0
- package/dist/native/index.d.ts +13 -0
- package/dist/native/index.js +44 -0
- package/dist/native/sidebar.d.ts +39 -0
- package/dist/native/sidebar.js +126 -0
- package/dist/native/specs/DesktopNavigationHostNativeComponent.d.ts +10 -0
- package/dist/native/specs/DesktopNavigationHostNativeComponent.js +4 -0
- package/dist/native/split.d.ts +27 -0
- package/dist/native/split.js +105 -0
- package/dist/native/stack.d.ts +26 -0
- package/dist/native/stack.js +66 -0
- package/dist/platform/index.d.ts +52 -0
- package/dist/platform/index.js +28 -0
- package/dist/platform/macos.d.ts +2 -0
- package/dist/platform/macos.js +18 -0
- package/dist/platform/windows.d.ts +2 -0
- package/dist/platform/windows.js +18 -0
- package/dist/routers/index.d.ts +23 -0
- package/dist/routers/index.js +393 -0
- package/dist/routers/types.d.ts +116 -0
- package/dist/routers/types.js +2 -0
- package/dist/sidebar/index.d.ts +17 -0
- package/dist/sidebar/index.js +18 -0
- package/dist/split/index.d.ts +42 -0
- package/dist/split/index.js +199 -0
- package/dist/stack/index.d.ts +23 -0
- package/dist/stack/index.js +88 -0
- package/dist/tabs/index.d.ts +13 -0
- package/dist/tabs/index.js +15 -0
- package/docs/GUIDE.md +384 -0
- package/macos/DDNNavigationComponentView.h +4 -0
- package/macos/DDNNavigationComponentView.mm +40 -0
- package/macos/DDNNavigationView.swift +398 -0
- package/macos/DDNNavigationViewManager.mm +11 -0
- package/package.json +82 -0
- package/react-native.config.js +19 -0
- package/src/core/NavigationContainer.tsx +137 -0
- package/src/core/NavigationItem.tsx +149 -0
- package/src/core/Scene.tsx +194 -0
- package/src/core/SelectionNavigator.tsx +371 -0
- package/src/core/builder.tsx +147 -0
- package/src/core/context.tsx +43 -0
- package/src/core/focus.tsx +50 -0
- package/src/core/hooks.ts +42 -0
- package/src/core/navigation.ts +77 -0
- package/src/core/store.ts +471 -0
- package/src/core/types.ts +176 -0
- package/src/index.ts +36 -0
- package/src/native/host.tsx +377 -0
- package/src/native/icons.ts +110 -0
- package/src/native/index.ts +56 -0
- package/src/native/sidebar.tsx +230 -0
- package/src/native/specs/DesktopNavigationHostNativeComponent.ts +12 -0
- package/src/native/split.tsx +160 -0
- package/src/native/stack.tsx +157 -0
- package/src/platform/index.tsx +76 -0
- package/src/platform/macos.ts +15 -0
- package/src/platform/windows.ts +15 -0
- package/src/routers/index.ts +442 -0
- package/src/routers/types.ts +117 -0
- package/src/sidebar/index.tsx +42 -0
- package/src/split/index.tsx +350 -0
- package/src/stack/index.tsx +213 -0
- package/src/tabs/index.tsx +40 -0
- package/windows/DesktopNavigation/DesktopNavigation.def +3 -0
- package/windows/DesktopNavigation/DesktopNavigation.vcxproj +125 -0
- package/windows/DesktopNavigation/NavigationHost.cpp +432 -0
- package/windows/DesktopNavigation/ReactPackageProvider.cpp +9 -0
- package/windows/DesktopNavigation/ReactPackageProvider.h +10 -0
- package/windows/DesktopNavigation/ReactPackageProvider.idl +6 -0
- package/windows/DesktopNavigation/pch.cpp +1 -0
- package/windows/DesktopNavigation/pch.h +27 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
private struct Item: Decodable, Identifiable {
|
|
5
|
+
var key: String
|
|
6
|
+
var title: String
|
|
7
|
+
var disabled: Bool?
|
|
8
|
+
var hidden: Bool?
|
|
9
|
+
var section: String?
|
|
10
|
+
var icon: SidebarIcon?
|
|
11
|
+
var badge: String?
|
|
12
|
+
var id: String { key }
|
|
13
|
+
}
|
|
14
|
+
// Resolved descriptors are shared with the Fabric JSON configuration.
|
|
15
|
+
private struct SidebarIcon: Decodable {
|
|
16
|
+
var type: String
|
|
17
|
+
var name: String?
|
|
18
|
+
var uri: String?
|
|
19
|
+
var template: Bool?
|
|
20
|
+
var size: Double
|
|
21
|
+
var color: String?
|
|
22
|
+
}
|
|
23
|
+
private struct SidebarIconView: View {
|
|
24
|
+
let icon: SidebarIcon
|
|
25
|
+
@State private var loaded: NSImage?
|
|
26
|
+
@State private var loadedURI: String?
|
|
27
|
+
@ViewBuilder var image: some View {
|
|
28
|
+
if icon.type == "symbol", let name = icon.name {
|
|
29
|
+
Image(systemName: name).resizable().scaledToFit()
|
|
30
|
+
} else if let loaded, loadedURI == icon.uri {
|
|
31
|
+
Image(nsImage: loaded).resizable()
|
|
32
|
+
.renderingMode(icon.template == true ? .template : .original).scaledToFit()
|
|
33
|
+
} else { Color.clear }
|
|
34
|
+
}
|
|
35
|
+
var body: some View {
|
|
36
|
+
Group {
|
|
37
|
+
if let tint = icon.color { image.foregroundStyle(color(tint, fallback: .primary)) }
|
|
38
|
+
else { image }
|
|
39
|
+
}
|
|
40
|
+
.frame(width: icon.size, height: icon.size)
|
|
41
|
+
.accessibilityHidden(true)
|
|
42
|
+
.task(id: icon.uri) {
|
|
43
|
+
loaded = nil
|
|
44
|
+
loadedURI = nil
|
|
45
|
+
guard icon.type == "image", let uri = icon.uri, let url = URL(string: uri) else { return }
|
|
46
|
+
do {
|
|
47
|
+
let data: Data
|
|
48
|
+
if url.isFileURL {
|
|
49
|
+
// File I/O does not block SwiftUI's main thread.
|
|
50
|
+
data = try await Task.detached { try Data(contentsOf: url) }.value
|
|
51
|
+
} else {
|
|
52
|
+
let (responseData, response) = try await URLSession.shared.data(from: url)
|
|
53
|
+
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { return }
|
|
54
|
+
data = responseData
|
|
55
|
+
}
|
|
56
|
+
guard !Task.isCancelled else { return }
|
|
57
|
+
loaded = NSImage(data: data)
|
|
58
|
+
loadedURI = uri
|
|
59
|
+
} catch { /* Keep the title usable when the image is unavailable. */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
private struct Column: Decodable {
|
|
64
|
+
var key: String
|
|
65
|
+
var width: Double
|
|
66
|
+
var minWidth: Double
|
|
67
|
+
var maxWidth: Double?
|
|
68
|
+
}
|
|
69
|
+
private struct Appearance: Decodable {
|
|
70
|
+
var backgroundColor: String?
|
|
71
|
+
var foregroundColor: String?
|
|
72
|
+
var accentColor: String?
|
|
73
|
+
var sidebarBackgroundColor: String?
|
|
74
|
+
}
|
|
75
|
+
private struct Configuration: Decodable {
|
|
76
|
+
var mode = "stack"
|
|
77
|
+
var items: [Item] = []
|
|
78
|
+
var activeKey = ""
|
|
79
|
+
var revision = 0
|
|
80
|
+
var appearance: Appearance?
|
|
81
|
+
var headerShown: Bool?
|
|
82
|
+
var canGoBack: Bool?
|
|
83
|
+
var backTitle: String?
|
|
84
|
+
var collapsed: Bool?
|
|
85
|
+
var paneWidth: Double?
|
|
86
|
+
var footerHeight: Double?
|
|
87
|
+
var columns: [Column]?
|
|
88
|
+
}
|
|
89
|
+
// Not a valid route key: route keys never start with a NUL character.
|
|
90
|
+
private let footerSlotKey = "\u{0}footer"
|
|
91
|
+
|
|
92
|
+
private func color(_ value: String?, fallback: Color) -> Color {
|
|
93
|
+
guard let value, value.first == "#",
|
|
94
|
+
let hex = UInt64(value.dropFirst(), radix: 16),
|
|
95
|
+
value.count == 7 || value.count == 9 else { return fallback }
|
|
96
|
+
let rgba = value.count == 7 ? (hex << 8) | 255 : hex
|
|
97
|
+
return Color(.sRGB, red: Double((rgba >> 24) & 255) / 255,
|
|
98
|
+
green: Double((rgba >> 16) & 255) / 255,
|
|
99
|
+
blue: Double((rgba >> 8) & 255) / 255, opacity: Double(rgba & 255) / 255)
|
|
100
|
+
}
|
|
101
|
+
private extension View {
|
|
102
|
+
/// Omitted appearance colors keep the adaptive system style instead of a fixed color.
|
|
103
|
+
@ViewBuilder func foregroundStyle(hex value: String?) -> some View {
|
|
104
|
+
if let value { foregroundStyle(color(value, fallback: .primary)) } else { self }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
private final class NavigationModel: ObservableObject {
|
|
108
|
+
@Published var configuration = Configuration()
|
|
109
|
+
var emit: ((String) -> Void)?
|
|
110
|
+
var frames: [String: CGRect] = [:]
|
|
111
|
+
var viewport = CGRect.zero
|
|
112
|
+
func send(_ value: [String: Any]) {
|
|
113
|
+
var message = value
|
|
114
|
+
message["revision"] = configuration.revision
|
|
115
|
+
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
|
116
|
+
let payload = String(data: data, encoding: .utf8) else { return }
|
|
117
|
+
emit?(payload)
|
|
118
|
+
}
|
|
119
|
+
weak var host: NSView?
|
|
120
|
+
// Weak anchors avoid retaining a removed SwiftUI row or its hosting subtree.
|
|
121
|
+
let iconAnchors = NSMapTable<NSString, IconAnchorView>(keyOptions: .strongMemory, valueOptions: .weakMemory)
|
|
122
|
+
private var scheduled = false
|
|
123
|
+
private var lastLayout = ""
|
|
124
|
+
func scheduleReport() {
|
|
125
|
+
guard !scheduled else { return }
|
|
126
|
+
scheduled = true
|
|
127
|
+
DispatchQueue.main.async { [weak self] in
|
|
128
|
+
guard let self else { return }
|
|
129
|
+
self.scheduled = false
|
|
130
|
+
self.report(self.frames)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
func report(_ values: [String: CGRect]) {
|
|
134
|
+
frames = values
|
|
135
|
+
func rect(_ value: CGRect) -> [String: CGFloat] {
|
|
136
|
+
let frame = value.isNull ? CGRect.zero : value
|
|
137
|
+
return ["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
|
|
138
|
+
}
|
|
139
|
+
var slots = values
|
|
140
|
+
let footer = slots.removeValue(forKey: footerSlotKey)
|
|
141
|
+
var icons: [String: Any] = [:]
|
|
142
|
+
if configuration.mode == "sidebar", configuration.collapsed != true, let host {
|
|
143
|
+
for item in configuration.items where item.hidden != true && item.icon?.type == "react" {
|
|
144
|
+
guard let anchor = iconAnchors.object(forKey: item.key as NSString),
|
|
145
|
+
anchor.window != nil, anchor.isDescendant(of: host), !anchor.isHiddenOrHasHiddenAncestor else { continue }
|
|
146
|
+
let frame = anchor.convert(anchor.bounds, to: host)
|
|
147
|
+
let clip = anchor.convert(anchor.visibleRect, to: host).intersection(viewport).intersection(frame)
|
|
148
|
+
icons[item.key] = ["frame": rect(frame), "clip": rect(clip)]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
var message: [String: Any] = ["type": "layout", "revision": configuration.revision,
|
|
152
|
+
"frames": slots.mapValues { rect($0.intersection(viewport)) }, "iconFrames": icons]
|
|
153
|
+
if configuration.mode == "sidebar", configuration.collapsed != true,
|
|
154
|
+
(configuration.footerHeight ?? 0) > 0, let footer {
|
|
155
|
+
let visible = footer.intersection(viewport)
|
|
156
|
+
if !visible.isNull, visible.width > 0, visible.height > 0 { message["footerFrame"] = rect(visible) }
|
|
157
|
+
}
|
|
158
|
+
guard let data = try? JSONSerialization.data(withJSONObject: message, options: .sortedKeys),
|
|
159
|
+
let payload = String(data: data, encoding: .utf8), payload != lastLayout else { return }
|
|
160
|
+
lastLayout = payload
|
|
161
|
+
emit?(payload)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Measuring the actual AppKit view's visibleRect includes the List's scroll
|
|
165
|
+
// viewport. A partially scrolled icon is clipped, never resized to fit.
|
|
166
|
+
private final class IconAnchorView: NSView {
|
|
167
|
+
weak var model: NavigationModel?
|
|
168
|
+
var key = ""
|
|
169
|
+
override var isFlipped: Bool { true }
|
|
170
|
+
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
171
|
+
override func layout() { super.layout(); model?.scheduleReport() }
|
|
172
|
+
override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); model?.scheduleReport() }
|
|
173
|
+
override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview(); model?.scheduleReport() }
|
|
174
|
+
}
|
|
175
|
+
private struct ReactIconAnchor: NSViewRepresentable {
|
|
176
|
+
let key: String
|
|
177
|
+
let model: NavigationModel
|
|
178
|
+
func makeNSView(context: Context) -> IconAnchorView { IconAnchorView() }
|
|
179
|
+
func updateNSView(_ view: IconAnchorView, context: Context) {
|
|
180
|
+
view.key = key
|
|
181
|
+
view.model = model
|
|
182
|
+
model.iconAnchors.setObject(view, forKey: key as NSString)
|
|
183
|
+
model.scheduleReport()
|
|
184
|
+
}
|
|
185
|
+
static func dismantleNSView(_ view: IconAnchorView, coordinator: ()) {
|
|
186
|
+
if view.model?.iconAnchors.object(forKey: view.key as NSString) === view {
|
|
187
|
+
view.model?.iconAnchors.removeObject(forKey: view.key as NSString)
|
|
188
|
+
view.model?.scheduleReport()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
}
|
|
193
|
+
private struct FramePreference: PreferenceKey {
|
|
194
|
+
static var defaultValue: [String: CGRect] { [:] }
|
|
195
|
+
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
|
196
|
+
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
private struct ContentSlot: View {
|
|
200
|
+
let id: String
|
|
201
|
+
var body: some View {
|
|
202
|
+
Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
203
|
+
.background(GeometryReader { geometry in
|
|
204
|
+
Color.clear.preference(key: FramePreference.self,
|
|
205
|
+
value: [id: geometry.frame(in: .named("DDNNavigation"))])
|
|
206
|
+
})
|
|
207
|
+
.accessibilityHidden(true)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
private struct FooterSlot: View {
|
|
211
|
+
var body: some View {
|
|
212
|
+
Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
213
|
+
.background(GeometryReader { geometry in
|
|
214
|
+
Color.clear.preference(key: FramePreference.self,
|
|
215
|
+
value: [footerSlotKey: geometry.frame(in: .named("DDNNavigation"))])
|
|
216
|
+
})
|
|
217
|
+
.accessibilityHidden(true)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
private struct NavigationRoot: View {
|
|
221
|
+
@ObservedObject var model: NavigationModel
|
|
222
|
+
var config: Configuration { model.configuration }
|
|
223
|
+
var visibility: Binding<NavigationSplitViewVisibility> {
|
|
224
|
+
Binding(get: { config.collapsed == true ? .detailOnly : .all }, set: { value in
|
|
225
|
+
guard config.mode == "sidebar" else { return }
|
|
226
|
+
model.send(["type": "collapse", "collapsed": value == .detailOnly])
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
var body: some View {
|
|
230
|
+
Group {
|
|
231
|
+
switch config.mode {
|
|
232
|
+
case "sidebar": sidebar
|
|
233
|
+
case "split": split
|
|
234
|
+
default: stack
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
238
|
+
// Sidebar rows apply the foreground individually so that selected rows keep
|
|
239
|
+
// the system's selected label color.
|
|
240
|
+
.foregroundStyle(hex: config.mode == "sidebar" ? nil : config.appearance?.foregroundColor)
|
|
241
|
+
.tint(config.appearance?.accentColor.map { color($0, fallback: .accentColor) })
|
|
242
|
+
.background(color(config.appearance?.backgroundColor, fallback: Color(nsColor: .windowBackgroundColor)))
|
|
243
|
+
.coordinateSpace(name: "DDNNavigation")
|
|
244
|
+
// RN scenes are composited over the measured native content slots. Avoid
|
|
245
|
+
// animating the placeholders independently of their React content.
|
|
246
|
+
.transaction { $0.disablesAnimations = true; $0.animation = nil }
|
|
247
|
+
.onPreferenceChange(FramePreference.self) { frames in
|
|
248
|
+
let revision = config.revision
|
|
249
|
+
// Defer events until after SwiftUI's layout pass has completed.
|
|
250
|
+
DispatchQueue.main.async {
|
|
251
|
+
guard model.configuration.revision == revision else { return }
|
|
252
|
+
model.report(frames)
|
|
253
|
+
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
.onChange(of: config.revision) {
|
|
257
|
+
DispatchQueue.main.async { model.report(model.frames) }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
var stack: some View {
|
|
261
|
+
VStack(spacing: 0) {
|
|
262
|
+
if config.headerShown != false {
|
|
263
|
+
HStack {
|
|
264
|
+
if config.canGoBack == true {
|
|
265
|
+
Button { model.send(["type": "back"]) } label: {
|
|
266
|
+
Label(config.backTitle ?? "Back", systemImage: "chevron.left")
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
Spacer()
|
|
270
|
+
Text(config.items.first(where: { $0.key == config.activeKey })?.title ?? "")
|
|
271
|
+
.font(.headline).lineLimit(1).accessibilityAddTraits(.isHeader)
|
|
272
|
+
Spacer()
|
|
273
|
+
}.padding(10)
|
|
274
|
+
Divider()
|
|
275
|
+
}
|
|
276
|
+
NavigationStack(path: Binding(get: { Array(config.items.dropFirst().map(\.key)) }, set: { path in
|
|
277
|
+
let count = config.items.count - 1 - path.count
|
|
278
|
+
if count > 0 { model.send(["type": "pop", "count": count]) }
|
|
279
|
+
})) {
|
|
280
|
+
ContentSlot(id: config.items.first?.key ?? "")
|
|
281
|
+
.navigationDestination(for: String.self) { key in
|
|
282
|
+
ContentSlot(id: key).navigationBarBackButtonHidden(true)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
var sidebar: some View {
|
|
288
|
+
NavigationSplitView(columnVisibility: visibility) {
|
|
289
|
+
VStack(spacing: 0) {
|
|
290
|
+
List(selection: Binding<String?>(get: { config.activeKey }, set: { key in
|
|
291
|
+
if let key { model.send(["type": "select", "key": key]) }
|
|
292
|
+
})) {
|
|
293
|
+
ForEach(Array(config.items.filter { $0.hidden != true }.enumerated()), id: \.element.key) { index, item in
|
|
294
|
+
if let section = item.section,
|
|
295
|
+
index == 0 || config.items.filter({ $0.hidden != true })[index - 1].section != section {
|
|
296
|
+
Text(section).font(.caption).foregroundStyle(.secondary).accessibilityAddTraits(.isHeader)
|
|
297
|
+
.selectionDisabled()
|
|
298
|
+
}
|
|
299
|
+
Group {
|
|
300
|
+
if let icon = item.icon {
|
|
301
|
+
Label { title(item) } icon: {
|
|
302
|
+
if icon.type == "react" {
|
|
303
|
+
ReactIconAnchor(key: item.key, model: model)
|
|
304
|
+
.frame(width: icon.size, height: icon.size).accessibilityHidden(true)
|
|
305
|
+
} else { SidebarIconView(icon: icon) }
|
|
306
|
+
}
|
|
307
|
+
.labelStyle(.titleAndIcon)
|
|
308
|
+
} else { title(item) }
|
|
309
|
+
}
|
|
310
|
+
.badge(item.badge.map { Text($0) })
|
|
311
|
+
.tag(item.key).disabled(item.disabled == true)
|
|
312
|
+
.selectionDisabled(item.disabled == true)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
.listStyle(.sidebar)
|
|
316
|
+
.scrollContentBackground(.hidden)
|
|
317
|
+
if let height = config.footerHeight, height > 0 {
|
|
318
|
+
FooterSlot().frame(height: height)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Without an explicit color, keep the translucent system sidebar material.
|
|
322
|
+
.background(config.appearance?.sidebarBackgroundColor.map { color($0, fallback: .clear) } ?? .clear)
|
|
323
|
+
.navigationSplitViewColumnWidth(min: 100, ideal: config.paneWidth ?? 240, max: 600)
|
|
324
|
+
} detail: { ContentSlot(id: config.activeKey) }
|
|
325
|
+
}
|
|
326
|
+
// Only the label is tinted, so selection neither overrides the system's selected
|
|
327
|
+
// label color nor recreates native icon anchors.
|
|
328
|
+
func title(_ item: Item) -> some View {
|
|
329
|
+
Text(item.title).foregroundStyle(hex: item.key == config.activeKey ? nil : config.appearance?.foregroundColor)
|
|
330
|
+
}
|
|
331
|
+
@ViewBuilder func column(_ value: Column) -> some View {
|
|
332
|
+
ContentSlot(id: value.key).navigationSplitViewColumnWidth(
|
|
333
|
+
min: value.minWidth, ideal: value.width, max: value.maxWidth ?? 100000)
|
|
334
|
+
}
|
|
335
|
+
@ViewBuilder var split: some View {
|
|
336
|
+
let columns = config.columns ?? []
|
|
337
|
+
if columns.count == 3 {
|
|
338
|
+
NavigationSplitView(columnVisibility: .constant(.all)) {
|
|
339
|
+
column(columns[0]).toolbar(removing: .sidebarToggle)
|
|
340
|
+
} content: { column(columns[1]) } detail: { column(columns[2]) }
|
|
341
|
+
.navigationSplitViewStyle(.balanced)
|
|
342
|
+
} else if columns.count == 2 {
|
|
343
|
+
NavigationSplitView(columnVisibility: .constant(.all)) {
|
|
344
|
+
column(columns[0]).toolbar(removing: .sidebarToggle)
|
|
345
|
+
} detail: { column(columns[1]) }
|
|
346
|
+
.navigationSplitViewStyle(.balanced)
|
|
347
|
+
} else if let first = columns.first { column(first) }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/// The Fabric view hosts only native chrome/layout. React scenes remain in the
|
|
352
|
+
/// original Fabric tree, preserving context, Yoga layout and responder ancestry.
|
|
353
|
+
@objc(DDNNavigationView)
|
|
354
|
+
public final class DDNNavigationView: NSView {
|
|
355
|
+
private let model = NavigationModel()
|
|
356
|
+
private var hosting: NSHostingView<NavigationRoot>!
|
|
357
|
+
private var geometryObservers: [NSObjectProtocol] = []
|
|
358
|
+
@objc public var onEvent: ((String) -> Void)? {
|
|
359
|
+
didSet { model.emit = onEvent }
|
|
360
|
+
}
|
|
361
|
+
public override var isFlipped: Bool { true }
|
|
362
|
+
@objc public override init(frame: NSRect) {
|
|
363
|
+
super.init(frame: frame)
|
|
364
|
+
hosting = NSHostingView(rootView: NavigationRoot(model: model))
|
|
365
|
+
hosting.frame = bounds
|
|
366
|
+
hosting.autoresizingMask = [.width, .height]
|
|
367
|
+
// This view is embedded in an RN layout: Yoga owns its size, and React places
|
|
368
|
+
// scenes in host coordinates. Do not let SwiftUI inset content for window
|
|
369
|
+
// safe areas (e.g. a full-size content title bar) or constrain the window.
|
|
370
|
+
hosting.safeAreaRegions = []
|
|
371
|
+
hosting.sizingOptions = []
|
|
372
|
+
addSubview(hosting)
|
|
373
|
+
model.host = self
|
|
374
|
+
// NSClipView sends bounds changes while scrolling, even without a SwiftUI
|
|
375
|
+
// layout pass. Observe descendants only, and coalesce reports per run loop.
|
|
376
|
+
for name in [NSView.boundsDidChangeNotification, NSView.frameDidChangeNotification] {
|
|
377
|
+
geometryObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in
|
|
378
|
+
guard let self, let view = notification.object as? NSView, view.isDescendant(of: self) else { return }
|
|
379
|
+
self.model.scheduleReport()
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
deinit { geometryObservers.forEach(NotificationCenter.default.removeObserver) }
|
|
384
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
385
|
+
public override func layout() {
|
|
386
|
+
super.layout()
|
|
387
|
+
if model.viewport != bounds {
|
|
388
|
+
model.viewport = bounds
|
|
389
|
+
model.report(model.frames)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
@objc public func setConfiguration(_ json: String) {
|
|
393
|
+
model.viewport = bounds
|
|
394
|
+
guard let data = json.data(using: .utf8),
|
|
395
|
+
let value = try? JSONDecoder().decode(Configuration.self, from: data) else { return }
|
|
396
|
+
model.configuration = value
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#import <React/RCTViewManager.h>
|
|
2
|
+
|
|
3
|
+
// Export view metadata for requireNativeComponent. Fabric creates the view
|
|
4
|
+
// through codegen's componentProvider; Paper construction is unsupported.
|
|
5
|
+
@interface DDNNavigationViewManager : RCTViewManager
|
|
6
|
+
@end
|
|
7
|
+
@implementation DDNNavigationViewManager
|
|
8
|
+
RCT_EXPORT_MODULE(DesktopNavigationHost)
|
|
9
|
+
RCT_EXPORT_VIEW_PROPERTY(configuration, NSString)
|
|
10
|
+
RCT_EXPORT_VIEW_PROPERTY(onNavigationEvent, RCTDirectEventBlock)
|
|
11
|
+
@end
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@natsuneko-laboratory/react-native-desktop-navigation",
|
|
3
|
+
"version": "0.1.0-alpha.12",
|
|
4
|
+
"description": "Desktop-first navigation for React Native macOS and Windows",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"react-native": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"react-native": "./src/index.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./routers": {
|
|
16
|
+
"types": "./dist/routers/index.d.ts",
|
|
17
|
+
"default": "./dist/routers/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./native": {
|
|
21
|
+
"types": "./dist/native/index.d.ts",
|
|
22
|
+
"react-native": "./src/native/index.ts",
|
|
23
|
+
"default": "./dist/native/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"macos",
|
|
32
|
+
"windows",
|
|
33
|
+
"DesktopNavigation.podspec",
|
|
34
|
+
"react-native.config.js",
|
|
35
|
+
"NATIVE.md",
|
|
36
|
+
"docs"
|
|
37
|
+
],
|
|
38
|
+
"sideEffects": false,
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": ">=18",
|
|
41
|
+
"react-native": ">=0.78"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/react": "^19.0.0",
|
|
45
|
+
"@types/react-test-renderer": "^19.0.0",
|
|
46
|
+
"lucide-react-native": "^1.46.0",
|
|
47
|
+
"prettier": "^3.6.2",
|
|
48
|
+
"react": "19.1.0",
|
|
49
|
+
"react-native": "0.81.0",
|
|
50
|
+
"react-native-svg": "^15.15.5",
|
|
51
|
+
"react-test-renderer": "19.1.0",
|
|
52
|
+
"typescript": "~5.9.2",
|
|
53
|
+
"vitest": "^3.2.4"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "https://github.com/mika-f/react-native-desktop-navigation.git"
|
|
61
|
+
},
|
|
62
|
+
"codegenConfig": {
|
|
63
|
+
"name": "DesktopNavigationSpec",
|
|
64
|
+
"type": "components",
|
|
65
|
+
"jsSrcsDir": "src/native/specs",
|
|
66
|
+
"ios": {
|
|
67
|
+
"componentProvider": {
|
|
68
|
+
"DesktopNavigationHost": "DDNNavigationComponentView"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"build": "tsc -p tsconfig.build.json",
|
|
74
|
+
"typecheck": "tsc --noEmit",
|
|
75
|
+
"test": "vitest run",
|
|
76
|
+
"check": "npm run typecheck && npm test && npm run build && npm run check:native-codegen",
|
|
77
|
+
"format": "prettier --write .",
|
|
78
|
+
"format:check": "prettier --check .",
|
|
79
|
+
"check:native-codegen": "node scripts/check-native-codegen.js",
|
|
80
|
+
"check:native-macos": "bash scripts/check-native-macos.sh"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
dependency: {
|
|
3
|
+
platforms: {
|
|
4
|
+
// RN macOS 0.81 invokes Apple Codegen with target "ios". Do not disable
|
|
5
|
+
// that key: it would exclude this library from the Fabric provider.
|
|
6
|
+
android: null,
|
|
7
|
+
macos: { podspecPath: 'DesktopNavigation.podspec' },
|
|
8
|
+
windows: {
|
|
9
|
+
sourceDir: 'windows',
|
|
10
|
+
projects: [
|
|
11
|
+
{
|
|
12
|
+
projectFile: 'DesktopNavigation\\DesktopNavigation.vcxproj',
|
|
13
|
+
directDependency: true,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import { NavigationStore } from './store';
|
|
4
|
+
import {
|
|
5
|
+
DefaultTheme,
|
|
6
|
+
PlatformContext,
|
|
7
|
+
ScopeContext,
|
|
8
|
+
StoreContext,
|
|
9
|
+
ThemeContext,
|
|
10
|
+
} from './context';
|
|
11
|
+
import type { NavigationRefHandle, NavigationTheme } from './types';
|
|
12
|
+
import type { ParamListBase, RootNavigationState } from '../routers';
|
|
13
|
+
import {
|
|
14
|
+
consumeKey,
|
|
15
|
+
defaultPlatformAdapter,
|
|
16
|
+
DesktopView,
|
|
17
|
+
eventHandled,
|
|
18
|
+
matchesShortcut,
|
|
19
|
+
type KeyboardShortcut,
|
|
20
|
+
type NavigationPlatformAdapter,
|
|
21
|
+
} from '../platform';
|
|
22
|
+
export interface KeyboardShortcutOptions {
|
|
23
|
+
back?: KeyboardShortcut[];
|
|
24
|
+
forward?: KeyboardShortcut[];
|
|
25
|
+
nextTab?: KeyboardShortcut[];
|
|
26
|
+
previousTab?: KeyboardShortcut[];
|
|
27
|
+
}
|
|
28
|
+
export interface NavigationContainerProps {
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
initialState?: RootNavigationState;
|
|
31
|
+
onStateChange?: (state: RootNavigationState) => void;
|
|
32
|
+
keyboardShortcuts?: boolean | KeyboardShortcutOptions;
|
|
33
|
+
platformAdapter?: NavigationPlatformAdapter;
|
|
34
|
+
theme?: NavigationTheme;
|
|
35
|
+
style?: StyleProp<ViewStyle>;
|
|
36
|
+
}
|
|
37
|
+
function Container(
|
|
38
|
+
{
|
|
39
|
+
children,
|
|
40
|
+
initialState,
|
|
41
|
+
onStateChange,
|
|
42
|
+
keyboardShortcuts = true,
|
|
43
|
+
platformAdapter = defaultPlatformAdapter,
|
|
44
|
+
theme = DefaultTheme,
|
|
45
|
+
style,
|
|
46
|
+
}: NavigationContainerProps,
|
|
47
|
+
ref: React.ForwardedRef<NavigationRefHandle<ParamListBase>>,
|
|
48
|
+
) {
|
|
49
|
+
const [store] = React.useState(() => new NavigationStore(initialState));
|
|
50
|
+
const callback = React.useRef(onStateChange);
|
|
51
|
+
callback.current = onStateChange;
|
|
52
|
+
React.useEffect(
|
|
53
|
+
() =>
|
|
54
|
+
store.subscribe(() => {
|
|
55
|
+
if (store.isReady()) callback.current?.(store.getState());
|
|
56
|
+
}),
|
|
57
|
+
[store],
|
|
58
|
+
);
|
|
59
|
+
React.useImperativeHandle(
|
|
60
|
+
ref,
|
|
61
|
+
() => ({
|
|
62
|
+
isReady: store.isReady,
|
|
63
|
+
navigate: (name, params) => {
|
|
64
|
+
if (store.isReady())
|
|
65
|
+
store.dispatch({ type: 'navigate', payload: { name, params } });
|
|
66
|
+
},
|
|
67
|
+
dispatch: (action) => {
|
|
68
|
+
if (store.isReady()) store.dispatch(action);
|
|
69
|
+
},
|
|
70
|
+
goBack: () => {
|
|
71
|
+
if (store.isReady()) store.dispatch({ type: 'back' });
|
|
72
|
+
},
|
|
73
|
+
goForward: () => {
|
|
74
|
+
if (store.isReady()) store.dispatch({ type: 'forward' });
|
|
75
|
+
},
|
|
76
|
+
canGoBack: () => store.isReady() && store.canDispatch('back'),
|
|
77
|
+
canGoForward: () => store.isReady() && store.canDispatch('forward'),
|
|
78
|
+
getRootState: store.getState,
|
|
79
|
+
}),
|
|
80
|
+
[store],
|
|
81
|
+
);
|
|
82
|
+
const shortcuts = {
|
|
83
|
+
...platformAdapter.keyboardShortcuts,
|
|
84
|
+
...(typeof keyboardShortcuts === 'object' ? keyboardShortcuts : {}),
|
|
85
|
+
};
|
|
86
|
+
return (
|
|
87
|
+
<StoreContext.Provider value={store}>
|
|
88
|
+
<ScopeContext.Provider value={{}}>
|
|
89
|
+
<PlatformContext.Provider value={platformAdapter}>
|
|
90
|
+
<ThemeContext.Provider value={theme}>
|
|
91
|
+
<DesktopView
|
|
92
|
+
style={[
|
|
93
|
+
{ flex: 1, backgroundColor: theme.colors.background },
|
|
94
|
+
style,
|
|
95
|
+
]}
|
|
96
|
+
focusable
|
|
97
|
+
keyDownEvents={
|
|
98
|
+
keyboardShortcuts ? Object.values(shortcuts).flat() : []
|
|
99
|
+
}
|
|
100
|
+
onKeyDown={(event) => {
|
|
101
|
+
if (
|
|
102
|
+
!keyboardShortcuts ||
|
|
103
|
+
eventHandled(event) ||
|
|
104
|
+
platformAdapter.isTextInputEvent(event)
|
|
105
|
+
)
|
|
106
|
+
return;
|
|
107
|
+
for (const type of [
|
|
108
|
+
'back',
|
|
109
|
+
'forward',
|
|
110
|
+
'nextTab',
|
|
111
|
+
'previousTab',
|
|
112
|
+
] as const) {
|
|
113
|
+
if (
|
|
114
|
+
shortcuts[type].some((key) =>
|
|
115
|
+
matchesShortcut(event, key),
|
|
116
|
+
) &&
|
|
117
|
+
store.dispatch({ type })
|
|
118
|
+
) {
|
|
119
|
+
consumeKey(event);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</DesktopView>
|
|
127
|
+
</ThemeContext.Provider>
|
|
128
|
+
</PlatformContext.Provider>
|
|
129
|
+
</ScopeContext.Provider>
|
|
130
|
+
</StoreContext.Provider>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
export const NavigationContainer = React.forwardRef(Container) as <
|
|
134
|
+
P extends ParamListBase = ParamListBase,
|
|
135
|
+
>(
|
|
136
|
+
props: NavigationContainerProps & { ref?: React.Ref<NavigationRefHandle<P>> },
|
|
137
|
+
) => React.ReactElement;
|