diff --git a/Sources/WebUI/Styles/Effects/ViewTransition/DocumentViewTransition.swift b/Sources/WebUI/Styles/Effects/ViewTransition/DocumentViewTransition.swift new file mode 100644 index 00000000..546ff4d2 --- /dev/null +++ b/Sources/WebUI/Styles/Effects/ViewTransition/DocumentViewTransition.swift @@ -0,0 +1,436 @@ +import Foundation + +/// Configuration for document-level view transitions +public struct DocumentViewTransitionConfiguration: Sendable { + /// The default transition type for the document + public let defaultTransition: ViewTransitionType? + + /// The duration for document-level transitions in milliseconds + public let duration: Int? + + /// The timing function for document-level transitions + public let timing: ViewTransitionTiming? + + /// The delay before transitions start in milliseconds + public let delay: Int? + + /// Whether to enable cross-document view transitions + public let enableCrossDocument: Bool + + /// Custom CSS for view transitions + public let customCSS: String? + + /// Initialize document view transition configuration. + /// + /// - Parameters: + /// - defaultTransition: The default transition type for the document + /// - duration: The duration for document-level transitions in milliseconds + /// - timing: The timing function for document-level transitions + /// - delay: The delay before transitions start in milliseconds + /// - enableCrossDocument: Whether to enable cross-document view transitions + /// - customCSS: Custom CSS for view transitions + public init( + defaultTransition: ViewTransitionType? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil, + enableCrossDocument: Bool = false, + customCSS: String? = nil + ) { + self.defaultTransition = defaultTransition + self.duration = duration + self.timing = timing + self.delay = delay + self.enableCrossDocument = enableCrossDocument + self.customCSS = customCSS + } +} + +/// Extension to add view transition support to Documents +extension Document { + /// View transition configuration for this document. + /// + /// Override this property to configure document-level view transitions. + /// When specified, generates CSS and JavaScript for smooth page transitions. + /// + /// - Returns: The view transition configuration, or nil for no document-level transitions + public var viewTransitions: DocumentViewTransitionConfiguration? { + nil + } + + /// Generate CSS for document-level view transitions. + /// + /// This method generates CSS that enables smooth transitions between pages + /// and provides default styling for view transition elements. + /// + /// - Returns: CSS string for view transitions, or nil if no configuration + public func generateViewTransitionCSS() -> String? { + guard let config = viewTransitions else { return nil } + + var css = """ + /* Document-level view transitions */ + @view-transition { + navigation: auto; + } + + """ + + // Add default transition styles + if let defaultTransition = config.defaultTransition { + css += """ + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: \(config.duration ?? 300)ms; + animation-timing-function: \(config.timing?.rawValue ?? "ease-in-out"); + } + + """ + + // Add transition-specific styles + switch defaultTransition { + case .fade: + css += """ + ::view-transition-old(root) { + animation-name: fade-out; + } + + ::view-transition-new(root) { + animation-name: fade-in; + } + + @keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + """ + case .slide, .slideLeft: + css += """ + ::view-transition-old(root) { + animation-name: slide-out-left; + } + + ::view-transition-new(root) { + animation-name: slide-in-right; + } + + @keyframes slide-out-left { + from { transform: translateX(0); } + to { transform: translateX(-100%); } + } + + @keyframes slide-in-right { + from { transform: translateX(100%); } + to { transform: translateX(0); } + } + + """ + case .slideRight: + css += """ + ::view-transition-old(root) { + animation-name: slide-out-right; + } + + ::view-transition-new(root) { + animation-name: slide-in-left; + } + + @keyframes slide-out-right { + from { transform: translateX(0); } + to { transform: translateX(100%); } + } + + @keyframes slide-in-left { + from { transform: translateX(-100%); } + to { transform: translateX(0); } + } + + """ + case .slideUp: + css += """ + ::view-transition-old(root) { + animation-name: slide-out-up; + } + + ::view-transition-new(root) { + animation-name: slide-in-down; + } + + @keyframes slide-out-up { + from { transform: translateY(0); } + to { transform: translateY(-100%); } + } + + @keyframes slide-in-down { + from { transform: translateY(-100%); } + to { transform: translateY(0); } + } + + """ + case .slideDown: + css += """ + ::view-transition-old(root) { + animation-name: slide-out-down; + } + + ::view-transition-new(root) { + animation-name: slide-in-up; + } + + @keyframes slide-out-down { + from { transform: translateY(0); } + to { transform: translateY(100%); } + } + + @keyframes slide-in-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } + } + + """ + case .scale, .scaleUp: + css += """ + ::view-transition-old(root) { + animation-name: scale-out; + } + + ::view-transition-new(root) { + animation-name: scale-in; + } + + @keyframes scale-out { + from { transform: scale(1); } + to { transform: scale(1.1); opacity: 0; } + } + + @keyframes scale-in { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } + } + + """ + case .scaleDown: + css += """ + ::view-transition-old(root) { + animation-name: scale-down-out; + } + + ::view-transition-new(root) { + animation-name: scale-down-in; + } + + @keyframes scale-down-out { + from { transform: scale(1); } + to { transform: scale(0.9); opacity: 0; } + } + + @keyframes scale-down-in { + from { transform: scale(1.1); opacity: 0; } + to { transform: scale(1); opacity: 1; } + } + + """ + case .flip, .flipHorizontal: + css += """ + ::view-transition-old(root) { + animation-name: flip-out; + } + + ::view-transition-new(root) { + animation-name: flip-in; + } + + @keyframes flip-out { + from { transform: rotateY(0deg); } + to { transform: rotateY(90deg); } + } + + @keyframes flip-in { + from { transform: rotateY(-90deg); } + to { transform: rotateY(0deg); } + } + + """ + case .flipVertical: + css += """ + ::view-transition-old(root) { + animation-name: flip-vertical-out; + } + + ::view-transition-new(root) { + animation-name: flip-vertical-in; + } + + @keyframes flip-vertical-out { + from { transform: rotateX(0deg); } + to { transform: rotateX(90deg); } + } + + @keyframes flip-vertical-in { + from { transform: rotateX(-90deg); } + to { transform: rotateX(0deg); } + } + + """ + case .none: + css += """ + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } + + """ + } + } + + // Add custom CSS if provided + if let customCSS = config.customCSS { + css += "\n/* Custom view transition styles */\n" + css += customCSS + css += "\n" + } + + return css + } + + /// Generate JavaScript for document-level view transitions. + /// + /// This method generates JavaScript that enables cross-document view transitions + /// and provides programmatic control over view transitions. + /// + /// - Returns: JavaScript string for view transitions, or nil if not enabled + public func generateViewTransitionJS() -> String? { + guard let config = viewTransitions, config.enableCrossDocument else { return nil } + + return """ + // Document-level view transitions + (function() { + 'use strict'; + + // Check for View Transitions API support + if (!document.startViewTransition) { + return; + } + + // Enable cross-document view transitions + document.addEventListener('DOMContentLoaded', function() { + // Handle navigation with view transitions + document.addEventListener('click', function(e) { + const link = e.target.closest('a'); + if (link && link.href && link.hostname === window.location.hostname) { + e.preventDefault(); + + // Start view transition + document.startViewTransition(() => { + window.location.href = link.href; + }); + } + }); + }); + + // Handle back/forward navigation + window.addEventListener('popstate', function(e) { + document.startViewTransition(() => { + window.location.reload(); + }); + }); + })(); + """ + } +} + +/// Website extension to provide default view transition configuration +extension Website { + /// Default view transition configuration for all documents in this website. + /// + /// Override this property to set default view transitions for all pages. + /// Individual documents can override this configuration. + /// + /// - Returns: The default view transition configuration, or nil for no defaults + public var defaultViewTransitions: DocumentViewTransitionConfiguration? { + nil + } +} + +/// Extension to integrate view transitions into the Document rendering pipeline +extension Document { + /// Enhanced head content that includes view transition CSS and JavaScript. + /// + /// This method combines the original head content with generated view transition + /// CSS and JavaScript, providing seamless integration with the existing rendering system. + /// + /// - Returns: Complete head content including view transitions + public var enhancedHead: String? { + var headContent = head ?? "" + + // Add view transition CSS + if let viewTransitionCSS = generateViewTransitionCSS() { + headContent += """ + + + """ + } + + // Add view transition JavaScript + if let viewTransitionJS = generateViewTransitionJS() { + headContent += """ + + + """ + } + + return headContent.isEmpty ? nil : headContent + } + + /// Render the document with integrated view transitions. + /// + /// This method provides an alternative to the standard render() method + /// that automatically includes view transition CSS and JavaScript. + /// + /// - Returns: Complete HTML document with view transitions + public func renderWithViewTransitions() throws -> String { + var optionalTags: [String] = metadata.tags + [] + var bodyTags: [String] = [] + if let scripts = scripts { + for script in scripts { + let scriptTag = script.render() + script.placement == .head + ? optionalTags.append(scriptTag) + : bodyTags.append(scriptTag) + } + } + if let stylesheets = stylesheets { + for stylesheet in stylesheets { + optionalTags.append( + "" + ) + } + } + let html = """ + + + + + + \(metadata.pageTitle) + \(optionalTags.joined(separator: "\n")) + + + \(enhancedHead ?? "") + + \(body.render()) + \(bodyTags.joined(separator: "\n")) + + """ + return HTMLMinifier.minify(html) + } +} diff --git a/Sources/WebUI/Styles/Effects/ViewTransition/ViewTransitionStyleOperation.swift b/Sources/WebUI/Styles/Effects/ViewTransition/ViewTransitionStyleOperation.swift new file mode 100644 index 00000000..4bfde30a --- /dev/null +++ b/Sources/WebUI/Styles/Effects/ViewTransition/ViewTransitionStyleOperation.swift @@ -0,0 +1,406 @@ +import Foundation + +/// Style operation for applying CSS View Transitions +public struct ViewTransitionStyleOperation: StyleOperation, Sendable { + public static let shared = ViewTransitionStyleOperation() + + /// Parameters for configuring view transitions + public struct Parameters: Sendable { + public let transitionType: ViewTransitionType? + public let name: String? + public let duration: Int? + public let timing: ViewTransitionTiming? + public let delay: Int? + public let slideDirection: SlideDirection? + public let scaleOrigin: ScaleOrigin? + public let behavior: ViewTransitionBehavior? + + /// Initialize view transition parameters. + /// + /// - Parameters: + /// - transitionType: The type of view transition to apply + /// - name: The view transition name for CSS View Transitions API + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - slideDirection: The direction for slide transitions + /// - scaleOrigin: The origin point for scale transitions + /// - behavior: The transition behavior + public init( + transitionType: ViewTransitionType? = nil, + name: String? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil, + slideDirection: SlideDirection? = nil, + scaleOrigin: ScaleOrigin? = nil, + behavior: ViewTransitionBehavior? = nil + ) { + self.transitionType = transitionType + self.name = name + self.duration = duration + self.timing = timing + self.delay = delay + self.slideDirection = slideDirection + self.scaleOrigin = scaleOrigin + self.behavior = behavior + } + + /// Create parameters from StyleParameters. + /// + /// - Parameter params: The style parameters to convert + /// - Returns: A new Parameters instance + public static func from(_ params: StyleParameters) -> Parameters { + Parameters( + transitionType: params.get("viewTransitionType"), + name: params.get("viewTransitionName"), + duration: params.get("viewTransitionDuration"), + timing: params.get("viewTransitionTiming"), + delay: params.get("viewTransitionDelay"), + slideDirection: params.get("viewTransitionSlideDirection"), + scaleOrigin: params.get("viewTransitionScaleOrigin"), + behavior: params.get("viewTransitionBehavior") + ) + } + } + + /// Apply view transition classes based on parameters. + /// + /// - Parameter params: The parameters containing view transition configuration + /// - Returns: An array of CSS class names to apply + public func applyClasses(params: Parameters) -> [String] { + var classes: [String] = [] + + // Apply view transition name for CSS View Transitions API + if let name = params.name { + classes.append("view-transition-name-\(name)") + } + + // Apply transition type + if let transitionType = params.transitionType { + classes.append("view-transition-\(transitionType.rawValue)") + + // Apply direction-specific classes for slide transitions + if transitionType == .slide, let direction = params.slideDirection { + classes.append("view-transition-slide-\(direction.rawValue)") + } + + // Apply origin-specific classes for scale transitions + if transitionType == .scale || transitionType == .scaleUp || transitionType == .scaleDown, + let origin = params.scaleOrigin + { + classes.append("view-transition-origin-\(origin.rawValue)") + } + } + + // Apply timing + if let timing = params.timing { + classes.append("view-transition-timing-\(timing.rawValue)") + } + + // Apply duration + if let duration = params.duration { + classes.append("view-transition-duration-\(duration)") + } + + // Apply delay + if let delay = params.delay { + classes.append("view-transition-delay-\(delay)") + } + + // Apply behavior + if let behavior = params.behavior { + classes.append("view-transition-behavior-\(behavior.rawValue)") + } + + return classes + } + + private init() {} +} + + +// MARK: - Markup Extension +extension Markup { + /// Apply view transition configuration to this element. + /// + /// - Parameters: + /// - transitionType: The type of view transition to apply + /// - name: The view transition name for CSS View Transitions API + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - slideDirection: The direction for slide transitions + /// - scaleOrigin: The origin point for scale transitions + /// - behavior: The transition behavior + /// - modifiers: The modifiers to apply the transition on + /// - Returns: A modified markup element with view transition applied + public func viewTransition( + _ transitionType: ViewTransitionType? = nil, + name: String? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil, + slideDirection: SlideDirection? = nil, + scaleOrigin: ScaleOrigin? = nil, + behavior: ViewTransitionBehavior? = nil, + on modifiers: Modifier... + ) -> some Markup { + let params = ViewTransitionStyleOperation.Parameters( + transitionType: transitionType, + name: name, + duration: duration, + timing: timing, + delay: delay, + slideDirection: slideDirection, + scaleOrigin: scaleOrigin, + behavior: behavior + ) + return ViewTransitionStyleOperation.shared.applyTo(self, params: params, modifiers: Array(modifiers)) + } + + /// Apply named view transition for CSS View Transitions API. + /// + /// - Parameter name: The view transition name + /// - Returns: A modified markup element with view transition name applied + public func viewTransitionName(_ name: String) -> some Markup { + viewTransition(name: name) + } + + /// Apply fade transition. + /// + /// - Parameters: + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - Returns: A modified markup element with fade transition applied + public func fadeTransition(duration: Int? = nil, timing: ViewTransitionTiming? = nil, delay: Int? = nil) + -> some Markup + { + viewTransition(.fade, duration: duration, timing: timing, delay: delay) + } + + /// Apply slide transition with direction. + /// + /// - Parameters: + /// - direction: The direction for the slide transition + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - Returns: A modified markup element with slide transition applied + public func slideTransition( + _ direction: SlideDirection, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil + ) -> some Markup { + viewTransition(.slide, duration: duration, timing: timing, delay: delay, slideDirection: direction) + } + + /// Apply scale transition with origin. + /// + /// - Parameters: + /// - scaleType: The type of scale transition + /// - origin: The origin point for the scale transition + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - Returns: A modified markup element with scale transition applied + public func scaleTransition( + _ scaleType: ViewTransitionType = .scale, + origin: ScaleOrigin? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil + ) -> some Markup { + viewTransition(scaleType, duration: duration, timing: timing, delay: delay, scaleOrigin: origin) + } +} + +// MARK: - ResponsiveBuilder Extension +extension ResponsiveBuilder { + /// Apply view transition configuration in responsive context. + /// + /// - Parameters: + /// - transitionType: The type of view transition to apply + /// - name: The view transition name for CSS View Transitions API + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - slideDirection: The direction for slide transitions + /// - scaleOrigin: The origin point for scale transitions + /// - behavior: The transition behavior + /// - Returns: A modified responsive builder with view transition applied + public func viewTransition( + _ transitionType: ViewTransitionType? = nil, + name: String? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil, + slideDirection: SlideDirection? = nil, + scaleOrigin: ScaleOrigin? = nil, + behavior: ViewTransitionBehavior? = nil + ) -> ResponsiveBuilder { + let params = ViewTransitionStyleOperation.Parameters( + transitionType: transitionType, + name: name, + duration: duration, + timing: timing, + delay: delay, + slideDirection: slideDirection, + scaleOrigin: scaleOrigin, + behavior: behavior + ) + return ViewTransitionStyleOperation.shared.applyToBuilder(self, params: params) + } + + /// Apply named view transition in responsive context. + /// + /// - Parameter name: The view transition name + /// - Returns: A modified responsive builder with view transition name applied + public func viewTransitionName(_ name: String) -> ResponsiveBuilder { + viewTransition(name: name) + } + + /// Apply fade transition in responsive context. + /// + /// - Parameters: + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - Returns: A modified responsive builder with fade transition applied + public func fadeTransition(duration: Int? = nil, timing: ViewTransitionTiming? = nil, delay: Int? = nil) + -> ResponsiveBuilder + { + viewTransition(.fade, duration: duration, timing: timing, delay: delay) + } + + /// Apply slide transition in responsive context. + /// + /// - Parameters: + /// - direction: The direction for the slide transition + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - Returns: A modified responsive builder with slide transition applied + public func slideTransition( + _ direction: SlideDirection, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil + ) -> ResponsiveBuilder { + viewTransition(.slide, duration: duration, timing: timing, delay: delay, slideDirection: direction) + } + + /// Apply scale transition in responsive context. + /// + /// - Parameters: + /// - scaleType: The type of scale transition + /// - origin: The origin point for the scale transition + /// - duration: The duration of the transition in milliseconds + /// - timing: The timing function for the transition + /// - delay: The delay before the transition starts in milliseconds + /// - Returns: A modified responsive builder with scale transition applied + public func scaleTransition( + _ scaleType: ViewTransitionType = .scale, + origin: ScaleOrigin? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil + ) -> ResponsiveBuilder { + viewTransition(scaleType, duration: duration, timing: timing, delay: delay, scaleOrigin: origin) + } +} + +// MARK: - Global DSL Functions +/// Apply view transition configuration declaratively. +/// +/// - Parameters: +/// - transitionType: The type of view transition to apply +/// - name: The view transition name for CSS View Transitions API +/// - duration: The duration of the transition in milliseconds +/// - timing: The timing function for the transition +/// - delay: The delay before the transition starts in milliseconds +/// - slideDirection: The direction for slide transitions +/// - scaleOrigin: The origin point for scale transitions +/// - behavior: The transition behavior +/// - Returns: A responsive modification with view transition applied +public func viewTransition( + _ transitionType: ViewTransitionType? = nil, + name: String? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil, + slideDirection: SlideDirection? = nil, + scaleOrigin: ScaleOrigin? = nil, + behavior: ViewTransitionBehavior? = nil +) -> ResponsiveModification { + let params = ViewTransitionStyleOperation.Parameters( + transitionType: transitionType, + name: name, + duration: duration, + timing: timing, + delay: delay, + slideDirection: slideDirection, + scaleOrigin: scaleOrigin, + behavior: behavior + ) + return ViewTransitionStyleOperation.shared.asModification(params: params) +} + +/// Apply named view transition declaratively. +/// +/// - Parameter name: The view transition name +/// - Returns: A responsive modification with view transition name applied +public func viewTransitionName(_ name: String) -> ResponsiveModification { + viewTransition(name: name) +} + +/// Apply fade transition declaratively. +/// +/// - Parameters: +/// - duration: The duration of the transition in milliseconds +/// - timing: The timing function for the transition +/// - delay: The delay before the transition starts in milliseconds +/// - Returns: A responsive modification with fade transition applied +public func fadeTransition(duration: Int? = nil, timing: ViewTransitionTiming? = nil, delay: Int? = nil) + -> ResponsiveModification +{ + viewTransition(.fade, duration: duration, timing: timing, delay: delay) +} + +/// Apply slide transition declaratively. +/// +/// - Parameters: +/// - direction: The direction for the slide transition +/// - duration: The duration of the transition in milliseconds +/// - timing: The timing function for the transition +/// - delay: The delay before the transition starts in milliseconds +/// - Returns: A responsive modification with slide transition applied +public func slideTransition( + _ direction: SlideDirection, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil +) -> ResponsiveModification { + viewTransition(.slide, duration: duration, timing: timing, delay: delay, slideDirection: direction) +} + +/// Apply scale transition declaratively. +/// +/// - Parameters: +/// - scaleType: The type of scale transition +/// - origin: The origin point for the scale transition +/// - duration: The duration of the transition in milliseconds +/// - timing: The timing function for the transition +/// - delay: The delay before the transition starts in milliseconds +/// - Returns: A responsive modification with scale transition applied +public func scaleTransition( + _ scaleType: ViewTransitionType = .scale, + origin: ScaleOrigin? = nil, + duration: Int? = nil, + timing: ViewTransitionTiming? = nil, + delay: Int? = nil +) -> ResponsiveModification { + viewTransition(scaleType, duration: duration, timing: timing, delay: delay, scaleOrigin: origin) +} diff --git a/Sources/WebUI/Styles/Effects/ViewTransition/ViewTransitionTypes.swift b/Sources/WebUI/Styles/Effects/ViewTransition/ViewTransitionTypes.swift new file mode 100644 index 00000000..30a0962e --- /dev/null +++ b/Sources/WebUI/Styles/Effects/ViewTransition/ViewTransitionTypes.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Specifies the type of view transition to apply +public enum ViewTransitionType: String, CaseIterable, Sendable { + case fade = "fade" + case slide = "slide" + case slideUp = "slide-up" + case slideDown = "slide-down" + case slideLeft = "slide-left" + case slideRight = "slide-right" + case scale = "scale" + case scaleUp = "scale-up" + case scaleDown = "scale-down" + case flip = "flip" + case flipHorizontal = "flip-horizontal" + case flipVertical = "flip-vertical" + case none = "none" +} + +/// Specifies the direction for slide transitions +public enum SlideDirection: String, CaseIterable, Sendable { + case up = "up" + case down = "down" + case left = "left" + case right = "right" +} + +/// Specifies the origin point for scale transitions +public enum ScaleOrigin: String, CaseIterable, Sendable { + case center = "center" + case top = "top" + case bottom = "bottom" + case left = "left" + case right = "right" + case topLeft = "top-left" + case topRight = "top-right" + case bottomLeft = "bottom-left" + case bottomRight = "bottom-right" +} + +/// Specifies timing function for view transitions +public enum ViewTransitionTiming: String, CaseIterable, Sendable { + case linear = "linear" + case easeIn = "ease-in" + case easeOut = "ease-out" + case easeInOut = "ease-in-out" + case circIn = "cubic-bezier(0.55, 0, 1, 0.45)" + case circOut = "cubic-bezier(0, 0.55, 0.45, 1)" + case circInOut = "cubic-bezier(0.85, 0, 0.15, 1)" + case backIn = "cubic-bezier(0.36, 0, 0.66, -0.56)" + case backOut = "cubic-bezier(0.34, 1.56, 0.64, 1)" + case backInOut = "cubic-bezier(0.68, -0.6, 0.32, 1.6)" +} + +/// Specifies view transition behavior +public enum ViewTransitionBehavior: String, CaseIterable, Sendable { + case auto = "auto" + case smooth = "smooth" + case instant = "instant" +} diff --git a/Tests/WebUITests/Core/DocumentViewTransitionTests.swift b/Tests/WebUITests/Core/DocumentViewTransitionTests.swift new file mode 100644 index 00000000..800a20f1 --- /dev/null +++ b/Tests/WebUITests/Core/DocumentViewTransitionTests.swift @@ -0,0 +1,382 @@ +import Testing + +@testable import WebUI + +@Suite("Document View Transition Tests") +struct DocumentViewTransitionTests { + + // MARK: - Test Document Implementations + + struct BasicViewTransitionDocument: Document { + var metadata: Metadata { + Metadata( + title: "Test Page", + description: "Test page for view transitions" + ) + } + + var body: some Markup { + Stack { + Text("Hello World") + } + } + + var viewTransitions: DocumentViewTransitionConfiguration? { + DocumentViewTransitionConfiguration( + defaultTransition: .fade, + duration: 300, + timing: .easeInOut, + enableCrossDocument: true + ) + } + } + + struct SlideViewTransitionDocument: Document { + var metadata: Metadata { + Metadata( + title: "Slide Page", + description: "Test page for slide transitions" + ) + } + + var body: some Markup { + Text("Slide Content") + } + + var viewTransitions: DocumentViewTransitionConfiguration? { + DocumentViewTransitionConfiguration( + defaultTransition: .slideLeft, + duration: 500, + timing: .backOut, + delay: 100, + enableCrossDocument: true, + customCSS: """ + .custom-transition { + animation-delay: 50ms; + } + """ + ) + } + } + + struct NoViewTransitionDocument: Document { + var metadata: Metadata { + Metadata( + title: "No Transition Page", + description: "Test page without view transitions" + ) + } + + var body: some Markup { + Text("No Transition Content") + } + + // viewTransitions returns nil by default + } + + // MARK: - DocumentViewTransitionConfiguration Tests + + @Test("Document view transition configuration initialization") + func testDocumentViewTransitionConfigurationInit() async throws { + let config = DocumentViewTransitionConfiguration( + defaultTransition: .fade, + duration: 300, + timing: .easeInOut, + delay: 50, + enableCrossDocument: true, + customCSS: ".custom { color: red; }" + ) + + #expect(config.defaultTransition == .fade) + #expect(config.duration == 300) + #expect(config.timing == .easeInOut) + #expect(config.delay == 50) + #expect(config.enableCrossDocument == true) + #expect(config.customCSS == ".custom { color: red; }") + } + + @Test("Document view transition configuration with defaults") + func testDocumentViewTransitionConfigurationDefaults() async throws { + let config = DocumentViewTransitionConfiguration() + + #expect(config.defaultTransition == nil) + #expect(config.duration == nil) + #expect(config.timing == nil) + #expect(config.delay == nil) + #expect(config.enableCrossDocument == false) + #expect(config.customCSS == nil) + } + + // MARK: - CSS Generation Tests + + @Test("Document generates fade transition CSS") + func testDocumentGeneratesFadeTransitionCSS() async throws { + let document = BasicViewTransitionDocument() + let css = document.generateViewTransitionCSS() + + #expect(css != nil) + #expect(css!.contains("@view-transition")) + #expect(css!.contains("navigation: auto")) + #expect(css!.contains("animation-duration: 300ms")) + #expect(css!.contains("animation-timing-function: ease-in-out")) + #expect(css!.contains("::view-transition-old(root)")) + #expect(css!.contains("::view-transition-new(root)")) + #expect(css!.contains("animation-name: fade-out")) + #expect(css!.contains("animation-name: fade-in")) + #expect(css!.contains("@keyframes fade-out")) + #expect(css!.contains("@keyframes fade-in")) + } + + @Test("Document generates slide transition CSS") + func testDocumentGeneratesSlideTransitionCSS() async throws { + let document = SlideViewTransitionDocument() + let css = document.generateViewTransitionCSS() + + #expect(css != nil) + #expect(css!.contains("@view-transition")) + #expect(css!.contains("animation-duration: 500ms")) + #expect(css!.contains("animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1)")) + #expect(css!.contains("animation-name: slide-out-left")) + #expect(css!.contains("animation-name: slide-in-right")) + #expect(css!.contains("@keyframes slide-out-left")) + #expect(css!.contains("@keyframes slide-in-right")) + #expect(css!.contains("transform: translateX(-100%)")) + #expect(css!.contains("transform: translateX(100%)")) + #expect(css!.contains("transform: translateX(0)")) + } + + @Test("Document generates custom CSS") + func testDocumentGeneratesCustomCSS() async throws { + let document = SlideViewTransitionDocument() + let css = document.generateViewTransitionCSS() + + #expect(css != nil) + #expect(css!.contains("/* Custom view transition styles */")) + #expect(css!.contains(".custom-transition")) + #expect(css!.contains("animation-delay: 50ms")) + } + + @Test("Document with no view transitions generates no CSS") + func testDocumentWithNoViewTransitionsGeneratesNoCSS() async throws { + let document = NoViewTransitionDocument() + let css = document.generateViewTransitionCSS() + + #expect(css == nil) + } + + // MARK: - JavaScript Generation Tests + + @Test("Document generates cross-document JavaScript") + func testDocumentGeneratesCrossDocumentJavaScript() async throws { + let document = BasicViewTransitionDocument() + let js = document.generateViewTransitionJS() + + #expect(js != nil) + #expect(js!.contains("'use strict'")) + #expect(js!.contains("document.startViewTransition")) + #expect(js!.contains("DOMContentLoaded")) + #expect(js!.contains("addEventListener('click'")) + #expect(js!.contains("addEventListener('popstate'")) + #expect(js!.contains("window.location.href")) + #expect(js!.contains("window.location.reload")) + } + + @Test("Document without cross-document transitions generates no JavaScript") + func testDocumentWithoutCrossDocumentGeneratesNoJavaScript() async throws { + struct NoCrossDocumentTransitionDocument: Document { + var metadata: Metadata { + Metadata( + title: "No Cross Document", + description: "Test page" + ) + } + + var body: some Markup { + Text("Content") + } + + var viewTransitions: DocumentViewTransitionConfiguration? { + DocumentViewTransitionConfiguration( + defaultTransition: .fade, + enableCrossDocument: false + ) + } + } + + let document = NoCrossDocumentTransitionDocument() + let js = document.generateViewTransitionJS() + + #expect(js == nil) + } + + // MARK: - Enhanced Head Content Tests + + @Test("Document enhanced head includes view transition CSS and JavaScript") + func testDocumentEnhancedHeadIncludesViewTransitions() async throws { + let document = BasicViewTransitionDocument() + let enhancedHead = document.enhancedHead + + #expect(enhancedHead != nil) + #expect(enhancedHead!.contains("")) + #expect(enhancedHead!.contains("")) + } + + @Test("Document enhanced head with custom head content") + func testDocumentEnhancedHeadWithCustomContent() async throws { + struct CustomHeadDocument: Document { + var metadata: Metadata { + Metadata( + title: "Custom Head", + description: "Test page" + ) + } + + var body: some Markup { + Text("Content") + } + + var head: String? { + "" + } + + var viewTransitions: DocumentViewTransitionConfiguration? { + DocumentViewTransitionConfiguration( + defaultTransition: .fade, + enableCrossDocument: true + ) + } + } + + let document = CustomHeadDocument() + let enhancedHead = document.enhancedHead + + #expect(enhancedHead != nil) + #expect(enhancedHead!.contains("")) + #expect(enhancedHead!.contains("