diff --git a/apps/onestack.dev/data/docs/components-Link.mdx b/apps/onestack.dev/data/docs/components-Link.mdx
index df96288f4..216db32ee 100644
--- a/apps/onestack.dev/data/docs/components-Link.mdx
+++ b/apps/onestack.dev/data/docs/components-Link.mdx
@@ -96,3 +96,140 @@ export interface LinkProps extends TextProps {
mask?: Href
}
```
+
+---
+
+## Link Preview (iOS)
+
+Link preview (also known as "Peek and Pop") shows a preview popup of a screen when users long-press a link. This is an iOS-only feature that works automatically on devices using the new architecture.
+
+### Requirements
+
+- iOS 15.1+
+- React Native New Architecture enabled (bridgeless mode)
+- Non-Apple TV device
+
+### No Installation Required
+
+Link preview is bundled with the `one` package. It works automatically on supported iOS devices and gracefully does nothing on unsupported platforms (web, Android, old architecture).
+
+### Basic Usage
+
+Use the compound `Link` components to enable preview:
+
+```tsx
+import { Link } from 'one'
+
+export function ProfileLink() {
+ return (
+
+
+
+ View Profile
+
+
+
+
+ )
+}
+```
+
+### Custom Preview Content
+
+By default, `Link.Preview` renders a snapshot of the destination screen. You can customize it:
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+### Context Menus
+
+Add actions that appear when the preview is shown:
+
+```tsx
+
+
+
+
+
+
+
+ Add to Favorites
+
+
+ Share
+
+
+ Delete
+
+
+
+```
+
+### Link.MenuAction Props
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `children` | `ReactNode` | The title of the menu item |
+| `icon` | `string` | SF Symbol name |
+| `onPress` | `() => void` | Callback when selected |
+| `destructive` | `boolean` | Display as destructive (red) |
+| `disabled` | `boolean` | Disable the action |
+| `subtitle` | `string` | Optional subtitle |
+| `hidden` | `boolean` | Hide the action |
+| `isOn` | `boolean` | Display as selected (checkmark) |
+
+### Nested Menus
+
+Create submenus for grouped actions:
+
+```tsx
+
+
+ Like
+
+
+
+ Report
+
+
+ Hide
+
+
+
+```
+
+### Detecting Preview State
+
+Use `useIsPreview()` to adjust component behavior when rendered inside a preview:
+
+```tsx
+import { useIsPreview } from 'one'
+
+function MyComponent() {
+ const isPreview = useIsPreview()
+
+ if (isPreview) {
+ // Render simplified version for preview
+ return
+ }
+
+ return
+}
+```
+
+### Platform Behavior
+
+| Platform | Behavior |
+|----------|----------|
+| iOS (new arch) | Full link preview with context menu |
+| iOS (old arch) | Gracefully disabled, link works normally |
+| Android | Gracefully disabled, link works normally |
+| Web | Gracefully disabled, link works normally |
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 6ad87e33c..38d04110a 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