From 3813f1be58495fac724911fb1f83e14257a4efac Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 28 Jan 2026 11:48:30 +0100 Subject: [PATCH 1/2] feat: add iOS Link component --- .../main/java/voltra/generated/ShortNames.kt | 1 + data/components.json | 14 ++ .../DeepLinksLiveActivityUI.tsx | 51 +++++ .../live-activities/DeepLinksLiveActivity.tsx | 42 +++++ .../live-activities/LiveActivitiesScreen.tsx | 24 ++- ios/shared/ComponentTypeID.swift | 4 + ios/shared/ShortNames.swift | 1 + ios/shared/VoltraNode.swift | 3 + .../Generated/Parameters/LinkParameters.swift | 15 ++ ios/ui/Helpers/VoltraDeepLinkResolver.swift | 24 ++- ios/ui/Views/VoltraLink.swift | 25 +++ src/jsx/Link.tsx | 5 + src/jsx/primitives.ts | 1 + src/jsx/props/Link.ts | 10 + src/payload/component-ids.ts | 2 + src/payload/short-names.ts | 2 + website/docs/ios/components/interactive.md | 174 +++++++++++++++++- website/docs/ios/components/overview.md | 6 +- website/docs/ios/development/interactions.md | 36 +++- 19 files changed, 422 insertions(+), 18 deletions(-) create mode 100644 example/components/live-activities/DeepLinksLiveActivityUI.tsx create mode 100644 example/screens/live-activities/DeepLinksLiveActivity.tsx create mode 100644 ios/ui/Generated/Parameters/LinkParameters.swift create mode 100644 ios/ui/Views/VoltraLink.swift create mode 100644 src/jsx/Link.tsx create mode 100644 src/jsx/props/Link.ts diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt index 109166f..6eac589 100644 --- a/android/src/main/java/voltra/generated/ShortNames.kt +++ b/android/src/main/java/voltra/generated/ShortNames.kt @@ -42,6 +42,7 @@ object ShortNames { "cvl" to "currentValueLabel", "dlu" to "deepLinkUrl", "dv" to "defaultValue", + "dest" to "destination", "dir" to "direction", "dth" to "dither", "dur" to "durationMs", diff --git a/data/components.json b/data/components.json index 32ac349..47d2529 100644 --- a/data/components.json +++ b/data/components.json @@ -62,6 +62,7 @@ "enabled": "en", "id": "id", "deepLinkUrl": "dlu", + "destination": "dest", "padding": "pad", "text": "txt", "paddingVertical": "pv", @@ -1172,6 +1173,19 @@ "description": "Text color" } } + }, + { + "name": "Link", + "description": "Navigable link that opens a URL when tapped", + "swiftAvailability": "iOS 14.0, macOS 11.0", + "hasChildren": true, + "parameters": { + "destination": { + "type": "string", + "optional": false, + "description": "URL to navigate to when the link is tapped" + } + } } ] } diff --git a/example/components/live-activities/DeepLinksLiveActivityUI.tsx b/example/components/live-activities/DeepLinksLiveActivityUI.tsx new file mode 100644 index 0000000..563356b --- /dev/null +++ b/example/components/live-activities/DeepLinksLiveActivityUI.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { Voltra } from 'voltra' + +export function DeepLinksLiveActivityUI() { + return ( + + {/* Link Examples */} + + {/* Link with absolute URL */} + + + + + Order #123 + Tap to view details + + + + + + + {/* Link with relative path */} + + + + + Settings + Manage preferences + + + + + + + + ) +} diff --git a/example/screens/live-activities/DeepLinksLiveActivity.tsx b/example/screens/live-activities/DeepLinksLiveActivity.tsx new file mode 100644 index 0000000..f76fb57 --- /dev/null +++ b/example/screens/live-activities/DeepLinksLiveActivity.tsx @@ -0,0 +1,42 @@ +import React, { forwardRef, useEffect, useImperativeHandle } from 'react' +import { useLiveActivity } from 'voltra/client' + +import { DeepLinksLiveActivityUI } from '../../components/live-activities/DeepLinksLiveActivityUI' +import { LiveActivityExampleComponent } from './types' + +const DeepLinksLiveActivity: LiveActivityExampleComponent = forwardRef( + ({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => { + const { start, update, end, isActive } = useLiveActivity( + { + lockScreen: { + content: , + }, + island: { + keylineTint: '#3B82F6', + }, + }, + { + activityName: 'deep-links', + autoUpdate, + autoStart, + deepLinkUrl: '/voltraui/deep-links', + } + ) + + useEffect(() => { + onIsActiveChange?.(isActive) + }, [isActive, onIsActiveChange]) + + useImperativeHandle(ref, () => ({ + start, + update, + end, + })) + + return null + } +) + +DeepLinksLiveActivity.displayName = 'DeepLinksLiveActivity' + +export default DeepLinksLiveActivity diff --git a/example/screens/live-activities/LiveActivitiesScreen.tsx b/example/screens/live-activities/LiveActivitiesScreen.tsx index d3e8403..4572b54 100644 --- a/example/screens/live-activities/LiveActivitiesScreen.tsx +++ b/example/screens/live-activities/LiveActivitiesScreen.tsx @@ -8,6 +8,7 @@ import { Card } from '~/components/Card' import { NotificationsCard } from '~/components/NotificationsCard' import BasicLiveActivity from '~/screens/live-activities/BasicLiveActivity' import CompassLiveActivity from '~/screens/live-activities/CompassLiveActivity' +import DeepLinksLiveActivity from '~/screens/live-activities/DeepLinksLiveActivity' import FlightLiveActivity from '~/screens/live-activities/FlightLiveActivity' import LiquidGlassLiveActivity from '~/screens/live-activities/LiquidGlassLiveActivity' import MusicPlayerLiveActivity from '~/screens/live-activities/MusicPlayerLiveActivity' @@ -16,7 +17,15 @@ import WorkoutLiveActivity from '~/screens/live-activities/WorkoutLiveActivity' import { LiveActivityExampleComponentRef } from './types' -type ActivityKey = 'basic' | 'stylesheet' | 'glass' | 'flight' | 'workout' | 'compass' | 'supplementalFamilies' +type ActivityKey = + | 'basic' + | 'stylesheet' + | 'glass' + | 'deepLinks' + | 'flight' + | 'workout' + | 'compass' + | 'supplementalFamilies' const ACTIVITY_METADATA: Record = { basic: { @@ -31,6 +40,10 @@ const ACTIVITY_METADATA: Record(null) const stylesheetRef = useRef(null) const glassRef = useRef(null) + const deepLinksRef = useRef(null) const flightRef = useRef(null) const workoutRef = useRef(null) const compassRef = useRef(null) @@ -86,6 +102,7 @@ export default function LiveActivitiesScreen() { basic: basicRef, stylesheet: stylesheetRef, glass: glassRef, + deepLinks: deepLinksRef, flight: flightRef, workout: workoutRef, compass: compassRef, @@ -113,6 +130,10 @@ export default function LiveActivitiesScreen() { (isActive: boolean) => handleStatusChange('glass', isActive), [handleStatusChange] ) + const handleDeepLinksStatusChange = useCallback( + (isActive: boolean) => handleStatusChange('deepLinks', isActive), + [handleStatusChange] + ) const handleFlightStatusChange = useCallback( (isActive: boolean) => handleStatusChange('flight', isActive), [handleStatusChange] @@ -201,6 +222,7 @@ export default function LiveActivitiesScreen() { + diff --git a/ios/shared/ComponentTypeID.swift b/ios/shared/ComponentTypeID.swift index a7a5a1c..5338223 100644 --- a/ios/shared/ComponentTypeID.swift +++ b/ios/shared/ComponentTypeID.swift @@ -29,6 +29,7 @@ public enum ComponentTypeID: Int, Codable { case SPACER = 16 case DIVIDER = 17 case MASK = 18 + case LINK = 19 /// Get the component name string for this ID public var componentName: String { @@ -71,6 +72,8 @@ public enum ComponentTypeID: Int, Codable { return "Divider" case .MASK: return "Mask" + case .LINK: + return "Link" } } @@ -98,6 +101,7 @@ public enum ComponentTypeID: Int, Codable { case "Spacer": self = .SPACER case "Divider": self = .DIVIDER case "Mask": self = .MASK + case "Link": self = .LINK default: return nil } diff --git a/ios/shared/ShortNames.swift b/ios/shared/ShortNames.swift index 17e5d10..762c0b6 100644 --- a/ios/shared/ShortNames.swift +++ b/ios/shared/ShortNames.swift @@ -39,6 +39,7 @@ public enum ShortNames { "cvl": "currentValueLabel", "dlu": "deepLinkUrl", "dv": "defaultValue", + "dest": "destination", "dir": "direction", "dth": "dither", "dur": "durationMs", diff --git a/ios/shared/VoltraNode.swift b/ios/shared/VoltraNode.swift index c95c26e..4d47213 100644 --- a/ios/shared/VoltraNode.swift +++ b/ios/shared/VoltraNode.swift @@ -122,6 +122,9 @@ struct VoltraElementView: View { case "Button": VoltraButton(element) + case "Link": + VoltraLink(element) + case "VStack": VoltraVStack(element) diff --git a/ios/ui/Generated/Parameters/LinkParameters.swift b/ios/ui/Generated/Parameters/LinkParameters.swift new file mode 100644 index 0000000..fc1d4e2 --- /dev/null +++ b/ios/ui/Generated/Parameters/LinkParameters.swift @@ -0,0 +1,15 @@ +// +// LinkParameters.swift + +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import Foundation + +/// Parameters for Link component +/// Navigable link that opens a URL when tapped +public struct LinkParameters: ComponentParameters { + /// URL to navigate to when the link is tapped + public let destination: String? +} diff --git a/ios/ui/Helpers/VoltraDeepLinkResolver.swift b/ios/ui/Helpers/VoltraDeepLinkResolver.swift index 83c724f..9688003 100644 --- a/ios/ui/Helpers/VoltraDeepLinkResolver.swift +++ b/ios/ui/Helpers/VoltraDeepLinkResolver.swift @@ -18,12 +18,26 @@ enum VoltraDeepLinkResolver { _ attributes: VoltraAttributes ) -> URL? { if let raw = attributes.deepLinkUrl, !raw.isEmpty { - if raw.contains("://"), let url = URL(string: raw) { return url } - if let scheme = deepLinkScheme() { - let path = raw.hasPrefix("/") ? raw : "/\(raw)" - return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))") - } + return resolveUrl(raw) + } + return nil + } + + /// Resolves a URL string, supporting both absolute and relative paths + /// - Parameter raw: The URL string (e.g., "myapp://path", "/path", or "path") + /// - Returns: A resolved URL, or nil if invalid + static func resolveUrl(_ raw: String) -> URL? { + guard !raw.isEmpty else { return nil } + + // If it's already an absolute URL, use it as-is + if raw.contains("://"), let url = URL(string: raw) { return url } + + // Otherwise, prepend the app's URL scheme + if let scheme = deepLinkScheme() { + let path = raw.hasPrefix("/") ? raw : "/\(raw)" + return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))") } + return nil } } diff --git a/ios/ui/Views/VoltraLink.swift b/ios/ui/Views/VoltraLink.swift new file mode 100644 index 0000000..f31f8e6 --- /dev/null +++ b/ios/ui/Views/VoltraLink.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct VoltraLink: VoltraView { + public typealias Parameters = LinkParameters + public let element: VoltraElement + + public init(_ element: VoltraElement) { + self.element = element + } + + public var body: some View { + if let urlString = params.destination, + let url = VoltraDeepLinkResolver.resolveUrl(urlString) + { + Link(destination: url) { + element.children ?? .empty + } + .applyStyle(element.style) + } else { + // Fallback: render children without link if destination is invalid + (element.children ?? .empty) + .applyStyle(element.style) + } + } +} diff --git a/src/jsx/Link.tsx b/src/jsx/Link.tsx new file mode 100644 index 0000000..632f84b --- /dev/null +++ b/src/jsx/Link.tsx @@ -0,0 +1,5 @@ +import { createVoltraComponent } from './createVoltraComponent.js' +import type { LinkProps } from './props/Link.js' + +export type { LinkProps } +export const Link = createVoltraComponent('Link') diff --git a/src/jsx/primitives.ts b/src/jsx/primitives.ts index 358a13a..be72a65 100644 --- a/src/jsx/primitives.ts +++ b/src/jsx/primitives.ts @@ -7,6 +7,7 @@ export * from './GroupBox.js' export * from './HStack.js' export * from './Image.js' export * from './Label.js' +export * from './Link.js' export * from './LinearGradient.js' export * from './LinearProgressView.js' export * from './Mask.js' diff --git a/src/jsx/props/Link.ts b/src/jsx/props/Link.ts new file mode 100644 index 0000000..8021729 --- /dev/null +++ b/src/jsx/props/Link.ts @@ -0,0 +1,10 @@ +// 🤖 AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +import type { VoltraBaseProps } from '../baseProps' + +export type LinkProps = VoltraBaseProps & { + /** URL to navigate to when the link is tapped */ + destination: string +} diff --git a/src/payload/component-ids.ts b/src/payload/component-ids.ts index bd70a34..9556609 100644 --- a/src/payload/component-ids.ts +++ b/src/payload/component-ids.ts @@ -27,6 +27,7 @@ export const COMPONENT_NAME_TO_ID: Record = { Spacer: 16, Divider: 17, Mask: 18, + Link: 19, } /** @@ -52,6 +53,7 @@ export const COMPONENT_ID_TO_NAME: Record = { 16: 'Spacer', 17: 'Divider', 18: 'Mask', + 19: 'Link', } /** diff --git a/src/payload/short-names.ts b/src/payload/short-names.ts index 1bfc33a..a992747 100644 --- a/src/payload/short-names.ts +++ b/src/payload/short-names.ts @@ -35,6 +35,7 @@ export const NAME_TO_SHORT: Record = { currentValueLabel: 'cvl', deepLinkUrl: 'dlu', defaultValue: 'dv', + destination: 'dest', direction: 'dir', dither: 'dth', durationMs: 'dur', @@ -181,6 +182,7 @@ export const SHORT_TO_NAME: Record = { cvl: 'currentValueLabel', dlu: 'deepLinkUrl', dv: 'defaultValue', + dest: 'destination', dir: 'direction', dth: 'dither', dur: 'durationMs', diff --git a/website/docs/ios/components/interactive.md b/website/docs/ios/components/interactive.md index 41364bd..d4338b1 100644 --- a/website/docs/ios/components/interactive.md +++ b/website/docs/ios/components/interactive.md @@ -1,13 +1,177 @@ # Interactive Controls (iOS) -User interface controls that respond to user interaction in Live Activities. These work via AppIntents and require iOS 17.0+ for interactivity. +User interface controls that respond to user interaction in Live Activities. -### Button +--- + +## Button + +An interactive button component that triggers in-app events via interaction intents. + +**Parameters:** + +- `buttonStyle` (string, optional): Visual style of the button: + - `"automatic"` - System-determined style + - `"bordered"` - Bordered style + - `"borderedProminent"` - Bordered with prominent fill + - `"plain"` - Plain style without border + - `"borderless"` - Borderless style + +**Apple Documentation:** [Button](https://developer.apple.com/documentation/swiftui/button) + +**Availability:** iOS 17.0+ (interaction intents) + +### Usage + +Buttons fire interaction events that you can handle in your app: + +```tsx + + Play Music + +``` + +Handle the event: + +```typescript +import { addVoltraListener } from 'voltra/client' + +const subscription = addVoltraListener('interaction', (event) => { + if (event.identifier === 'play-button') { + // Handle play action + } +}) +``` -Triggers an action/intent when pressed. +### Examples + +**Styled button:** + +```tsx + + Save Changes + +``` + +**Button with icon:** + +```tsx + + + + Delete + + +``` + +**Compact button:** + +```tsx + + + +``` --- -### Toggle +## Link + +A navigable link component that opens a URL when tapped. Uses SwiftUI's native Link for semantic navigation. + +**Parameters:** + +- `destination` (string, required): URL to navigate to when tapped. Supports both absolute URLs and relative paths. + +**Apple Documentation:** [Link](https://developer.apple.com/documentation/swiftui/link) + +**Availability:** iOS 14.0+ + +### URL Normalization + +Link automatically normalizes URLs using your app's URL scheme: + +- Absolute URLs: Used as-is (`"myapp://orders/123"`, `"https://example.com"`) +- Relative with `/`: `"/settings"` → `"myapp://settings"` +- Relative without `/`: `"help"` → `"myapp://help"` + +### Examples + +**Link with absolute URL:** + +```tsx + + + + + Order #123 + Tap to view details + + + +``` + +**Link with relative path:** + +```tsx + + + + Open Settings + + +``` + +**External link:** + +```tsx + + + + Visit Support Site + + +``` + +### When to use Link vs Button + +| Feature | Link | Button | +|---------|------|--------| +| **Use Case** | Navigation to URLs | In-app actions/events | +| **Visual** | Unstyled (custom via children) | Button styling (bordered, prominent, etc.) | +| **iOS Version** | 14.0+ | 17.0+ | +| **Tap Behavior** | Opens URL | Fires interaction event | +| **Mechanism** | SwiftUI Link | AppIntents (VoltraInteractionIntent) | + +**Recommendation:** Use `Link` for navigation (e.g., list items, cards that open URLs). Use `Button` for actions that your app needs to handle (e.g., play/pause, save, delete). + +--- + +## Toggle + +Toggles a boolean state via an intent. Fires an interaction event when changed. + +**Parameters:** + +- `defaultValue` (boolean, optional): Initial toggle state (default: `false`) + +**Apple Documentation:** [Toggle](https://developer.apple.com/documentation/swiftui/toggle) + +**Availability:** iOS 17.0+ + +**Example:** + +```tsx + +``` + +Handle toggle events: + +```typescript +import { addVoltraListener } from 'voltra/client' -Toggles a boolean state via an intent. +const subscription = addVoltraListener('interaction', (event) => { + if (event.identifier === 'notifications-toggle') { + // Handle toggle state change + } +}) +``` diff --git a/website/docs/ios/components/overview.md b/website/docs/ios/components/overview.md index 4812845..733921a 100644 --- a/website/docs/ios/components/overview.md +++ b/website/docs/ios/components/overview.md @@ -42,8 +42,8 @@ Components for displaying data and status information. This includes progress in [See all data visualization & status components →](./status) -### Interactive Controls +### Interactive Controls & Navigation -User interface controls that respond to user interaction. This category includes Button and Toggle components that enable interactive Live Activities. +User interface controls that respond to user interaction and navigation. This category includes Button and Toggle components for interactive Live Activities, plus Link for semantic URL navigation. -[See all interactive control components →](./interactive) +[See all interactive control & navigation components →](./interactive) diff --git a/website/docs/ios/development/interactions.md b/website/docs/ios/development/interactions.md index de28afb..dd796e3 100644 --- a/website/docs/ios/development/interactions.md +++ b/website/docs/ios/development/interactions.md @@ -8,11 +8,13 @@ ActivityKit provides a limited set of interactions with Live Activities. The onl - **Live Activity view**: Tapping anywhere on the Live Activity itself can launch your app via a deep link, allowing users to access more detailed information or perform specific actions. -- **Buttons**: Interactive buttons that can trigger actions within your app. Buttons are implemented using AppIntents and work on both the Lock Screen and Dynamic Island. +- **Links**: Navigation elements that open URLs when tapped. Links use SwiftUI's native Link component and work across all Live Activity contexts (iOS 14.0+). -- **Toggles**: Interactive toggle switches that allow users to change boolean states. Like buttons, toggles use AppIntents and are supported across all Live Activity contexts. +- **Buttons**: Interactive buttons that trigger in-app events. Buttons are implemented using AppIntents and work on both the Lock Screen and Dynamic Island (iOS 17.0+). -These interactions are powered by Apple's AppIntents framework, which enables Live Activities to communicate with your app even when it's not running. +- **Toggles**: Interactive toggle switches that allow users to change boolean states. Like buttons, toggles use AppIntents and are supported across all Live Activity contexts (iOS 17.0+). + +These interactions are powered by Apple's AppIntents framework and SwiftUI's Link component, which enable Live Activities to communicate with your app even when it's not running. ## Handling interactions @@ -61,7 +63,9 @@ If you don't provide an explicit `id` prop, Voltra will automatically generate a ## Deep linking -When users tap on the Live Activity itself (not on a button or toggle), your app can be launched via a deep link. Configure the deep link URL when starting a Live Activity: +### Activity-level deep links + +When users tap on the Live Activity itself (not on a button or link), your app can be launched via a deep link. Configure the deep link URL when starting a Live Activity: ```typescript import { useLiveActivity } from 'voltra/client' @@ -84,6 +88,30 @@ The deep link URL can be: When the Live Activity is tapped, your app will be launched (or brought to the foreground) and the deep link will be handled by your routing system. +### Component-level deep links + +Individual components can also open URLs when tapped, providing granular navigation within your Live Activity. + +#### Link Component + +The `Link` component provides semantic navigation to URLs. It uses SwiftUI's native Link and works on iOS 14.0+: + +```tsx + + + + Order #123 + + +``` + +Links support URL normalization: +- Absolute URLs: `"myapp://path"`, `"https://example.com"` +- Relative paths: `"/settings"` → `"myapp://settings"` +- Simple paths: `"help"` → `"myapp://help"` + +For styled navigation buttons, wrap a Link with custom children to achieve button-like appearance while maintaining URL navigation functionality. + ## Limitations While Live Activities provide powerful interaction capabilities, there are some limitations to be aware of: From dcfc8d5cb20c8bad2d0ac8e94931e6ea6140691e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 28 Jan 2026 11:58:36 +0100 Subject: [PATCH 2/2] style: format --- src/jsx/primitives.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsx/primitives.ts b/src/jsx/primitives.ts index be72a65..dd0fae9 100644 --- a/src/jsx/primitives.ts +++ b/src/jsx/primitives.ts @@ -7,9 +7,9 @@ export * from './GroupBox.js' export * from './HStack.js' export * from './Image.js' export * from './Label.js' -export * from './Link.js' export * from './LinearGradient.js' export * from './LinearProgressView.js' +export * from './Link.js' export * from './Mask.js' export * from './Spacer.js' export * from './Symbol.js'