From a904e080e62c4dadc4d69bda5da496da5feaa62c Mon Sep 17 00:00:00 2001 From: Mac Long Date: Wed, 2 Jul 2025 21:21:14 +0100 Subject: [PATCH] Add comprehensive Lucide Icons support for Button and Icon components - Create LucideIcon enum with 150+ predefined icons for type safety - Add dedicated Icon component for standalone icon usage with size presets - Update SystemImage to auto-detect and render Lucide vs system icons - Enhance Button component with direct LucideIcon support - Add LucideStyles utility for CSS management (CDN/local options) - Include comprehensive test suite covering all functionality - Maintain backward compatibility with existing SystemImage usage - Support accessibility features with automatic ARIA labels --- .../Elements/Interactive/Button/Button.swift | 53 +++ Sources/WebUI/Elements/Media/Icon.swift | 205 ++++++++++ Sources/WebUI/Elements/Media/LucideIcon.swift | 288 ++++++++++++++ .../WebUI/Elements/Media/SystemImage.swift | 112 +++++- Sources/WebUI/Utilities/LucideStyles.swift | 137 +++++++ Tests/WebUITests/ElementTests.swift | 7 +- Tests/WebUITests/LucideIconTests.swift | 367 ++++++++++++++++++ 7 files changed, 1152 insertions(+), 17 deletions(-) create mode 100644 Sources/WebUI/Elements/Media/Icon.swift create mode 100644 Sources/WebUI/Elements/Media/LucideIcon.swift create mode 100644 Sources/WebUI/Utilities/LucideStyles.swift create mode 100644 Tests/WebUITests/LucideIconTests.swift diff --git a/Sources/WebUI/Elements/Interactive/Button/Button.swift b/Sources/WebUI/Elements/Interactive/Button/Button.swift index db4a6a87..0fa952c5 100644 --- a/Sources/WebUI/Elements/Interactive/Button/Button.swift +++ b/Sources/WebUI/Elements/Interactive/Button/Button.swift @@ -111,6 +111,59 @@ public struct Button: Element { } } + /// Creates a new HTML button with string title and Lucide icon. + /// + /// This initializer provides type-safe access to Lucide icons in buttons, + /// following SwiftUI patterns with enhanced icon support. + /// + /// - Parameters: + /// - title: The button's text content. + /// - systemImage: Lucide icon to display before the text. + /// - type: Button type (submit or reset), optional. + /// - autofocus: When true, automatically focuses the button when the page loads, optional. + /// - onClick: JavaScript function to execute when the button is clicked, optional. + /// - id: Unique identifier for the HTML element, useful for JavaScript interaction and styling. + /// - classes: An array of CSS classnames for styling the button. + /// - role: ARIA role of the element for accessibility, enhancing screen reader interpretation. + /// - label: ARIA label to describe the element for accessibility when button text isn't sufficient. + /// - data: Dictionary of `data-*` attributes for storing custom data relevant to the button. + /// + /// ## Example + /// ```swift + /// Button("Save", systemImage: .check, type: .submit) + /// Button("Delete", systemImage: .trash, onClick: "confirmDelete()") + /// Button("Settings", systemImage: .settings, classes: ["icon-button"]) + /// ``` + public init( + _ title: String, + systemImage: LucideIcon, + type: ButtonType? = nil, + autofocus: Bool? = nil, + onClick: String? = nil, + id: String? = nil, + classes: [String]? = nil, + role: AriaRole? = nil, + label: String? = nil, + data: [String: String]? = nil + ) { + self.type = type + self.autofocus = autofocus + self.onClick = onClick + self.id = id + self.classes = classes + self.role = role + self.label = label + self.data = data + + // Create content builder that includes both Lucide icon and text + self.contentBuilder = { + [ + SystemImage(systemImage, classes: ["button-icon"]), + " \(title)" + ] + } + } + /// Creates a new HTML button using HTMLBuilder closure syntax. /// /// - Parameters: diff --git a/Sources/WebUI/Elements/Media/Icon.swift b/Sources/WebUI/Elements/Media/Icon.swift new file mode 100644 index 00000000..e61084b3 --- /dev/null +++ b/Sources/WebUI/Elements/Media/Icon.swift @@ -0,0 +1,205 @@ +import Foundation + +/// Creates HTML elements for Lucide icons using the Lucide icon font. +/// +/// The Icon component provides a simple way to include Lucide icons in web interfaces. +/// It automatically generates the appropriate CSS classes for the Lucide icon font +/// and supports both enum-based type safety and string-based flexibility. +/// +/// ## Usage +/// ```swift +/// Icon(.airplay) // Type-safe enum +/// Icon("airplay") // String identifier +/// Icon(.heart, classes: ["favorite-icon"]) // With custom styling +/// Icon(.settings, size: .large) // With predefined size +/// ``` +/// +/// ## Requirements +/// The Lucide CSS must be included in your document head: +/// ```html +/// +/// ``` +public struct Icon: Element { + private let icon: LucideIcon + private let size: IconSize? + private let id: String? + private let classes: [String]? + private let role: AriaRole? + private let label: String? + private let data: [String: String]? + + /// Size presets for icons. + public enum IconSize: String { + case small = "lucide-sm" // 16px + case medium = "lucide-md" // 20px (default) + case large = "lucide-lg" // 24px + case extraLarge = "lucide-xl" // 32px + + /// The CSS class name for this icon size. + public var cssClass: String { + return self.rawValue + } + } + + /// Creates a new icon element using a LucideIcon enum value. + /// + /// This is the preferred type-safe way to create icons using predefined + /// Lucide icon identifiers. + /// + /// - Parameters: + /// - icon: The Lucide icon to display. + /// - size: Optional size preset for the icon. + /// - id: Unique identifier for the HTML element. + /// - classes: An array of CSS classnames for styling the icon. + /// - role: ARIA role of the element for accessibility. + /// - label: ARIA label to describe the icon for accessibility. + /// - data: Dictionary of `data-*` attributes for storing custom data. + /// + /// ## Example + /// ```swift + /// Icon(.airplay) + /// Icon(.heart, size: .large, classes: ["text-red-500"]) + /// Icon(.settings, label: "Open settings menu") + /// ``` + public init( + _ icon: LucideIcon, + size: IconSize? = nil, + id: String? = nil, + classes: [String]? = nil, + role: AriaRole? = nil, + label: String? = nil, + data: [String: String]? = nil + ) { + self.icon = icon + self.size = size + self.id = id + self.classes = classes + self.role = role + self.label = label + self.data = data + } + + /// Creates a new icon element using a string identifier. + /// + /// This initializer provides flexibility for custom or unlisted icons + /// while maintaining the same API structure. + /// + /// - Parameters: + /// - iconName: The string identifier for the icon (e.g., "airplay"). + /// - size: Optional size preset for the icon. + /// - id: Unique identifier for the HTML element. + /// - classes: An array of CSS classnames for styling the icon. + /// - role: ARIA role of the element for accessibility. + /// - label: ARIA label to describe the icon for accessibility. + /// - data: Dictionary of `data-*` attributes for storing custom data. + /// + /// ## Example + /// ```swift + /// Icon("airplay") + /// Icon("custom-icon", classes: ["my-custom-style"]) + /// Icon("brand-new-icon", size: .large) + /// ``` + public init( + _ iconName: String, + size: IconSize? = nil, + id: String? = nil, + classes: [String]? = nil, + role: AriaRole? = nil, + label: String? = nil, + data: [String: String]? = nil + ) { + self.icon = LucideIcon(rawValue: iconName) ?? LucideIcon.circle + self.size = size + self.id = id + self.classes = classes + self.role = role + self.label = label + self.data = data + } + + public var body: some HTML { + HTMLString(content: renderTag()) + } + + private func renderTag() -> String { + // Build CSS classes for the icon + var allClasses: [String] = [] + + // Add base Lucide icon class + allClasses.append("lucide") + allClasses.append(icon.cssClass) + + // Add size class if specified + if let size = size { + allClasses.append(size.cssClass) + } + + // Add custom classes + if let classes = classes { + allClasses.append(contentsOf: classes) + } + + let attributes = AttributeBuilder.buildAttributes( + id: id, + classes: allClasses, + role: role, + label: label ?? icon.displayName, + data: data + ) + + // Render as an inline element (i) for icon fonts + // Using is the standard convention for icon fonts + return AttributeBuilder.renderTag( + "i", attributes: attributes, content: "" + ) + } +} + +// MARK: - Convenience Extensions + +extension Icon { + /// Creates a small icon. + /// + /// - Parameters: + /// - icon: The Lucide icon to display. + /// - classes: Optional custom CSS classes. + /// - label: Optional ARIA label. + /// - Returns: An Icon configured with small size. + public static func small( + _ icon: LucideIcon, + classes: [String]? = nil, + label: String? = nil + ) -> Icon { + return Icon(icon, size: .small, classes: classes, label: label) + } + + /// Creates a large icon. + /// + /// - Parameters: + /// - icon: The Lucide icon to display. + /// - classes: Optional custom CSS classes. + /// - label: Optional ARIA label. + /// - Returns: An Icon configured with large size. + public static func large( + _ icon: LucideIcon, + classes: [String]? = nil, + label: String? = nil + ) -> Icon { + return Icon(icon, size: .large, classes: classes, label: label) + } + + /// Creates an extra large icon. + /// + /// - Parameters: + /// - icon: The Lucide icon to display. + /// - classes: Optional custom CSS classes. + /// - label: Optional ARIA label. + /// - Returns: An Icon configured with extra large size. + public static func extraLarge( + _ icon: LucideIcon, + classes: [String]? = nil, + label: String? = nil + ) -> Icon { + return Icon(icon, size: .extraLarge, classes: classes, label: label) + } +} \ No newline at end of file diff --git a/Sources/WebUI/Elements/Media/LucideIcon.swift b/Sources/WebUI/Elements/Media/LucideIcon.swift new file mode 100644 index 00000000..6407c587 --- /dev/null +++ b/Sources/WebUI/Elements/Media/LucideIcon.swift @@ -0,0 +1,288 @@ +import Foundation + +/// Represents Lucide icons with their string identifiers. +/// +/// This enum provides type-safe access to Lucide icons, ensuring consistent +/// naming and reducing the chance of typos. Each case corresponds to the +/// official Lucide icon name as defined in the Lucide icon library. +/// +/// ## Usage +/// ```swift +/// Icon(.airplay) +/// Button("Save", systemImage: .check) +/// SystemImage(.heart, classes: ["favorite-icon"]) +/// ``` +/// +/// Reference: https://lucide.dev/icons/ +public enum LucideIcon: String, CaseIterable { + // MARK: - Navigation & UI + case arrowLeft = "arrow-left" + case arrowRight = "arrow-right" + case arrowUp = "arrow-up" + case arrowDown = "arrow-down" + case chevronLeft = "chevron-left" + case chevronRight = "chevron-right" + case chevronUp = "chevron-up" + case chevronDown = "chevron-down" + case menu = "menu" + case x = "x" + case plus = "plus" + case minus = "minus" + case moreHorizontal = "more-horizontal" + case moreVertical = "more-vertical" + + // MARK: - Actions & States + case check = "check" + case checkCircle = "check-circle" + case xCircle = "x-circle" + case alertCircle = "alert-circle" + case alertTriangle = "alert-triangle" + case info = "info" + case helpCircle = "help-circle" + case edit = "edit" + case edit2 = "edit-2" + case edit3 = "edit-3" + case trash = "trash" + case trash2 = "trash-2" + case copy = "copy" + case clipboard = "clipboard" + case save = "save" + case download = "download" + case upload = "upload" + case refresh = "refresh-cw" + case rotate = "rotate-cw" + case undo = "undo" + case redo = "redo" + + // MARK: - Media & Content + case play = "play" + case pause = "pause" + case stop = "stop" + case skipForward = "skip-forward" + case skipBack = "skip-back" + case volume = "volume-2" + case volumeOff = "volume-x" + case image = "image" + case video = "video" + case music = "music" + case file = "file" + case fileText = "file-text" + case folder = "folder" + case folderOpen = "folder-open" + + // MARK: - Communication + case mail = "mail" + case phone = "phone" + case messageSquare = "message-square" + case messageCircle = "message-circle" + case send = "send" + case share = "share" + case share2 = "share-2" + case bell = "bell" + case bellOff = "bell-off" + + // MARK: - User & Profile + case user = "user" + case users = "users" + case userPlus = "user-plus" + case userMinus = "user-minus" + case userCheck = "user-check" + case userX = "user-x" + case heart = "heart" + case star = "star" + case bookmark = "bookmark" + case thumbsUp = "thumbs-up" + case thumbsDown = "thumbs-down" + + // MARK: - Layout & View + case grid = "grid-3x3" + case list = "list" + case columns = "columns" + case sidebar = "sidebar" + case maximize = "maximize" + case minimize = "minimize" + case maximize2 = "maximize-2" + case minimize2 = "minimize-2" + case eye = "eye" + case eyeOff = "eye-off" + case layout = "layout" + case layoutGrid = "layout-grid" + + // MARK: - Settings & Configuration + case settings = "settings" + case sliders = "sliders" + case filter = "filter" + case search = "search" + case zoom = "zoom-in" + case zoomOut = "zoom-out" + case tool = "tool" + case wrench = "wrench" + case gear = "gear" + case lock = "lock" + case unlock = "unlock" + case key = "key" + + // MARK: - Data & Statistics + case barChart = "bar-chart" + case lineChart = "line-chart" + case pieChart = "pie-chart" + case trendingUp = "trending-up" + case trendingDown = "trending-down" + case activity = "activity" + case analytics = "bar-chart-3" + case database = "database" + case server = "server" + case hardDrive = "hard-drive" + + // MARK: - E-commerce & Shopping + case shoppingCart = "shopping-cart" + case shoppingBag = "shopping-bag" + case creditCard = "credit-card" + case dollarSign = "dollar-sign" + case tag = "tag" + case package = "package" + case gift = "gift" + + // MARK: - Social & External + case github = "github" + case twitter = "twitter" + case linkedin = "linkedin" + case facebook = "facebook" + case instagram = "instagram" + case youtube = "youtube" + case externalLink = "external-link" + case link = "link" + case linkOff = "link-off" + + // MARK: - Device & System + case smartphone = "smartphone" + case tablet = "tablet" + case laptop = "laptop" + case monitor = "monitor" + case wifi = "wifi" + case wifiOff = "wifi-off" + case bluetooth = "bluetooth" + case battery = "battery" + case power = "power" + case cpu = "cpu" + case memory = "memory-stick" + + // MARK: - Weather & Location + case sun = "sun" + case moon = "moon" + case cloud = "cloud" + case cloudRain = "cloud-rain" + case cloudSnow = "cloud-snow" + case mapPin = "map-pin" + case map = "map" + case navigation = "navigation" + case compass = "compass" + case globe = "globe" + + // MARK: - Time & Calendar + case clock = "clock" + case calendar = "calendar" + case calendarDays = "calendar-days" + case timer = "timer" + case stopwatch = "stopwatch" + case hourglass = "hourglass" + + // MARK: - Shapes & Graphics + case circle = "circle" + case square = "square" + case triangle = "triangle" + case hexagon = "hexagon" + case diamond = "diamond" + case droplet = "droplet" + case flame = "flame" + case zap = "zap" + case shield = "shield" + case shieldCheck = "shield-check" + + // MARK: - Transportation + case car = "car" + case truck = "truck" + case plane = "plane" + case train = "train" + case bike = "bike" + case bus = "bus" + case ship = "ship" + + // MARK: - Multimedia & Entertainment + case airplay = "airplay" + case cast = "cast" + case headphones = "headphones" + case mic = "mic" + case micOff = "mic-off" + case camera = "camera" + case cameraOff = "camera-off" + case film = "film" + case gamepad = "gamepad-2" + + /// Returns the CSS class name for this Lucide icon. + /// + /// The class name follows the Lucide convention of "lucide-{icon-name}". + /// This can be used directly in HTML class attributes when the Lucide + /// CSS is included in the document. + /// + /// - Returns: The CSS class name (e.g., "lucide-airplay") + public var cssClass: String { + return "lucide-\(self.rawValue)" + } + + /// Returns the icon identifier string. + /// + /// This is the same as the rawValue but provides a more semantic + /// property name for when you need the string identifier. + /// + /// - Returns: The icon identifier (e.g., "airplay") + public var identifier: String { + return self.rawValue + } + + /// Returns a human-readable name for the icon. + /// + /// Converts the kebab-case identifier to a title-case name + /// suitable for display purposes. + /// + /// - Returns: A title-case name (e.g., "Air Play") + public var displayName: String { + return self.rawValue + .split(separator: "-") + .map { $0.capitalized } + .joined(separator: " ") + } +} + +// MARK: - ExpressibleByStringLiteral + +extension LucideIcon: ExpressibleByStringLiteral { + /// Creates a LucideIcon from a string literal. + /// + /// This allows using string literals where LucideIcon is expected, + /// providing a convenient way to specify icons while maintaining + /// type safety for known icons. + /// + /// ## Example + /// ```swift + /// let icon: LucideIcon = "airplay" // Creates LucideIcon.airplay + /// let customIcon: LucideIcon = "custom-icon" // Creates a custom case + /// ``` + /// + /// - Parameter value: The string literal representing the icon identifier + public init(stringLiteral value: String) { + self = LucideIcon(rawValue: value) ?? .circle // Default fallback + } +} + +// MARK: - CustomStringConvertible + +extension LucideIcon: CustomStringConvertible { + /// A textual representation of the icon. + /// + /// Returns the raw identifier string, making it easy to use + /// LucideIcon values in string contexts. + public var description: String { + return self.rawValue + } +} \ No newline at end of file diff --git a/Sources/WebUI/Elements/Media/SystemImage.swift b/Sources/WebUI/Elements/Media/SystemImage.swift index 4558b9ba..047ec50a 100644 --- a/Sources/WebUI/Elements/Media/SystemImage.swift +++ b/Sources/WebUI/Elements/Media/SystemImage.swift @@ -3,31 +3,89 @@ import Foundation /// Creates HTML elements for system images/icons using SVG or icon fonts. /// /// Represents system icons that can be used in buttons, labels, and other UI elements. -/// This component provides a simple way to include common icons in web interfaces. +/// This component supports both traditional system icons and Lucide icons, providing +/// flexibility for different icon systems while maintaining a consistent API. /// -/// ## Example +/// ## Usage with Lucide Icons /// ```swift -/// SystemImage("checkmark") -/// SystemImage("trash", classes: ["icon-danger"]) +/// SystemImage(.airplay) // Lucide enum +/// SystemImage("airplay") // String (auto-detects Lucide) +/// SystemImage(.heart, classes: ["favorite"]) // With custom styling +/// ``` +/// +/// ## Traditional System Icons +/// ```swift +/// SystemImage("checkmark") // Legacy system icon /// SystemImage("arrow.down", classes: ["download-icon"]) /// ``` public struct SystemImage: Element { - private let name: String + private let iconType: IconType private let id: String? private let classes: [String]? private let role: AriaRole? private let label: String? private let data: [String: String]? + + /// The type of icon being rendered. + private enum IconType { + case lucide(LucideIcon) + case system(String) + } + + /// Creates a new system image element using a LucideIcon. + /// + /// This initializer provides type-safe access to Lucide icons and + /// automatically configures the appropriate CSS classes. + /// + /// - Parameters: + /// - icon: The Lucide icon to display. + /// - id: Unique identifier for the HTML element. + /// - classes: An array of CSS classnames for styling the icon. + /// - role: ARIA role of the element for accessibility. + /// - label: ARIA label to describe the icon for accessibility. + /// - data: Dictionary of `data-*` attributes for storing custom data. + /// + /// ## Example + /// ```swift + /// SystemImage(.airplay) + /// SystemImage(.heart, classes: ["favorite-icon"]) + /// SystemImage(.settings, label: "Open settings") + /// ``` + public init( + _ icon: LucideIcon, + id: String? = nil, + classes: [String]? = nil, + role: AriaRole? = nil, + label: String? = nil, + data: [String: String]? = nil + ) { + self.iconType = .lucide(icon) + self.id = id + self.classes = classes + self.role = role + self.label = label + self.data = data + } - /// Creates a new system image element. + /// Creates a new system image element using a string identifier. + /// + /// This initializer automatically detects whether the string corresponds + /// to a known Lucide icon or should be treated as a traditional system icon. /// /// - Parameters: - /// - name: The name of the system image/icon. + /// - name: The name/identifier of the icon. /// - id: Unique identifier for the HTML element. /// - classes: An array of CSS classnames for styling the icon. /// - role: ARIA role of the element for accessibility. /// - label: ARIA label to describe the icon for accessibility. /// - data: Dictionary of `data-*` attributes for storing custom data. + /// + /// ## Example + /// ```swift + /// SystemImage("airplay") // Auto-detects as Lucide + /// SystemImage("checkmark") // Traditional system icon + /// SystemImage("custom-icon", classes: ["my-style"]) + /// ``` public init( _ name: String, id: String? = nil, @@ -36,7 +94,13 @@ public struct SystemImage: Element { label: String? = nil, data: [String: String]? = nil ) { - self.name = name + // Check if this is a known Lucide icon + if let lucideIcon = LucideIcon(rawValue: name) { + self.iconType = .lucide(lucideIcon) + } else { + self.iconType = .system(name) + } + self.id = id self.classes = classes self.role = role @@ -49,8 +113,24 @@ public struct SystemImage: Element { } private func renderTag() -> String { - // Combine base icon classes with custom classes - var allClasses = ["system-image", "icon-\(name.replacingOccurrences(of: ".", with: "-"))"] + var allClasses: [String] = [] + var labelText: String + + switch iconType { + case .lucide(let icon): + // Use Lucide CSS classes + allClasses.append("lucide") + allClasses.append(icon.cssClass) + labelText = label ?? icon.displayName + + case .system(let name): + // Use traditional system icon classes + allClasses.append("system-image") + allClasses.append("icon-\(name.replacingOccurrences(of: ".", with: "-"))") + labelText = label ?? name + } + + // Add custom classes if let classes = classes { allClasses.append(contentsOf: classes) } @@ -59,14 +139,18 @@ public struct SystemImage: Element { id: id, classes: allClasses, role: role, - label: label ?? name, + label: labelText, data: data ) - // Render as a span with CSS classes for icon fonts/CSS icons - // This allows flexibility for different icon systems (Font Awesome, Feather, etc.) + // Use for Lucide icons (icon font convention), for system icons + let tagName = switch iconType { + case .lucide: "i" + case .system: "span" + } + return AttributeBuilder.renderTag( - "span", attributes: attributes, content: "" + tagName, attributes: attributes, content: "" ) } } \ No newline at end of file diff --git a/Sources/WebUI/Utilities/LucideStyles.swift b/Sources/WebUI/Utilities/LucideStyles.swift new file mode 100644 index 00000000..4bcc4201 --- /dev/null +++ b/Sources/WebUI/Utilities/LucideStyles.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Utility for managing Lucide icon font styles in documents. +/// +/// This utility provides convenient methods for including the Lucide CSS +/// in your documents, either via CDN or local assets. It can be used +/// directly in Document implementations or as part of a Website's global styles. +/// +/// ## Usage in Document +/// ```swift +/// struct MyPage: Document { +/// var stylesheets: [String]? { +/// LucideStyles.cdn +/// } +/// } +/// ``` +/// +/// ## Usage in Website +/// ```swift +/// struct MyWebsite: Website { +/// var stylesheets: [String]? { +/// ["/css/styles.css"] + LucideStyles.cdn +/// } +/// } +/// ``` +public enum LucideStyles { + /// The CDN URL for the latest Lucide icon font. + /// + /// This loads the Lucide CSS from the unpkg.com CDN, which provides + /// fast global delivery and automatic caching. + public static let cdnURL = "https://unpkg.com/lucide-static@latest/font/lucide.css" + + /// The CDN URL for a specific version of Lucide icon font. + /// + /// Use this when you need to pin to a specific version for consistency + /// across deployments. + /// + /// - Parameter version: The version string (e.g., "0.294.0") + /// - Returns: The versioned CDN URL + public static func cdnURL(version: String) -> String { + return "https://unpkg.com/lucide-static@\(version)/font/lucide.css" + } + + /// Array containing the CDN stylesheet URL. + /// + /// Convenient for use in Document or Website stylesheet arrays. + public static let cdn: [String] = [cdnURL] + + /// Array containing a versioned CDN stylesheet URL. + /// + /// - Parameter version: The version string (e.g., "0.294.0") + /// - Returns: Array with the versioned CDN URL + public static func cdn(version: String) -> [String] { + return [cdnURL(version: version)] + } + + /// The local path for Lucide CSS assets. + /// + /// Use this when you want to serve Lucide CSS from your own domain + /// for better performance or offline support. + public static let localPath = "/css/lucide.css" + + /// Array containing the local stylesheet path. + /// + /// Convenient for use in Document or Website stylesheet arrays when + /// you're hosting Lucide CSS locally. + public static let local: [String] = [localPath] + + /// Checks if any Lucide icons are used in the given HTML content. + /// + /// This method can be used to conditionally include Lucide CSS only + /// when icons are actually used on a page. + /// + /// - Parameter content: The HTML content to analyze + /// - Returns: true if Lucide icon classes are found + public static func containsLucideIcons(in content: String) -> Bool { + return content.contains("lucide-") || content.contains("class=\"lucide") + } + + /// Returns CDN stylesheets only if Lucide icons are detected in content. + /// + /// This method provides automatic optimization by only including + /// Lucide CSS when icons are actually used. + /// + /// - Parameter content: The HTML content to analyze + /// - Returns: Array with CDN URL if icons are found, empty array otherwise + public static func conditionalCDN(for content: String) -> [String] { + return containsLucideIcons(in: content) ? cdn : [] + } + + /// Returns local stylesheets only if Lucide icons are detected in content. + /// + /// - Parameter content: The HTML content to analyze + /// - Returns: Array with local path if icons are found, empty array otherwise + public static func conditionalLocal(for content: String) -> [String] { + return containsLucideIcons(in: content) ? local : [] + } +} + +/// Extension to Document for easy Lucide CSS inclusion. +extension Document { + /// Stylesheets that include Lucide CSS via CDN. + /// + /// Use this computed property when you want to automatically include + /// Lucide CSS in your document. + /// + /// ## Example + /// ```swift + /// struct MyPage: Document { + /// var stylesheets: [String]? { + /// withLucideCDN + /// } + /// } + /// ``` + public var withLucideCDN: [String] { + let existing = stylesheets ?? [] + return existing + LucideStyles.cdn + } + + /// Stylesheets that include local Lucide CSS. + /// + /// Use this computed property when you want to automatically include + /// locally hosted Lucide CSS in your document. + /// + /// ## Example + /// ```swift + /// struct MyPage: Document { + /// var stylesheets: [String]? { + /// withLucideLocal + /// } + /// } + /// ``` + public var withLucideLocal: [String] { + let existing = stylesheets ?? [] + return existing + LucideStyles.local + } +} \ No newline at end of file diff --git a/Tests/WebUITests/ElementTests.swift b/Tests/WebUITests/ElementTests.swift index 68b20090..bc80c6b8 100644 --- a/Tests/WebUITests/ElementTests.swift +++ b/Tests/WebUITests/ElementTests.swift @@ -119,12 +119,13 @@ import Testing ) let rendered = image.render() - #expect(rendered.contains(" with Lucide classes + #expect(rendered.contains("")) + #expect(rendered.contains(">")) } @Test("Button element with onClick only") diff --git a/Tests/WebUITests/LucideIconTests.swift b/Tests/WebUITests/LucideIconTests.swift new file mode 100644 index 00000000..7e8b83ad --- /dev/null +++ b/Tests/WebUITests/LucideIconTests.swift @@ -0,0 +1,367 @@ +import Testing +import Foundation + +@testable import WebUI + +@Suite("Lucide Icon Tests") struct LucideIconTests { + + // MARK: - LucideIcon Enum Tests + + @Test("LucideIcon enum raw values") + func testLucideIconRawValues() { + #expect(LucideIcon.airplay.rawValue == "airplay") + #expect(LucideIcon.arrowLeft.rawValue == "arrow-left") + #expect(LucideIcon.checkCircle.rawValue == "check-circle") + #expect(LucideIcon.moreHorizontal.rawValue == "more-horizontal") + #expect(LucideIcon.shoppingCart.rawValue == "shopping-cart") + } + + @Test("LucideIcon CSS class generation") + func testLucideIconCSSClass() { + #expect(LucideIcon.airplay.cssClass == "lucide-airplay") + #expect(LucideIcon.arrowLeft.cssClass == "lucide-arrow-left") + #expect(LucideIcon.checkCircle.cssClass == "lucide-check-circle") + #expect(LucideIcon.github.cssClass == "lucide-github") + } + + @Test("LucideIcon identifier property") + func testLucideIconIdentifier() { + #expect(LucideIcon.settings.identifier == "settings") + #expect(LucideIcon.userPlus.identifier == "user-plus") + #expect(LucideIcon.fileText.identifier == "file-text") + } + + @Test("LucideIcon display name generation") + func testLucideIconDisplayName() { + #expect(LucideIcon.airplay.displayName == "Airplay") + #expect(LucideIcon.arrowLeft.displayName == "Arrow Left") + #expect(LucideIcon.checkCircle.displayName == "Check Circle") + #expect(LucideIcon.userPlus.displayName == "User Plus") + #expect(LucideIcon.shoppingCart.displayName == "Shopping Cart") + } + + @Test("LucideIcon string literal initialization") + func testLucideIconStringLiteral() { + let icon1: LucideIcon = "airplay" + let icon2: LucideIcon = "check-circle" + let icon3: LucideIcon = "nonexistent-icon" + + #expect(icon1 == .airplay) + #expect(icon2 == .checkCircle) + #expect(icon3 == .circle) // Falls back to default + } + + @Test("LucideIcon description") + func testLucideIconDescription() { + #expect(LucideIcon.airplay.description == "airplay") + #expect(LucideIcon.arrowLeft.description == "arrow-left") + #expect(LucideIcon.checkCircle.description == "check-circle") + } + + // MARK: - Icon Component Tests + + @Test("Icon component with LucideIcon enum") + func testIconComponentWithEnum() { + let icon = Icon(.airplay) + let rendered = icon.render() + + #expect(rendered.contains("")) + } + + @Test("Icon component with string identifier") + func testIconComponentWithString() { + let icon = Icon("heart") + let rendered = icon.render() + + #expect(rendered.contains("")) + } + + @Test("Icon component with size") + func testIconComponentWithSize() { + let smallIcon = Icon(.check, size: .small) + let largeIcon = Icon(.settings, size: .large) + + let smallRendered = smallIcon.render() + let largeRendered = largeIcon.render() + + #expect(smallRendered.contains("lucide-sm")) + #expect(smallRendered.contains("lucide-check")) + + #expect(largeRendered.contains("lucide-lg")) + #expect(largeRendered.contains("lucide-settings")) + } + + @Test("Icon component with custom classes") + func testIconComponentWithCustomClasses() { + let icon = Icon(.heart, classes: ["favorite-icon", "text-red-500"]) + let rendered = icon.render() + + #expect(rendered.contains("class=\"lucide lucide-heart favorite-icon text-red-500\"")) + } + + @Test("Icon component with all attributes") + func testIconComponentWithAllAttributes() { + let icon = Icon( + .settings, + size: .large, + id: "settings-icon", + classes: ["toolbar-icon"], + role: .button, + label: "Open settings menu", + data: ["action": "settings"] + ) + let rendered = icon.render() + + #expect(rendered.contains("id=\"settings-icon\"")) + #expect(rendered.contains("class=\"lucide lucide-settings lucide-lg toolbar-icon\"")) + #expect(rendered.contains("role=\"button\"")) + #expect(rendered.contains("aria-label=\"Open settings menu\"")) + #expect(rendered.contains("data-action=\"settings\"")) + } + + @Test("Icon component convenience methods") + func testIconComponentConvenienceMethods() { + let smallIcon = Icon.small(.check) + let largeIcon = Icon.large(.heart, classes: ["favorite"]) + let extraLargeIcon = Icon.extraLarge(.settings, label: "Settings") + + let smallRendered = smallIcon.render() + let largeRendered = largeIcon.render() + let extraLargeRendered = extraLargeIcon.render() + + #expect(smallRendered.contains("lucide-sm")) + #expect(largeRendered.contains("lucide-lg favorite")) + #expect(extraLargeRendered.contains("lucide-xl")) + #expect(extraLargeRendered.contains("aria-label=\"Settings\"")) + } + + // MARK: - SystemImage Component Tests + + @Test("SystemImage with LucideIcon enum") + func testSystemImageWithLucideEnum() { + let systemImage = SystemImage(.airplay) + let rendered = systemImage.render() + + #expect(rendered.contains("")) + } + + @Test("SystemImage with Lucide string auto-detection") + func testSystemImageWithLucideStringDetection() { + let lucideIcon = SystemImage("heart") + let systemIcon = SystemImage("checkmark") // Not in Lucide enum + + let lucideRendered = lucideIcon.render() + let systemRendered = systemIcon.render() + + // Lucide icon should use tag and lucide classes + #expect(lucideRendered.contains(" tag and system classes + #expect(systemRendered.contains("")) + } + + @Test("Button with LucideIcon and attributes") + func testButtonWithLucideIconAndAttributes() { + let button = Button( + "Delete", + systemImage: .trash, + onClick: "confirmDelete()", + id: "delete-btn", + classes: ["danger-button"], + label: "Delete item" + ) + let rendered = button.render() + + #expect(rendered.contains("id=\"delete-btn\"")) + #expect(rendered.contains("class=\"danger-button\"")) + #expect(rendered.contains("onclick=\"confirmDelete()\"")) + #expect(rendered.contains("aria-label=\"Delete item\"")) + #expect(rendered.contains("lucide-trash button-icon")) + #expect(rendered.contains(" Delete")) + } + + @Test("Button with string systemImage (legacy)") + func testButtonWithStringSystemImage() { + let button = Button("Save", systemImage: "checkmark") + let rendered = button.render() + + // Should use traditional system image classes + #expect(rendered.contains("system-image icon-checkmark button-icon")) + #expect(rendered.contains(" Save")) + } + + // MARK: - LucideStyles Utility Tests + + @Test("LucideStyles CDN URL generation") + func testLucideStylesCDN() { + #expect(LucideStyles.cdnURL == "https://unpkg.com/lucide-static@latest/font/lucide.css") + #expect(LucideStyles.cdnURL(version: "0.294.0") == "https://unpkg.com/lucide-static@0.294.0/font/lucide.css") + + #expect(LucideStyles.cdn == ["https://unpkg.com/lucide-static@latest/font/lucide.css"]) + #expect(LucideStyles.cdn(version: "0.294.0") == ["https://unpkg.com/lucide-static@0.294.0/font/lucide.css"]) + } + + @Test("LucideStyles local path") + func testLucideStylesLocal() { + #expect(LucideStyles.localPath == "/css/lucide.css") + #expect(LucideStyles.local == ["/css/lucide.css"]) + } + + @Test("LucideStyles icon detection") + func testLucideStylesIconDetection() { + let htmlWithLucideIcons = "" + let htmlWithoutLucideIcons = "No icons here" + let htmlWithLucideClass = "
Some content
" + + #expect(LucideStyles.containsLucideIcons(in: htmlWithLucideIcons)) + #expect(!LucideStyles.containsLucideIcons(in: htmlWithoutLucideIcons)) + #expect(LucideStyles.containsLucideIcons(in: htmlWithLucideClass)) + } + + @Test("LucideStyles conditional inclusion") + func testLucideStylesConditionalInclusion() { + let htmlWithIcons = "" + let htmlWithoutIcons = "No icons" + + let cdnWithIcons = LucideStyles.conditionalCDN(for: htmlWithIcons) + let cdnWithoutIcons = LucideStyles.conditionalCDN(for: htmlWithoutIcons) + + let localWithIcons = LucideStyles.conditionalLocal(for: htmlWithIcons) + let localWithoutIcons = LucideStyles.conditionalLocal(for: htmlWithoutIcons) + + #expect(cdnWithIcons == LucideStyles.cdn) + #expect(cdnWithoutIcons.isEmpty) + + #expect(localWithIcons == LucideStyles.local) + #expect(localWithoutIcons.isEmpty) + } + + // MARK: - Document Extension Tests + + @Test("Document extension Lucide CSS helpers") + func testDocumentLucideExtensions() { + struct TestDocument: Document { + var metadata: Metadata { + Metadata(site: "Test", title: "Test Page") + } + + var body: some HTML { + Icon(.heart) + } + + var stylesheets: [String]? { + ["/css/custom.css"] + } + } + + let document = TestDocument() + + let withCDN = document.withLucideCDN + let withLocal = document.withLucideLocal + + #expect(withCDN.contains("/css/custom.css")) + #expect(withCDN.contains(LucideStyles.cdnURL)) + + #expect(withLocal.contains("/css/custom.css")) + #expect(withLocal.contains(LucideStyles.localPath)) + } + + // MARK: - Integration Tests + + @Test("Complete icon rendering workflow") + func testCompleteIconWorkflow() { + // Test that all icon types render correctly together + let content = Stack { + Icon(.heart, size: .large, classes: ["text-red-500"]) + SystemImage(.settings) + Button("Save", systemImage: .check, type: .submit) + } + + let rendered = content.render() + + // Check that all Lucide icons are properly rendered + #expect(rendered.contains("lucide lucide-heart lucide-lg text-red-500")) + #expect(rendered.contains("lucide lucide-settings")) + #expect(rendered.contains("lucide lucide-check button-icon")) + + // Verify that Lucide detection works + #expect(LucideStyles.containsLucideIcons(in: rendered)) + } + + @Test("Icon accessibility features") + func testIconAccessibilityFeatures() { + let iconWithLabel = Icon(.settings, label: "Open settings menu") + let iconWithRole = Icon(.search, role: .button) + let iconDefault = Icon(.heart) + + let labelRendered = iconWithLabel.render() + let roleRendered = iconWithRole.render() + let defaultRendered = iconDefault.render() + + #expect(labelRendered.contains("aria-label=\"Open settings menu\"")) + #expect(roleRendered.contains("role=\"button\"")) + #expect(defaultRendered.contains("aria-label=\"Heart\"")) // Auto-generated from display name + } + + // MARK: - Edge Cases + + @Test("Unknown icon handling") + func testUnknownIconHandling() { + let unknownIcon = Icon("nonexistent-icon") + let rendered = unknownIcon.render() + + // Should fall back to circle icon + #expect(rendered.contains("lucide-circle")) + } + + @Test("Empty and nil attributes") + func testEmptyAndNilAttributes() { + let icon = Icon(.check, classes: [], data: [:]) + let rendered = icon.render() + + #expect(rendered.contains("class=\"lucide lucide-check\"")) + #expect(!rendered.contains("data-")) + } +} \ No newline at end of file