Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions apps/onestack.dev/data/docs/components-Link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Link href="/profile" asChild>
<Link.Trigger>
<Pressable>
<Text>View Profile</Text>
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
)
}
```

### Custom Preview Content

By default, `Link.Preview` renders a snapshot of the destination screen. You can customize it:

```tsx
<Link href="/user/123" asChild>
<Link.Trigger>
<UserCard />
</Link.Trigger>
<Link.Preview style={{ width: 300, height: 200 }}>
<UserPreviewCard userId="123" />
</Link.Preview>
</Link>
```

### Context Menus

Add actions that appear when the preview is shown:

```tsx
<Link href="/photo/456" asChild>
<Link.Trigger>
<PhotoThumbnail />
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction icon="star" onPress={handleFavorite}>
Add to Favorites
</Link.MenuAction>
<Link.MenuAction icon="square.and.arrow.up" onPress={handleShare}>
Share
</Link.MenuAction>
<Link.MenuAction icon="trash" destructive onPress={handleDelete}>
Delete
</Link.MenuAction>
</Link.Menu>
</Link>
```

### 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
<Link.Menu>
<Link.MenuAction icon="heart" onPress={handleLike}>
Like
</Link.MenuAction>
<Link.Menu title="More" icon="ellipsis.circle">
<Link.MenuAction icon="flag" onPress={handleReport}>
Report
</Link.MenuAction>
<Link.MenuAction icon="eye.slash" onPress={handleHide}>
Hide
</Link.MenuAction>
</Link.Menu>
</Link.Menu>
```

### 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 <SimplifiedView />
}

return <FullView />
}
```

### 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 |
6 changes: 6 additions & 0 deletions packages/one/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["OneLinkPreviewModule"]
}
}
195 changes: 195 additions & 0 deletions packages/one/ios/LinkPreview/LinkPreviewNativeActionView.swift
Original file line number Diff line number Diff line change
@@ -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<Value: Equatable> {
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<EnclosingSelf: LinkPreviewNativeActionView>(
_enclosingInstance instance: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, NativeActionProp<Value>>
) -> 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
}
}
Loading
Loading