diff --git a/packages/one-vscode/package.json b/packages/one-vscode/package.json index df79f09ed..d4d359da5 100644 --- a/packages/one-vscode/package.json +++ b/packages/one-vscode/package.json @@ -40,8 +40,8 @@ } }, "scripts": { - "build": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", - "watch": "npm run build -- --watch", + "build": "sh -c 'esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node'", + "watch": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch", "package": "vsce package" }, "devDependencies": { diff --git a/packages/one/expo-module.config.json b/packages/one/expo-module.config.json new file mode 100644 index 000000000..612593236 --- /dev/null +++ b/packages/one/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["OneLinkPreviewModule"] + } +} diff --git a/packages/one/ios/LinkPreview/LinkPreviewNativeActionView.swift b/packages/one/ios/LinkPreview/LinkPreviewNativeActionView.swift new file mode 100644 index 000000000..0fa4ee54f --- /dev/null +++ b/packages/one/ios/LinkPreview/LinkPreviewNativeActionView.swift @@ -0,0 +1,195 @@ +import ExpoModulesCore + +class LinkPreviewNativeActionView: RouterViewWithLogger, LinkPreviewMenuUpdatable { + var identifier: String = "" + // MARK: - Shared props + @NativeActionProp(updateAction: true, updateMenu: true) var title: String = "" + @NativeActionProp(updateAction: true, updateMenu: true) var icon: String? + @NativeActionProp(updateAction: true, updateMenu: true) var destructive: Bool? + @NativeActionProp(updateAction: true, updateMenu: true) var disabled: Bool = false + + // MARK: - Action only props + @NativeActionProp(updateAction: true) var isOn: Bool? + @NativeActionProp(updateAction: true) var keepPresented: Bool? + @NativeActionProp(updateAction: true) var discoverabilityLabel: String? + @NativeActionProp(updateAction: true, updateMenu: true) var subtitle: String? + + // MARK: - Menu only props + @NativeActionProp(updateMenu: true) var singleSelection: Bool = false + @NativeActionProp(updateMenu: true) var displayAsPalette: Bool = false + @NativeActionProp(updateMenu: true) var displayInline: Bool = false + @NativeActionProp(updateMenu: true) var preferredElementSize: MenuElementSize? + + // MARK: - UIBarButtonItem props + @NativeActionProp(updateAction: true, updateMenu: true) var routerHidden: Bool = false + @NativeActionProp(updateMenu: true) var titleStyle: TitleStyle? + @NativeActionProp(updateMenu: true) var sharesBackground: Bool? + @NativeActionProp(updateMenu: true) var hidesSharedBackground: Bool? + @NativeActionProp(updateAction: true, updateMenu: true) var customTintColor: UIColor? + @NativeActionProp(updateMenu: true) var barButtonItemStyle: UIBarButtonItem.Style? + @NativeActionProp(updateMenu: true) var subActions: [LinkPreviewNativeActionView] = [] + @NativeActionProp(updateMenu: true) var accessibilityLabelForMenu: String? + @NativeActionProp(updateMenu: true) var accessibilityHintForMenu: String? + + // MARK: - Events + let onSelected = EventDispatcher() + + // MARK: - Native API + weak var parentMenuUpdatable: LinkPreviewMenuUpdatable? + + private var baseUiAction: UIAction + private var menuAction: UIMenu + + var isMenuAction: Bool { + return !subActions.isEmpty + } + + var uiAction: UIMenuElement { + isMenuAction ? menuAction : baseUiAction + } + + required init(appContext: AppContext? = nil) { + baseUiAction = UIAction(title: "", handler: { _ in }) + menuAction = UIMenu(title: "", image: nil, options: [], children: []) + super.init(appContext: appContext) + clipsToBounds = true + baseUiAction = UIAction(title: "", handler: { _ in self.onSelected() }) + } + + func updateMenu() { + let subActions = subActions.map { subAction in + subAction.uiAction + } + var options: UIMenu.Options = [] + if #available(iOS 17.0, *) { + if displayAsPalette { + options.insert(.displayAsPalette) + } + } + if singleSelection { + options.insert(.singleSelection) + } + if displayInline { + options.insert(.displayInline) + } + if destructive == true { + options.insert(.destructive) + } + + menuAction = UIMenu( + title: title, + image: icon.flatMap { UIImage(systemName: $0) }, + options: options, + children: subActions + ) + + if let subtitle = subtitle { + menuAction.subtitle = subtitle + } + + if #available(iOS 16.0, *) { + if let preferredElementSize = preferredElementSize { + menuAction.preferredElementSize = preferredElementSize.toUIMenuElementSize() + } + } + + parentMenuUpdatable?.updateMenu() + } + + func updateUiAction() { + var attributes: UIMenuElement.Attributes = [] + if destructive == true { attributes.insert(.destructive) } + if disabled == true { attributes.insert(.disabled) } + if routerHidden { + attributes.insert(.hidden) + } + + if #available(iOS 16.0, *) { + if keepPresented == true { attributes.insert(.keepsMenuPresented) } + } + + baseUiAction.title = title + baseUiAction.image = icon.flatMap { UIImage(systemName: $0) } + baseUiAction.attributes = attributes + baseUiAction.state = isOn == true ? .on : .off + + if let subtitle = subtitle { + baseUiAction.subtitle = subtitle + } + if let label = discoverabilityLabel { + baseUiAction.discoverabilityTitle = label + } + + parentMenuUpdatable?.updateMenu() + } + + override func mountChildComponentView(_ childComponentView: UIView, index: Int) { + if let childActionView = childComponentView as? LinkPreviewNativeActionView { + subActions.insert(childActionView, at: index) + childActionView.parentMenuUpdatable = self + } else { + logger?.warn( + "[one-router] Unknown child component view (\(childComponentView)) mounted to NativeLinkPreviewActionView. This is most likely a bug in one-router." + ) + } + } + + override func unmountChildComponentView(_ child: UIView, index: Int) { + if let childActionView = child as? LinkPreviewNativeActionView { + subActions.removeAll(where: { $0 == childActionView }) + } else { + logger?.warn( + "[one-router] Unknown child component view (\(child)) unmounted from NativeLinkPreviewActionView. This is most likely a bug in one-router." + ) + } + } + + @propertyWrapper + struct NativeActionProp { + var value: Value + let updateAction: Bool + let updateMenu: Bool + + init(wrappedValue: Value, updateAction: Bool = false, updateMenu: Bool = false) { + self.value = wrappedValue + self.updateAction = updateAction + self.updateMenu = updateMenu + } + + static subscript( + _enclosingInstance instance: EnclosingSelf, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath> + ) -> Value { + get { + instance[keyPath: storageKeyPath].value + } + set { + let oldValue = instance[keyPath: storageKeyPath].value + if oldValue != newValue { + instance[keyPath: storageKeyPath].value = newValue + if instance[keyPath: storageKeyPath].updateAction { + instance.updateUiAction() + } + if instance[keyPath: storageKeyPath].updateMenu { + instance.updateMenu() + } + } + } + } + + var wrappedValue: Value { + get { value } + set { value = newValue } + } + } +} + +// Needed to allow optional properties without default `= nil` to avoid repetition +extension LinkPreviewNativeActionView.NativeActionProp where Value: ExpressibleByNilLiteral { + init(updateAction: Bool = false, updateMenu: Bool = false) { + self.value = nil + self.updateAction = updateAction + self.updateMenu = updateMenu + } +} diff --git a/packages/one/ios/LinkPreview/LinkPreviewNativeNavigation.swift b/packages/one/ios/LinkPreview/LinkPreviewNativeNavigation.swift new file mode 100644 index 000000000..07940f178 --- /dev/null +++ b/packages/one/ios/LinkPreview/LinkPreviewNativeNavigation.swift @@ -0,0 +1,192 @@ +import ExpoModulesCore +import RNScreens +import UIKit + +struct TabChangeCommand { + weak var tabBarController: UITabBarController? + let tabIndex: Int +} + +internal class LinkPreviewNativeNavigation { + private weak var preloadedScreenView: RNSScreenView? + private weak var preloadedStackView: RNSScreenStackView? + private var tabChangeCommands: [TabChangeCommand] = [] + private let logger: Logger? + + init(logger: Logger?) { + self.logger = logger + } + + func pushPreloadedView() { + self.performTabChanges() + + guard let preloadedScreenView, + let preloadedStackView + else { + // Check if there were any tab change commands to perform + // If there were, the preview transition could be to a different tab only + if self.tabChangeCommands.isEmpty { + logger?.warn( + "[one-router] No preloaded screen view to push. Link.Preview transition is only supported inside a native stack or native tabs navigators." + ) + } + return + } + + // Instead of pushing the preloaded screen view, we set its activity state + // React native screens will then handle the rest. + preloadedScreenView.activityState = Int32(RNSActivityState.onTop.rawValue) + preloadedStackView.markChildUpdated() + self.pushModalInnerScreenIfNeeded(screenView: preloadedScreenView) + } + + func updatePreloadedView(screenId: String?, tabPath: TabPathPayload?, responder: UIView) { + self.tabChangeCommands = [] + let oldTabKeys = tabPath?.path.map { $0.oldTabKey } ?? [] + let stackOrTabView = findStackViewWithScreenIdOrTabBarController( + screenId: screenId, tabKeys: oldTabKeys, responder: responder) + guard let stackOrTabView else { + return + } + if let tabView = stackOrTabView as? RNSBottomTabsScreenComponentView { + let newTabKeys = tabPath?.path.map { $0.newTabKey } ?? [] + // The order is important here. findStackViewWithScreenIdInSubViews must be called + // even if screenId is nil to compute the tabChangeCommands. + if let stackView = findStackViewWithScreenIdInSubViews( + screenId: screenId, tabKeys: newTabKeys, rootView: tabView), let screenId { + setPreloadedView(stackView: stackView, screenId: screenId) + } + } else if let stackView = stackOrTabView as? RNSScreenStackView, let screenId { + setPreloadedView(stackView: stackView, screenId: screenId) + } + } + + private func performTabChanges() { + self.tabChangeCommands.forEach { command in + command.tabBarController?.selectedIndex = command.tabIndex + } + } + + // If screen is a modal with header, it will have an inner stack screen + // In this case we need to set the activity state of the inner screen as well. + private func pushModalInnerScreenIfNeeded(screenView: RNSScreenView) { + // If the screen is modal with header then it will have exactly one child - RNSNavigationController. + if screenView.isModal() && screenView.controller.children.count == 1 { + // To get the inner screen stack we need to go through RNSNavigationController. + // The structure is as follows: + // RNSScreenView (preloadedScreenView) + // └── RNSNavigationController (outer stack) + // └── RNSScreenStackView (innerScreenStack) + if let rnsNavController = screenView.controller.children.first + as? RNSNavigationController, + // The delegate of RNSNavigationController is RNSScreenStackView. + let innerScreenStack = rnsNavController.delegate as? RNSScreenStackView, + // The first and only child of the inner screen stack should be + // RNSScreenView (). + let screenContentView = innerScreenStack.reactSubviews().first as? RNSScreenView { + // Same as above, we let React Native Screens handle the transition. + // We need to set the activity of inner screen as well, because its + // react value is the same as the preloaded screen - 0. + screenContentView.activityState = Int32(RNSActivityState.onTop.rawValue) + innerScreenStack.markChildUpdated() + } + } + } + + private func setPreloadedView( + stackView: RNSScreenStackView, screenId: String + ) { + let screenViews = stackView.reactSubviews() + if let screenView = screenViews?.first(where: { + ($0 as? RNSScreenView)?.screenId == screenId + }) as? RNSScreenView { + preloadedScreenView = screenView + preloadedStackView = stackView + } + } + + // Allowing for null screenId to support preloading tab navigators + // Even if the desired screenId is not found, we still need to compute the tabChangeCommands + private func findStackViewWithScreenIdInSubViews( + screenId: String?, tabKeys: [String], rootView: UIView + ) -> RNSScreenStackView? { + if let rootView = rootView as? RNSScreenStackView, + let screenId { + if rootView.screenIds.contains(screenId) { + return rootView + } + } else if let tabBarController = getTabBarControllerFromTabView(view: rootView) { + if let (tabIndex, tabView) = getIndexAndViewOfFirstTabWithKey( + tabBarController: tabBarController, tabKeys: tabKeys) { + self.tabChangeCommands.append( + TabChangeCommand(tabBarController: tabBarController, tabIndex: tabIndex)) + for subview in tabView.subviews { + if let result = findStackViewWithScreenIdInSubViews( + screenId: screenId, tabKeys: tabKeys, rootView: subview) { + return result + } + } + } + } else { + for subview in rootView.subviews { + let result = findStackViewWithScreenIdInSubViews( + screenId: screenId, tabKeys: tabKeys, rootView: subview) + if result != nil { + return result + } + } + } + + return nil + } + + private func getIndexAndViewOfFirstTabWithKey( + tabBarController: UITabBarController, tabKeys: [String] + ) -> (tabIndex: Int, tabView: UIView)? { + let views = tabBarController.viewControllers?.compactMap { $0.view } ?? [] + let enumeratedViews = views.enumerated() + if let result = + enumeratedViews + .first(where: { _, view in + guard let tabView = view as? RNSBottomTabsScreenComponentView, let tabKey = tabView.tabKey + else { + return false + } + return tabKeys.contains(tabKey) + }) { + return (result.offset, result.element) + } + return nil + } + + private func getTabBarControllerFromTabView(view: UIView) -> UITabBarController? { + if let tabScreenView = view as? RNSBottomTabsScreenComponentView { + return tabScreenView.reactViewController()?.tabBarController as? UITabBarController + } + if let tabHostView = view as? RNSBottomTabsHostComponentView { + return tabHostView.controller as? UITabBarController + } + return nil + } + + private func findStackViewWithScreenIdOrTabBarController( + screenId: String?, tabKeys: [String], responder: UIView + ) -> UIView? { + var currentResponder: UIResponder? = responder + + while let nextResponder = currentResponder?.next { + if let view = nextResponder as? RNSScreenStackView, + let screenId { + if view.screenIds.contains(screenId) { + return view + } + } else if let tabView = nextResponder as? RNSBottomTabsScreenComponentView { + if let tabKey = tabView.tabKey, tabKeys.contains(tabKey) { + return tabView + } + } + currentResponder = nextResponder + } + return nil + } +} diff --git a/packages/one/ios/LinkPreview/NativeLinkPreviewContentView.swift b/packages/one/ios/LinkPreview/NativeLinkPreviewContentView.swift new file mode 100644 index 000000000..951540cf5 --- /dev/null +++ b/packages/one/ios/LinkPreview/NativeLinkPreviewContentView.swift @@ -0,0 +1,9 @@ +import ExpoModulesCore + +class NativeLinkPreviewContentView: RouterViewWithLogger { + var preferredContentSize: CGSize = .zero + + func setInitialSize(bounds: CGRect) { + self.setShadowNodeSize(Float(bounds.width), height: Float(bounds.height)) + } +} diff --git a/packages/one/ios/LinkPreview/NativeLinkPreviewView.swift b/packages/one/ios/LinkPreview/NativeLinkPreviewView.swift new file mode 100644 index 000000000..da39c298a --- /dev/null +++ b/packages/one/ios/LinkPreview/NativeLinkPreviewView.swift @@ -0,0 +1,249 @@ +import ExpoModulesCore +import RNScreens + +class NativeLinkPreviewView: RouterViewWithLogger, UIContextMenuInteractionDelegate, + RNSDismissibleModalProtocol, LinkPreviewMenuUpdatable { + private var preview: NativeLinkPreviewContentView? + private var interaction: UIContextMenuInteraction? + var directChild: UIView? + var nextScreenId: String? { + didSet { + performUpdateOfPreloadedView() + } + } + var tabPath: TabPathPayload? { + didSet { + performUpdateOfPreloadedView() + } + } + private var actions: [LinkPreviewNativeActionView] = [] + + private lazy var linkPreviewNativeNavigation: LinkPreviewNativeNavigation = { + return LinkPreviewNativeNavigation(logger: logger) + }() + + let onPreviewTapped = EventDispatcher() + let onPreviewTappedAnimationCompleted = EventDispatcher() + let onWillPreviewOpen = EventDispatcher() + let onDidPreviewOpen = EventDispatcher() + let onPreviewWillClose = EventDispatcher() + let onPreviewDidClose = EventDispatcher() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + self.interaction = UIContextMenuInteraction(delegate: self) + } + + // MARK: - LinkPreviewModalDismissable + + func isDismissible() -> Bool { + return false + } + + // MARK: - Props + + func performUpdateOfPreloadedView() { + if nextScreenId == nil && tabPath?.path.isEmpty != false { + // If we have no tab to change and no screen to push, then we can't update the preloaded view + return + } + // However if one these is defined then we can perform the native update + linkPreviewNativeNavigation.updatePreloadedView( + screenId: nextScreenId, tabPath: tabPath, responder: self) + } + + // MARK: - Children + override func mountChildComponentView(_ childComponentView: UIView, index: Int) { + if let previewView = childComponentView as? NativeLinkPreviewContentView { + preview = previewView + } else if let actionView = childComponentView as? LinkPreviewNativeActionView { + actionView.parentMenuUpdatable = self + actions.append(actionView) + } else { + if directChild != nil { + logger?.warn( + "[one-router] Found a second child of . Only one is allowed. This is most likely a bug in one-router." + ) + return + } + directChild = childComponentView + if let interaction = self.interaction { + if let indirectTrigger = childComponentView as? LinkPreviewIndirectTriggerProtocol { + indirectTrigger.indirectTrigger?.addInteraction(interaction) + } else { + childComponentView.addInteraction(interaction) + } + } + super.mountChildComponentView(childComponentView, index: index) + } + } + + override func unmountChildComponentView(_ child: UIView, index: Int) { + if child is NativeLinkPreviewContentView { + preview = nil + } else if let actionView = child as? LinkPreviewNativeActionView { + actions.removeAll(where: { + $0 == actionView + }) + } else { + if let directChild = directChild { + if directChild != child { + logger?.warn( + "[one-router] Unmounting unexpected child from . This is most likely a bug in one-router." + ) + return + } + if let interaction = self.interaction { + if let indirectTrigger = directChild as? LinkPreviewIndirectTriggerProtocol { + indirectTrigger.indirectTrigger?.removeInteraction(interaction) + } else { + directChild.removeInteraction(interaction) + } + } + super.unmountChildComponentView(child, index: index) + } else { + logger?.warn( + "[one-router] No link child found to unmount. This is most likely a bug in one-router." + ) + return + } + } + } + + // MARK: - UIContextMenuInteractionDelegate + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + onWillPreviewOpen() + return UIContextMenuConfiguration( + identifier: nil, + previewProvider: { [weak self] in + self?.createPreviewViewController() + }, + actionProvider: { [weak self] _ in + self?.createContextMenu() + }) + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configuration: UIContextMenuConfiguration, + highlightPreviewForItemWithIdentifier identifier: any NSCopying + ) -> UITargetedPreview? { + if let superview, let directChild { + let triggerView: UIView = + (directChild as? LinkPreviewIndirectTriggerProtocol)?.indirectTrigger ?? directChild + let target = UIPreviewTarget( + container: superview, center: self.convert(triggerView.center, to: superview)) + + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear + + return UITargetedPreview(view: triggerView, parameters: parameters, target: target) + } + return nil + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willDisplayMenuFor configuration: UIContextMenuConfiguration, + animator: UIContextMenuInteractionAnimating? + ) { + // This happens when preview starts to become visible. + // It is not yet fully extended at this moment though + self.onDidPreviewOpen() + animator?.addCompletion { + // This happens around a second after the preview is opened and thus gives us no real value + // User could have already interacted with preview beforehand + } + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willEndFor configuration: UIContextMenuConfiguration, + animator: UIContextMenuInteractionAnimating? + ) { + onPreviewWillClose() + animator?.addCompletion { + self.onPreviewDidClose() + } + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, + animator: UIContextMenuInteractionCommitAnimating + ) { + if preview != nil { + self.onPreviewTapped() + animator.addCompletion { [weak self] in + self?.linkPreviewNativeNavigation.pushPreloadedView() + self?.onPreviewTappedAnimationCompleted() + } + } + } + + // MARK: - Context Menu Helpers + + private func createPreviewViewController() -> UIViewController? { + guard let preview = preview else { + return nil + } + + let vc = PreviewViewController(linkPreviewNativePreview: preview) + let preferredSize = preview.preferredContentSize + vc.preferredContentSize.width = preferredSize.width + vc.preferredContentSize.height = preferredSize.height + return vc + } + + func updateMenu() { + self.interaction?.updateVisibleMenu { _ in + self.createContextMenu() + } + } + + private func createContextMenu() -> UIMenu { + if actions.count == 1, let menu = actions[0].uiAction as? UIMenu { + return menu + } + return UIMenu( + title: "", + children: actions.map { action in + action.uiAction + } + ) + } +} + +class PreviewViewController: UIViewController { + private let linkPreviewNativePreview: NativeLinkPreviewContentView + init(linkPreviewNativePreview: NativeLinkPreviewContentView) { + self.linkPreviewNativePreview = linkPreviewNativePreview + super.init(nibName: nil, bundle: nil) + } + + override func loadView() { + self.view = linkPreviewNativePreview + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + linkPreviewNativePreview.setInitialSize(bounds: self.view.bounds) + } +} + +protocol LinkPreviewIndirectTriggerProtocol { + var indirectTrigger: UIView? { get } +} + +protocol LinkPreviewMenuUpdatable: AnyObject { + func updateMenu() +} diff --git a/packages/one/ios/LinkPreview/OneLinkPreviewModule.swift b/packages/one/ios/LinkPreview/OneLinkPreviewModule.swift new file mode 100644 index 000000000..f0a74889d --- /dev/null +++ b/packages/one/ios/LinkPreview/OneLinkPreviewModule.swift @@ -0,0 +1,168 @@ +import ExpoModulesCore + +public class OneLinkPreviewModule: Module { + static let moduleName: String = "OneLinkPreviewModule" + + public func definition() -> ModuleDefinition { + Name(OneLinkPreviewModule.moduleName) + + View(NativeLinkPreviewView.self) { + Prop("nextScreenId") { (view: NativeLinkPreviewView, nextScreenId: String) in + view.nextScreenId = nextScreenId + } + + Prop("tabPath") { (view: NativeLinkPreviewView, tabPath: TabPathPayload) in + view.tabPath = tabPath + } + + Prop("disableForceFlatten") { (_: NativeLinkPreviewView, _: Bool) in + // This prop is used in ExpoShadowNode in order to disable force flattening, when display: contents is used + } + + Events( + "onPreviewTapped", + "onPreviewTappedAnimationCompleted", + "onWillPreviewOpen", + "onDidPreviewOpen", + "onPreviewWillClose", + "onPreviewDidClose" + ) + } + + View(NativeLinkPreviewContentView.self) { + Prop("preferredContentSize") { (view: NativeLinkPreviewContentView, size: [String: Float]) in + let width = size["width", default: 0] + let height = size["height", default: 0] + + guard width >= 0, height >= 0 else { + view.logger?.warn("[one-router] Preferred content size cannot be negative (\(width), \(height))") + return + } + + view.preferredContentSize = CGSize( + width: CGFloat(width), + height: CGFloat(height) + ) + } + } + + View(LinkPreviewNativeActionView.self) { + Prop("title") { (view: LinkPreviewNativeActionView, title: String) in + view.title = title + } + Prop("identifier") { (view: LinkPreviewNativeActionView, identifier: String) in + view.identifier = identifier + } + Prop("icon") { (view: LinkPreviewNativeActionView, icon: String?) in + view.icon = icon + } + Prop("disabled") { (view: LinkPreviewNativeActionView, disabled: Bool?) in + view.disabled = disabled ?? false + } + Prop("destructive") { (view: LinkPreviewNativeActionView, destructive: Bool?) in + view.destructive = destructive + } + Prop("discoverabilityLabel") { (view: LinkPreviewNativeActionView, label: String?) in + view.discoverabilityLabel = label + } + Prop("subtitle") { (view: LinkPreviewNativeActionView, subtitle: String?) in + view.subtitle = subtitle + } + Prop("accessibilityLabel") { (view: LinkPreviewNativeActionView, label: String?) in + view.accessibilityLabelForMenu = label + } + Prop("accessibilityHint") { (view: LinkPreviewNativeActionView, hint: String?) in + view.accessibilityHintForMenu = hint + } + Prop("singleSelection") { (view: LinkPreviewNativeActionView, singleSelection: Bool?) in + view.singleSelection = singleSelection ?? false + } + Prop("displayAsPalette") { (view: LinkPreviewNativeActionView, displayAsPalette: Bool?) in + view.displayAsPalette = displayAsPalette ?? false + } + Prop("isOn") { (view: LinkPreviewNativeActionView, isOn: Bool?) in + view.isOn = isOn + } + Prop("keepPresented") { (view: LinkPreviewNativeActionView, keepPresented: Bool?) in + view.keepPresented = keepPresented + } + Prop("displayInline") { (view: LinkPreviewNativeActionView, displayInline: Bool?) in + view.displayInline = displayInline ?? false + } + Prop("hidden") { (view: LinkPreviewNativeActionView, hidden: Bool?) in + view.routerHidden = hidden ?? false + } + Prop("sharesBackground") { (view: LinkPreviewNativeActionView, sharesBackground: Bool?) in + view.sharesBackground = sharesBackground + } + Prop("hidesSharedBackground") { (view: LinkPreviewNativeActionView, hidesSharedBackground: Bool?) in + view.hidesSharedBackground = hidesSharedBackground + } + Prop("tintColor") { (view: LinkPreviewNativeActionView, tintColor: UIColor?) in + view.customTintColor = tintColor + } + Prop("barButtonItemStyle") { (view: LinkPreviewNativeActionView, style: BarItemStyle?) in + view.barButtonItemStyle = style?.toUIBarButtonItemStyle() + } + Prop("preferredElementSize") { (view: LinkPreviewNativeActionView, preferredElementSize: MenuElementSize?) in + view.preferredElementSize = preferredElementSize + } + Events("onSelected") + } + } +} + +struct TabPathPayload: Record { + @Field var path: [TabStatePath] +} + +struct TabStatePath: Record { + @Field var oldTabKey: String + @Field var newTabKey: String +} + +enum MenuElementSize: String, Enumerable { + case small + case medium + case large + case auto + + @available(iOS 16.0, *) + func toUIMenuElementSize() -> UIMenu.ElementSize { + switch self { + case .small: + return .small + case .medium: + return .medium + case .large: + return .large + case .auto: + if #available(iOS 17.0, *) { + return .automatic + } else { + return .medium + } + } + } +} + +enum BarItemStyle: String, Enumerable { + case plain + case prominent + + func toUIBarButtonItemStyle() -> UIBarButtonItem.Style { + switch self { + case .plain: + return .plain + case .prominent: + return .done + } + } +} + +struct TitleStyle: Equatable { + var fontFamily: String? + var fontSize: Double? + var fontWeight: String? + var color: UIColor? +} diff --git a/packages/one/ios/One.podspec b/packages/one/ios/One.podspec new file mode 100644 index 000000000..f15b243e6 --- /dev/null +++ b/packages/one/ios/One.podspec @@ -0,0 +1,29 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'One' + s.version = package['version'] + s.summary = 'One Router Native Module' + s.description = 'Native iOS module for One Router - provides Link preview and context menu functionality' + s.license = package['license'] + s.author = 'Tamagui' + s.homepage = 'https://github.com/tamagui/one' + s.platforms = { + :ios => '15.1' + } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/tamagui/one.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.dependency 'RNScreens' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule', + } + + s.source_files = "**/*.{h,m,swift}" +end diff --git a/packages/one/ios/RouterViewWithLogger.swift b/packages/one/ios/RouterViewWithLogger.swift new file mode 100644 index 000000000..e134bdffb --- /dev/null +++ b/packages/one/ios/RouterViewWithLogger.swift @@ -0,0 +1,5 @@ +import ExpoModulesCore + +class RouterViewWithLogger: ExpoView { + lazy var logger = appContext?.jsLogger +} diff --git a/packages/one/package.json b/packages/one/package.json index 4176d5c6e..9a766cbd3 100644 --- a/packages/one/package.json +++ b/packages/one/package.json @@ -192,6 +192,7 @@ "peerDependencies": { "@react-navigation/drawer": "~7.7.2", "@react-navigation/native": "~7.1.0", + "expo": ">=53.0.0", "react-native": "*", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.3", @@ -203,6 +204,9 @@ "@react-navigation/drawer": { "optional": true }, + "expo": { + "optional": true + }, "react-native": { "optional": true }, diff --git a/packages/one/src/interfaces/router.ts b/packages/one/src/interfaces/router.ts index 3392e0ea2..774b9d2a8 100644 --- a/packages/one/src/interfaces/router.ts +++ b/packages/one/src/interfaces/router.ts @@ -416,6 +416,74 @@ export namespace OneRouter { (props: React.PropsWithChildren>): JSX.Element /** Helper method to resolve an Href object into a string. */ resolveHref: (href: Href) => string + /** + * Context menu for the link. iOS only. + * @platform ios + */ + Menu: React.FC + /** + * Individual menu action within a Link.Menu. iOS only. + * @platform ios + */ + MenuAction: React.FC + /** + * Preview content shown on long-press. iOS only. + * @platform ios + */ + Preview: React.FC + /** + * Trigger element for the link. iOS only. + * @platform ios + */ + Trigger: React.FC + } + + /** Props for Link.Menu component */ + export interface LinkMenuProps { + title?: string + subtitle?: string + icon?: string + palette?: boolean + displayAsPalette?: boolean + inline?: boolean + displayInline?: boolean + destructive?: boolean + elementSize?: 'small' | 'medium' | 'large' | 'auto' + children?: React.ReactNode + } + + /** Props for Link.MenuAction component */ + export interface LinkMenuActionProps { + children?: ReactNode + destructive?: boolean + disabled?: boolean + discoverabilityLabel?: string + hidden?: boolean + icon?: string + isOn?: boolean + onPress?: () => void + subtitle?: string + title?: string + unstable_keepPresented?: boolean + } + + /** Props for Link.Preview component */ + export interface LinkPreviewProps { + children?: React.ReactNode + style?: LinkPreviewStyle + } + + /** Style for Link.Preview component */ + export type LinkPreviewStyle = { + width?: number + height?: number + [key: string]: any + } + + /** Props for Link.Trigger component */ + export interface LinkTriggerProps { + children?: React.ReactNode + withAppleZoom?: boolean } /** diff --git a/packages/one/src/link/Link.tsx b/packages/one/src/link/Link.tsx index 5985cd68b..a53aa55d7 100644 --- a/packages/one/src/link/Link.tsx +++ b/packages/one/src/link/Link.tsx @@ -5,8 +5,48 @@ import * as React from 'react' import { type GestureResponderEvent, Platform, Text, type TextProps } from 'react-native' import type { OneRouter } from '../interfaces/router' +import { LinkMenu, LinkMenuAction, LinkPreview, LinkTrigger } from './elements' import { resolveHref } from './href' import { useLinkTo } from './useLinkTo' +import { InternalLinkPreviewContext } from './preview/InternalLinkPreviewContext' +import { NativeLinkPreview } from './preview/nativeLinkPreview' + +export type { LinkMenuActionProps, LinkMenuProps, LinkPreviewProps, LinkTriggerProps } from './elements' + +/** + * Extracts compound children (Link.Trigger, Link.Preview, Link.Menu) from children. + * Returns the trigger content and other compound children separately. + */ +function useCompoundChildren(children: React.ReactNode) { + return React.useMemo(() => { + let triggerChild: React.ReactNode = null + let previewChild: React.ReactNode = null + let menuChild: React.ReactNode = null + let hasCompoundChildren = false + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) return + + if (child.type === LinkTrigger) { + hasCompoundChildren = true + triggerChild = child + } else if (child.type === LinkPreview) { + hasCompoundChildren = true + previewChild = child + } else if (child.type === LinkMenu) { + hasCompoundChildren = true + menuChild = child + } + }) + + return { + hasCompoundChildren, + triggerChild, + previewChild, + menuChild, + } + }, [children]) +} /** * Component to render link to another route using a path. @@ -35,6 +75,8 @@ export const Link = React.forwardRef(function Link( }: OneRouter.LinkProps, ref: React.ForwardedRef ) { + const [isPreviewVisible, setIsPreviewVisible] = React.useState(false) + // Mutate the style prop to add the className on web. const style = useInteropClassName(rest) @@ -63,6 +105,73 @@ export const Link = React.forwardRef(function Link( props.onPress(e) } + // Extract compound children (Link.Trigger, Link.Preview, Link.Menu) + const { hasCompoundChildren, triggerChild, previewChild, menuChild } = + useCompoundChildren(rest.children) + + // Context value for compound children + const internalContextValue = React.useMemo( + () => ({ + isVisible: isPreviewVisible, + href: resolvedHref, + }), + [isPreviewVisible, resolvedHref] + ) + + // If using compound children pattern with asChild + if (hasCompoundChildren && asChild && triggerChild && React.isValidElement(triggerChild)) { + const triggerContent = (triggerChild as React.ReactElement<{ children?: React.ReactNode }>).props?.children + + // On iOS, wrap with native preview + if (Platform.OS === 'ios') { + return ( + + setIsPreviewVisible(true)} + onPreviewDidClose={() => setIsPreviewVisible(false)} + onPreviewTapped={() => { + onPress({} as any) + }} + > + + {triggerContent} + + {previewChild} + {menuChild} + + + ) + } + + // On other platforms, just render the trigger + return ( + + {triggerContent} + + ) + } + const Element = asChild ? Slot : Text // Avoid using createElement directly, favoring JSX, to allow tools like Nativewind to perform custom JSX handling on native. @@ -84,7 +193,12 @@ export const Link = React.forwardRef(function Link( ) }) as unknown as OneRouter.LinkComponent +// Assign static properties and sub-components Link.resolveHref = resolveHref +Link.Menu = LinkMenu +Link.MenuAction = LinkMenuAction +Link.Preview = LinkPreview +Link.Trigger = LinkTrigger // Mutate the style prop to add the className on web. function useInteropClassName(props: { style?: TextProps['style']; className?: string }) { diff --git a/packages/one/src/link/elements.tsx b/packages/one/src/link/elements.tsx new file mode 100644 index 000000000..5aeec8143 --- /dev/null +++ b/packages/one/src/link/elements.tsx @@ -0,0 +1,266 @@ +'use client' + +import React, { isValidElement, use, useId, type PropsWithChildren, type ReactNode } from 'react' +import type { ViewStyle } from 'react-native' +import { Slot } from '@radix-ui/react-slot' + +import { InternalLinkPreviewContext } from './preview/InternalLinkPreviewContext' +import { useIsPreview } from './preview/PreviewRouteContext' +import { NativeLinkPreviewAction, NativeLinkPreviewContent } from './preview/nativeLinkPreview' + +export interface LinkMenuActionProps { + /** + * The title of the menu item. + */ + children?: ReactNode + /** + * If `true`, the menu item will be displayed as destructive. + */ + destructive?: boolean + /** + * If `true`, the menu item will be disabled and not selectable. + */ + disabled?: boolean + /** + * An elaborated title that explains the purpose of the action. + */ + discoverabilityLabel?: string + /** + * Whether the menu element should be hidden. + * @default false + */ + hidden?: boolean + /** + * SF Symbol displayed alongside the menu item. + */ + icon?: string + /** + * If `true`, the menu item will be displayed as selected. + */ + isOn?: boolean + onPress?: () => void + /** + * An optional subtitle for the menu item. + */ + subtitle?: string + /** + * The title of the menu item. + * @deprecated Use `children` prop instead. + */ + title?: string + /** + * If `true`, the menu will be kept presented after the action is selected. + */ + unstable_keepPresented?: boolean +} + +/** + * This component renders a context menu action for a link. + * It should only be used as a child of `Link.Menu` or `LinkMenu`. + * + * @platform ios + */ +export function LinkMenuAction(props: LinkMenuActionProps) { + const identifier = useId() + const internalContext = use(InternalLinkPreviewContext) + + if (useIsPreview() || process.env.EXPO_OS !== 'ios' || !internalContext) { + return null + } + + const { unstable_keepPresented, onPress, children, title, ...rest } = props + const label = typeof children === 'string' ? children : title + + return ( + onPress?.()} + /> + ) +} + +export interface LinkMenuProps { + /** + * The title of the menu item + */ + title?: string + /** + * An optional subtitle for the submenu. Does not appear on `inline` menus. + */ + subtitle?: string + /** + * Optional SF Symbol displayed alongside the menu item. + */ + icon?: string + /** + * If `true`, the menu will be displayed as a palette. + * This means that the menu will be displayed as one row. + * + * > **Note**: Palette menus are only supported in submenus. + */ + palette?: boolean + /** + * @deprecated Use `palette` prop instead. + */ + displayAsPalette?: boolean + /** + * If `true`, the menu will be displayed inline. + * This means that the menu will not be collapsed + */ + inline?: boolean + /** + * @deprecated Use `inline` prop instead. + */ + displayInline?: boolean + /** + * If `true`, the menu item will be displayed as destructive. + */ + destructive?: boolean + /** + * The preferred size of the menu elements. + * `elementSize` property is ignored when `palette` is used. + * + * @platform iOS 16.0+ + */ + elementSize?: 'small' | 'medium' | 'large' | 'auto' + children?: React.ReactNode +} + +/** + * Groups context menu actions for a link. + * + * If multiple `Link.Menu` components are used within a single `Link`, only the first will be rendered. + * Only `Link.MenuAction` and `LinkMenuAction` components are allowed as children. + * + * @platform ios + */ +export const LinkMenu: React.FC = (props) => { + const identifier = useId() + const internalContext = use(InternalLinkPreviewContext) + + if (useIsPreview() || process.env.EXPO_OS !== 'ios' || !internalContext) { + return null + } + + const children = React.Children.toArray(props.children).filter( + (child) => isValidElement(child) && (child.type === LinkMenuAction || child.type === LinkMenu) + ) + const displayAsPalette = props.palette ?? props.displayAsPalette + const displayInline = props.inline ?? props.displayInline + + return ( + {}} + children={children} + identifier={identifier} + /> + ) +} + +export type LinkPreviewStyle = Omit & { + /** + * Sets the preferred width of the preview. + * If not set, full width of the screen will be used. + * + * This is only **preferred** width, the actual width may be different + */ + width?: number + + /** + * Sets the preferred height of the preview. + * If not set, full height of the screen will be used. + * + * This is only **preferred** height, the actual height may be different + */ + height?: number +} + +export interface LinkPreviewProps { + children?: React.ReactNode + /** + * Custom styles for the preview container. + * + * Note that some styles may not work, as they are limited or reset by the native view + */ + style?: LinkPreviewStyle +} + +/** + * A component used to render and customize the link preview. + * + * If `Link.Preview` is used without any props, it will render a preview of the `href` passed to the `Link`. + * + * If multiple `Link.Preview` components are used within a single `Link`, only the first one will be rendered. + * + * To customize the preview, you can pass custom content as children. + * + * @platform ios + */ +export function LinkPreview(props: LinkPreviewProps) { + const { children, style } = props + const internalPreviewContext = use(InternalLinkPreviewContext) + + if (useIsPreview() || process.env.EXPO_OS !== 'ios' || !internalPreviewContext) { + return null + } + + const { isVisible } = internalPreviewContext + const { width, height, ...restOfStyle } = style ?? {} + const contentSize = { + width: width ?? 0, + height: height ?? 0, + } + + const content = isVisible ? children : null + + return ( + + {content} + + ) +} + +export interface LinkTriggerProps extends PropsWithChildren { + /** + * A shorthand for enabling the Apple Zoom Transition on this link trigger. + * + * When set to `true`, the trigger will be wrapped with `Link.AppleZoom`. + * If another `Link.AppleZoom` is already used inside `Link.Trigger`, an error + * will be thrown. + * + * @platform ios 18+ + */ + withAppleZoom?: boolean +} + +/** + * Serves as the trigger for a link. + * The content inside this component will be rendered as part of the base link. + * + * If multiple `Link.Trigger` components are used within a single `Link`, only the first will be rendered. + * + * @platform ios + */ +export function LinkTrigger({ withAppleZoom: _withAppleZoom, ...props }: LinkTriggerProps) { + if (React.Children.count(props.children) > 1 || !isValidElement(props.children)) { + // If onPress is passed, this means that Link passed props to this component. + // We can assume that asChild is used, so we throw an error, because link will not work in this case. + if (props && typeof props === 'object' && 'onPress' in props) { + throw new Error( + 'When using Link.Trigger in an asChild Link, you must pass a single child element that will emit onPress event.' + ) + } + return props.children + } + + return +} diff --git a/packages/one/src/link/preview/InternalLinkPreviewContext.ts b/packages/one/src/link/preview/InternalLinkPreviewContext.ts new file mode 100644 index 000000000..366fdf7aa --- /dev/null +++ b/packages/one/src/link/preview/InternalLinkPreviewContext.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react' + +import type { OneRouter } from '../../interfaces/router' + +export const InternalLinkPreviewContext = createContext< + { isVisible: boolean; href: OneRouter.Href } | undefined +>(undefined) diff --git a/packages/one/src/link/preview/LinkPreviewContext.tsx b/packages/one/src/link/preview/LinkPreviewContext.tsx new file mode 100644 index 000000000..5ee58f596 --- /dev/null +++ b/packages/one/src/link/preview/LinkPreviewContext.tsx @@ -0,0 +1,25 @@ +import React, { createContext, useContext, useState, type ReactNode } from 'react' + +interface LinkPreviewContextValue { + openPreviewKey: string | undefined + setOpenPreviewKey: (key: string | undefined) => void +} + +const LinkPreviewContext = createContext({ + openPreviewKey: undefined, + setOpenPreviewKey: () => {}, +}) + +export function LinkPreviewProvider({ children }: { children: ReactNode }) { + const [openPreviewKey, setOpenPreviewKey] = useState(undefined) + + return ( + + {children} + + ) +} + +export function useLinkPreviewContext() { + return useContext(LinkPreviewContext) +} diff --git a/packages/one/src/link/preview/PreviewRouteContext.ts b/packages/one/src/link/preview/PreviewRouteContext.ts new file mode 100644 index 000000000..3c60aed4f --- /dev/null +++ b/packages/one/src/link/preview/PreviewRouteContext.ts @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react' + +const PreviewRouteContext = createContext(false) + +export const PreviewRouteProvider = PreviewRouteContext.Provider + +export function useIsPreview(): boolean { + return useContext(PreviewRouteContext) +} diff --git a/packages/one/src/link/preview/nativeLinkPreview.tsx b/packages/one/src/link/preview/nativeLinkPreview.tsx new file mode 100644 index 000000000..68e42e8a3 --- /dev/null +++ b/packages/one/src/link/preview/nativeLinkPreview.tsx @@ -0,0 +1,124 @@ +/** + * React Native implementation using native views (iOS only). + * Web and Android fall back to passthrough/null behavior. + */ + +import type { PropsWithChildren } from 'react' +import { Platform, StyleSheet, type ViewProps, type ColorValue } from 'react-native' + +// Check if native views are available (iOS only, bridgeless mode) +const areNativeViewsAvailable = + Platform.OS === 'ios' && + !Platform.isTV && + (globalThis as any).RN$Bridgeless === true + +// Conditionally load native views +let NativeLinkPreviewViewComponent: React.ComponentType | null = null +let LinkPreviewNativeActionViewComponent: React.ComponentType | null = null +let NativeLinkPreviewContentViewComponent: React.ComponentType | null = null + +if (areNativeViewsAvailable) { + try { + // Dynamic require to only load on iOS + const { requireNativeView } = require('expo') + NativeLinkPreviewViewComponent = requireNativeView('OneLinkPreviewModule', 'NativeLinkPreviewView') + LinkPreviewNativeActionViewComponent = requireNativeView( + 'OneLinkPreviewModule', + 'LinkPreviewNativeActionView' + ) + NativeLinkPreviewContentViewComponent = requireNativeView( + 'OneLinkPreviewModule', + 'NativeLinkPreviewContentView' + ) + } catch (e) { + // Native module not available, features disabled + if (__DEV__) { + console.warn('[one-router] Native link preview module not available:', e) + } + } +} + +// #region Action View +export interface NativeLinkPreviewActionProps { + identifier: string + title: string + icon?: string + children?: React.ReactNode + disabled?: boolean + destructive?: boolean + discoverabilityLabel?: string + subtitle?: string + accessibilityLabel?: string + accessibilityHint?: string + displayAsPalette?: boolean + displayInline?: boolean + preferredElementSize?: 'auto' | 'small' | 'medium' | 'large' + isOn?: boolean + keepPresented?: boolean + hidden?: boolean + tintColor?: ColorValue + barButtonItemStyle?: 'plain' | 'prominent' + sharesBackground?: boolean + hidesSharedBackground?: boolean + onSelected: () => void +} + +export function NativeLinkPreviewAction(props: NativeLinkPreviewActionProps) { + if (!LinkPreviewNativeActionViewComponent) { + return null + } + return +} +// #endregion + +// #region Preview View +export interface TabPath { + oldTabKey: string + newTabKey: string +} + +export interface NativeLinkPreviewProps extends ViewProps { + nextScreenId: string | undefined + tabPath: + | { + path: TabPath[] + } + | undefined + disableForceFlatten?: boolean + onWillPreviewOpen?: () => void + onDidPreviewOpen?: () => void + onPreviewWillClose?: () => void + onPreviewDidClose?: () => void + onPreviewTapped?: () => void + onPreviewTappedAnimationCompleted?: () => void + children: React.ReactNode +} + +export function NativeLinkPreview(props: NativeLinkPreviewProps) { + if (!NativeLinkPreviewViewComponent) { + return props.children as React.ReactElement + } + return +} +// #endregion + +// #region Preview Content View +export interface NativeLinkPreviewContentProps extends ViewProps { + preferredContentSize?: { width: number; height: number } +} + +export function NativeLinkPreviewContent(props: PropsWithChildren) { + if (!NativeLinkPreviewContentViewComponent) { + return null + } + const style = StyleSheet.flatten([ + props.style, + { + position: 'absolute' as const, + top: 0, + left: 0, + }, + ]) + return +} +// #endregion diff --git a/packages/one/types/interfaces/router.d.ts b/packages/one/types/interfaces/router.d.ts index df531790d..6ebacc994 100644 --- a/packages/one/types/interfaces/router.d.ts +++ b/packages/one/types/interfaces/router.d.ts @@ -290,6 +290,69 @@ export declare namespace OneRouter { (props: React.PropsWithChildren>): JSX.Element; /** Helper method to resolve an Href object into a string. */ resolveHref: (href: Href) => string; + /** + * Context menu for the link. iOS only. + * @platform ios + */ + Menu: React.FC; + /** + * Individual menu action within a Link.Menu. iOS only. + * @platform ios + */ + MenuAction: React.FC; + /** + * Preview content shown on long-press. iOS only. + * @platform ios + */ + Preview: React.FC; + /** + * Trigger element for the link. iOS only. + * @platform ios + */ + Trigger: React.FC; + } + /** Props for Link.Menu component */ + export interface LinkMenuProps { + title?: string; + subtitle?: string; + icon?: string; + palette?: boolean; + displayAsPalette?: boolean; + inline?: boolean; + displayInline?: boolean; + destructive?: boolean; + elementSize?: 'small' | 'medium' | 'large' | 'auto'; + children?: React.ReactNode; + } + /** Props for Link.MenuAction component */ + export interface LinkMenuActionProps { + children?: ReactNode; + destructive?: boolean; + disabled?: boolean; + discoverabilityLabel?: string; + hidden?: boolean; + icon?: string; + isOn?: boolean; + onPress?: () => void; + subtitle?: string; + title?: string; + unstable_keepPresented?: boolean; + } + /** Props for Link.Preview component */ + export interface LinkPreviewProps { + children?: React.ReactNode; + style?: LinkPreviewStyle; + } + /** Style for Link.Preview component */ + export type LinkPreviewStyle = { + width?: number; + height?: number; + [key: string]: any; + }; + /** Props for Link.Trigger component */ + export interface LinkTriggerProps { + children?: React.ReactNode; + withAppleZoom?: boolean; } /** * Component to render link to another route using a path. diff --git a/packages/one/types/link/Link.d.ts b/packages/one/types/link/Link.d.ts index 1a7c986b7..8650f6fea 100644 --- a/packages/one/types/link/Link.d.ts +++ b/packages/one/types/link/Link.d.ts @@ -1,4 +1,5 @@ import type { OneRouter } from '../interfaces/router'; +export type { LinkMenuActionProps, LinkMenuProps, LinkPreviewProps, LinkTriggerProps } from './elements'; /** * Component to render link to another route using a path. * Uses an anchor tag on the web. diff --git a/packages/one/types/link/elements.d.ts b/packages/one/types/link/elements.d.ts new file mode 100644 index 000000000..d7b88121a --- /dev/null +++ b/packages/one/types/link/elements.d.ts @@ -0,0 +1,168 @@ +import React, { type PropsWithChildren, type ReactNode } from 'react'; +import type { ViewStyle } from 'react-native'; +export interface LinkMenuActionProps { + /** + * The title of the menu item. + */ + children?: ReactNode; + /** + * If `true`, the menu item will be displayed as destructive. + */ + destructive?: boolean; + /** + * If `true`, the menu item will be disabled and not selectable. + */ + disabled?: boolean; + /** + * An elaborated title that explains the purpose of the action. + */ + discoverabilityLabel?: string; + /** + * Whether the menu element should be hidden. + * @default false + */ + hidden?: boolean; + /** + * SF Symbol displayed alongside the menu item. + */ + icon?: string; + /** + * If `true`, the menu item will be displayed as selected. + */ + isOn?: boolean; + onPress?: () => void; + /** + * An optional subtitle for the menu item. + */ + subtitle?: string; + /** + * The title of the menu item. + * @deprecated Use `children` prop instead. + */ + title?: string; + /** + * If `true`, the menu will be kept presented after the action is selected. + */ + unstable_keepPresented?: boolean; +} +/** + * This component renders a context menu action for a link. + * It should only be used as a child of `Link.Menu` or `LinkMenu`. + * + * @platform ios + */ +export declare function LinkMenuAction(props: LinkMenuActionProps): import("react/jsx-runtime").JSX.Element | null; +export interface LinkMenuProps { + /** + * The title of the menu item + */ + title?: string; + /** + * An optional subtitle for the submenu. Does not appear on `inline` menus. + */ + subtitle?: string; + /** + * Optional SF Symbol displayed alongside the menu item. + */ + icon?: string; + /** + * If `true`, the menu will be displayed as a palette. + * This means that the menu will be displayed as one row. + * + * > **Note**: Palette menus are only supported in submenus. + */ + palette?: boolean; + /** + * @deprecated Use `palette` prop instead. + */ + displayAsPalette?: boolean; + /** + * If `true`, the menu will be displayed inline. + * This means that the menu will not be collapsed + */ + inline?: boolean; + /** + * @deprecated Use `inline` prop instead. + */ + displayInline?: boolean; + /** + * If `true`, the menu item will be displayed as destructive. + */ + destructive?: boolean; + /** + * The preferred size of the menu elements. + * `elementSize` property is ignored when `palette` is used. + * + * @platform iOS 16.0+ + */ + elementSize?: 'small' | 'medium' | 'large' | 'auto'; + children?: React.ReactNode; +} +/** + * Groups context menu actions for a link. + * + * If multiple `Link.Menu` components are used within a single `Link`, only the first will be rendered. + * Only `Link.MenuAction` and `LinkMenuAction` components are allowed as children. + * + * @platform ios + */ +export declare const LinkMenu: React.FC; +export type LinkPreviewStyle = Omit & { + /** + * Sets the preferred width of the preview. + * If not set, full width of the screen will be used. + * + * This is only **preferred** width, the actual width may be different + */ + width?: number; + /** + * Sets the preferred height of the preview. + * If not set, full height of the screen will be used. + * + * This is only **preferred** height, the actual height may be different + */ + height?: number; +}; +export interface LinkPreviewProps { + children?: React.ReactNode; + /** + * Custom styles for the preview container. + * + * Note that some styles may not work, as they are limited or reset by the native view + */ + style?: LinkPreviewStyle; +} +/** + * A component used to render and customize the link preview. + * + * If `Link.Preview` is used without any props, it will render a preview of the `href` passed to the `Link`. + * + * If multiple `Link.Preview` components are used within a single `Link`, only the first one will be rendered. + * + * To customize the preview, you can pass custom content as children. + * + * @platform ios + */ +export declare function LinkPreview(props: LinkPreviewProps): import("react/jsx-runtime").JSX.Element | null; +export interface LinkTriggerProps extends PropsWithChildren { + /** + * A shorthand for enabling the Apple Zoom Transition on this link trigger. + * + * When set to `true`, the trigger will be wrapped with `Link.AppleZoom`. + * If another `Link.AppleZoom` is already used inside `Link.Trigger`, an error + * will be thrown. + * + * @platform ios 18+ + */ + withAppleZoom?: boolean; +} +/** + * Serves as the trigger for a link. + * The content inside this component will be rendered as part of the base link. + * + * If multiple `Link.Trigger` components are used within a single `Link`, only the first will be rendered. + * + * @platform ios + */ +export declare function LinkTrigger({ withAppleZoom: _withAppleZoom, ...props }: LinkTriggerProps): string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | import("react/jsx-runtime").JSX.Element | null | undefined; +//# sourceMappingURL=elements.d.ts.map \ No newline at end of file diff --git a/packages/one/types/link/preview/InternalLinkPreviewContext.d.ts b/packages/one/types/link/preview/InternalLinkPreviewContext.d.ts new file mode 100644 index 000000000..13e087bfc --- /dev/null +++ b/packages/one/types/link/preview/InternalLinkPreviewContext.d.ts @@ -0,0 +1,6 @@ +import type { OneRouter } from '../../interfaces/router'; +export declare const InternalLinkPreviewContext: import("react").Context<{ + isVisible: boolean; + href: OneRouter.Href; +} | undefined>; +//# sourceMappingURL=InternalLinkPreviewContext.d.ts.map \ No newline at end of file diff --git a/packages/one/types/link/preview/LinkPreviewContext.d.ts b/packages/one/types/link/preview/LinkPreviewContext.d.ts new file mode 100644 index 000000000..8f5ddf14c --- /dev/null +++ b/packages/one/types/link/preview/LinkPreviewContext.d.ts @@ -0,0 +1,11 @@ +import { type ReactNode } from 'react'; +interface LinkPreviewContextValue { + openPreviewKey: string | undefined; + setOpenPreviewKey: (key: string | undefined) => void; +} +export declare function LinkPreviewProvider({ children }: { + children: ReactNode; +}): import("react/jsx-runtime").JSX.Element; +export declare function useLinkPreviewContext(): LinkPreviewContextValue; +export {}; +//# sourceMappingURL=LinkPreviewContext.d.ts.map \ No newline at end of file diff --git a/packages/one/types/link/preview/PreviewRouteContext.d.ts b/packages/one/types/link/preview/PreviewRouteContext.d.ts new file mode 100644 index 000000000..e16ac1b4e --- /dev/null +++ b/packages/one/types/link/preview/PreviewRouteContext.d.ts @@ -0,0 +1,3 @@ +export declare const PreviewRouteProvider: import("react").Provider; +export declare function useIsPreview(): boolean; +//# sourceMappingURL=PreviewRouteContext.d.ts.map \ No newline at end of file diff --git a/packages/one/types/link/preview/native.d.ts b/packages/one/types/link/preview/native.d.ts new file mode 100644 index 000000000..6f9a3c8b3 --- /dev/null +++ b/packages/one/types/link/preview/native.d.ts @@ -0,0 +1,57 @@ +/** + * React Native implementation using native views (iOS only). + * Web and Android fall back to passthrough/null behavior. + */ +import type { PropsWithChildren } from 'react'; +import { type ViewProps, type ColorValue } from 'react-native'; +export interface NativeLinkPreviewActionProps { + identifier: string; + title: string; + icon?: string; + children?: React.ReactNode; + disabled?: boolean; + destructive?: boolean; + discoverabilityLabel?: string; + subtitle?: string; + accessibilityLabel?: string; + accessibilityHint?: string; + displayAsPalette?: boolean; + displayInline?: boolean; + preferredElementSize?: 'auto' | 'small' | 'medium' | 'large'; + isOn?: boolean; + keepPresented?: boolean; + hidden?: boolean; + tintColor?: ColorValue; + barButtonItemStyle?: 'plain' | 'prominent'; + sharesBackground?: boolean; + hidesSharedBackground?: boolean; + onSelected: () => void; +} +export declare function NativeLinkPreviewAction(props: NativeLinkPreviewActionProps): import("react/jsx-runtime").JSX.Element | null; +export interface TabPath { + oldTabKey: string; + newTabKey: string; +} +export interface NativeLinkPreviewProps extends ViewProps { + nextScreenId: string | undefined; + tabPath: { + path: TabPath[]; + } | undefined; + disableForceFlatten?: boolean; + onWillPreviewOpen?: () => void; + onDidPreviewOpen?: () => void; + onPreviewWillClose?: () => void; + onPreviewDidClose?: () => void; + onPreviewTapped?: () => void; + onPreviewTappedAnimationCompleted?: () => void; + children: React.ReactNode; +} +export declare function NativeLinkPreview(props: NativeLinkPreviewProps): import("react/jsx-runtime").JSX.Element; +export interface NativeLinkPreviewContentProps extends ViewProps { + preferredContentSize?: { + width: number; + height: number; + }; +} +export declare function NativeLinkPreviewContent(props: PropsWithChildren): import("react/jsx-runtime").JSX.Element | null; +//# sourceMappingURL=native.d.ts.map \ No newline at end of file diff --git a/packages/one/types/link/preview/native.native.d.ts b/packages/one/types/link/preview/native.native.d.ts new file mode 100644 index 000000000..5582319cd --- /dev/null +++ b/packages/one/types/link/preview/native.native.d.ts @@ -0,0 +1,57 @@ +/** + * React Native implementation using native views (iOS). + * Android falls back to web behavior (no context menus). + */ +import type { PropsWithChildren } from 'react'; +import { type ViewProps, type ColorValue } from 'react-native'; +export interface NativeLinkPreviewActionProps { + identifier: string; + title: string; + icon?: string; + children?: React.ReactNode; + disabled?: boolean; + destructive?: boolean; + discoverabilityLabel?: string; + subtitle?: string; + accessibilityLabel?: string; + accessibilityHint?: string; + displayAsPalette?: boolean; + displayInline?: boolean; + preferredElementSize?: 'auto' | 'small' | 'medium' | 'large'; + isOn?: boolean; + keepPresented?: boolean; + hidden?: boolean; + tintColor?: ColorValue; + barButtonItemStyle?: 'plain' | 'prominent'; + sharesBackground?: boolean; + hidesSharedBackground?: boolean; + onSelected: () => void; +} +export declare function NativeLinkPreviewAction(props: NativeLinkPreviewActionProps): import("react/jsx-runtime").JSX.Element | null; +export interface TabPath { + oldTabKey: string; + newTabKey: string; +} +export interface NativeLinkPreviewProps extends ViewProps { + nextScreenId: string | undefined; + tabPath: { + path: TabPath[]; + } | undefined; + disableForceFlatten?: boolean; + onWillPreviewOpen?: () => void; + onDidPreviewOpen?: () => void; + onPreviewWillClose?: () => void; + onPreviewDidClose?: () => void; + onPreviewTapped?: () => void; + onPreviewTappedAnimationCompleted?: () => void; + children: React.ReactNode; +} +export declare function NativeLinkPreview(props: NativeLinkPreviewProps): import("react/jsx-runtime").JSX.Element; +export interface NativeLinkPreviewContentProps extends ViewProps { + preferredContentSize?: { + width: number; + height: number; + }; +} +export declare function NativeLinkPreviewContent(props: PropsWithChildren): import("react/jsx-runtime").JSX.Element | null; +//# sourceMappingURL=native.native.d.ts.map \ No newline at end of file diff --git a/packages/one/types/link/preview/nativeLinkPreview.d.ts b/packages/one/types/link/preview/nativeLinkPreview.d.ts new file mode 100644 index 000000000..0908e0958 --- /dev/null +++ b/packages/one/types/link/preview/nativeLinkPreview.d.ts @@ -0,0 +1,57 @@ +/** + * React Native implementation using native views (iOS only). + * Web and Android fall back to passthrough/null behavior. + */ +import type { PropsWithChildren } from 'react'; +import { type ViewProps, type ColorValue } from 'react-native'; +export interface NativeLinkPreviewActionProps { + identifier: string; + title: string; + icon?: string; + children?: React.ReactNode; + disabled?: boolean; + destructive?: boolean; + discoverabilityLabel?: string; + subtitle?: string; + accessibilityLabel?: string; + accessibilityHint?: string; + displayAsPalette?: boolean; + displayInline?: boolean; + preferredElementSize?: 'auto' | 'small' | 'medium' | 'large'; + isOn?: boolean; + keepPresented?: boolean; + hidden?: boolean; + tintColor?: ColorValue; + barButtonItemStyle?: 'plain' | 'prominent'; + sharesBackground?: boolean; + hidesSharedBackground?: boolean; + onSelected: () => void; +} +export declare function NativeLinkPreviewAction(props: NativeLinkPreviewActionProps): import("react/jsx-runtime").JSX.Element | null; +export interface TabPath { + oldTabKey: string; + newTabKey: string; +} +export interface NativeLinkPreviewProps extends ViewProps { + nextScreenId: string | undefined; + tabPath: { + path: TabPath[]; + } | undefined; + disableForceFlatten?: boolean; + onWillPreviewOpen?: () => void; + onDidPreviewOpen?: () => void; + onPreviewWillClose?: () => void; + onPreviewDidClose?: () => void; + onPreviewTapped?: () => void; + onPreviewTappedAnimationCompleted?: () => void; + children: React.ReactNode; +} +export declare function NativeLinkPreview(props: NativeLinkPreviewProps): import("react/jsx-runtime").JSX.Element; +export interface NativeLinkPreviewContentProps extends ViewProps { + preferredContentSize?: { + width: number; + height: number; + }; +} +export declare function NativeLinkPreviewContent(props: PropsWithChildren): import("react/jsx-runtime").JSX.Element | null; +//# sourceMappingURL=nativeLinkPreview.d.ts.map \ No newline at end of file