@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.
Files changed (103) hide show
  1. package/DesktopNavigation.podspec +18 -0
  2. package/LICENSE +21 -0
  3. package/NATIVE.md +300 -0
  4. package/README.md +127 -0
  5. package/dist/core/NavigationContainer.d.ts +23 -0
  6. package/dist/core/NavigationContainer.js +68 -0
  7. package/dist/core/NavigationItem.d.ts +27 -0
  8. package/dist/core/NavigationItem.js +71 -0
  9. package/dist/core/Scene.d.ts +19 -0
  10. package/dist/core/Scene.js +124 -0
  11. package/dist/core/SelectionNavigator.d.ts +38 -0
  12. package/dist/core/SelectionNavigator.js +194 -0
  13. package/dist/core/builder.d.ts +40 -0
  14. package/dist/core/builder.js +81 -0
  15. package/dist/core/context.d.ts +26 -0
  16. package/dist/core/context.js +36 -0
  17. package/dist/core/focus.d.ts +14 -0
  18. package/dist/core/focus.js +39 -0
  19. package/dist/core/hooks.d.ts +8 -0
  20. package/dist/core/hooks.js +48 -0
  21. package/dist/core/navigation.d.ts +6 -0
  22. package/dist/core/navigation.js +57 -0
  23. package/dist/core/store.d.ts +39 -0
  24. package/dist/core/store.js +391 -0
  25. package/dist/core/types.d.ts +155 -0
  26. package/dist/core/types.js +2 -0
  27. package/dist/index.d.ts +15 -0
  28. package/dist/index.js +46 -0
  29. package/dist/native/host.d.ts +100 -0
  30. package/dist/native/host.js +190 -0
  31. package/dist/native/icons.d.ts +56 -0
  32. package/dist/native/icons.js +55 -0
  33. package/dist/native/index.d.ts +13 -0
  34. package/dist/native/index.js +44 -0
  35. package/dist/native/sidebar.d.ts +39 -0
  36. package/dist/native/sidebar.js +126 -0
  37. package/dist/native/specs/DesktopNavigationHostNativeComponent.d.ts +10 -0
  38. package/dist/native/specs/DesktopNavigationHostNativeComponent.js +4 -0
  39. package/dist/native/split.d.ts +27 -0
  40. package/dist/native/split.js +105 -0
  41. package/dist/native/stack.d.ts +26 -0
  42. package/dist/native/stack.js +66 -0
  43. package/dist/platform/index.d.ts +52 -0
  44. package/dist/platform/index.js +28 -0
  45. package/dist/platform/macos.d.ts +2 -0
  46. package/dist/platform/macos.js +18 -0
  47. package/dist/platform/windows.d.ts +2 -0
  48. package/dist/platform/windows.js +18 -0
  49. package/dist/routers/index.d.ts +23 -0
  50. package/dist/routers/index.js +393 -0
  51. package/dist/routers/types.d.ts +116 -0
  52. package/dist/routers/types.js +2 -0
  53. package/dist/sidebar/index.d.ts +17 -0
  54. package/dist/sidebar/index.js +18 -0
  55. package/dist/split/index.d.ts +42 -0
  56. package/dist/split/index.js +199 -0
  57. package/dist/stack/index.d.ts +23 -0
  58. package/dist/stack/index.js +88 -0
  59. package/dist/tabs/index.d.ts +13 -0
  60. package/dist/tabs/index.js +15 -0
  61. package/docs/GUIDE.md +384 -0
  62. package/macos/DDNNavigationComponentView.h +4 -0
  63. package/macos/DDNNavigationComponentView.mm +40 -0
  64. package/macos/DDNNavigationView.swift +398 -0
  65. package/macos/DDNNavigationViewManager.mm +11 -0
  66. package/package.json +82 -0
  67. package/react-native.config.js +19 -0
  68. package/src/core/NavigationContainer.tsx +137 -0
  69. package/src/core/NavigationItem.tsx +149 -0
  70. package/src/core/Scene.tsx +194 -0
  71. package/src/core/SelectionNavigator.tsx +371 -0
  72. package/src/core/builder.tsx +147 -0
  73. package/src/core/context.tsx +43 -0
  74. package/src/core/focus.tsx +50 -0
  75. package/src/core/hooks.ts +42 -0
  76. package/src/core/navigation.ts +77 -0
  77. package/src/core/store.ts +471 -0
  78. package/src/core/types.ts +176 -0
  79. package/src/index.ts +36 -0
  80. package/src/native/host.tsx +377 -0
  81. package/src/native/icons.ts +110 -0
  82. package/src/native/index.ts +56 -0
  83. package/src/native/sidebar.tsx +230 -0
  84. package/src/native/specs/DesktopNavigationHostNativeComponent.ts +12 -0
  85. package/src/native/split.tsx +160 -0
  86. package/src/native/stack.tsx +157 -0
  87. package/src/platform/index.tsx +76 -0
  88. package/src/platform/macos.ts +15 -0
  89. package/src/platform/windows.ts +15 -0
  90. package/src/routers/index.ts +442 -0
  91. package/src/routers/types.ts +117 -0
  92. package/src/sidebar/index.tsx +42 -0
  93. package/src/split/index.tsx +350 -0
  94. package/src/stack/index.tsx +213 -0
  95. package/src/tabs/index.tsx +40 -0
  96. package/windows/DesktopNavigation/DesktopNavigation.def +3 -0
  97. package/windows/DesktopNavigation/DesktopNavigation.vcxproj +125 -0
  98. package/windows/DesktopNavigation/NavigationHost.cpp +432 -0
  99. package/windows/DesktopNavigation/ReactPackageProvider.cpp +9 -0
  100. package/windows/DesktopNavigation/ReactPackageProvider.h +10 -0
  101. package/windows/DesktopNavigation/ReactPackageProvider.idl +6 -0
  102. package/windows/DesktopNavigation/pch.cpp +1 -0
  103. 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;