diff --git a/packages/native/apple/Package.swift b/packages/native/apple/Package.swift index c0d86d185a785..efe82b00f9898 100644 --- a/packages/native/apple/Package.swift +++ b/packages/native/apple/Package.swift @@ -27,6 +27,8 @@ let package = Package( name: "Astryx", dependencies: ["AstryxNative"], path: "Sources/Astryx", + // Each component's spec lives beside its view (AST-093 FR1). + exclude: ["Icon/Icon.spec.md"], resources: [.process("Resources")] ), .testTarget(name: "AstryxNativeTests", dependencies: ["AstryxNative"], path: "Tests/AstryxNativeTests"), diff --git a/packages/native/apple/README.md b/packages/native/apple/README.md index ce71fc1307534..1bb257a681db1 100644 --- a/packages/native/apple/README.md +++ b/packages/native/apple/README.md @@ -26,6 +26,7 @@ apple/ │ │ │ └── Generated/ # typed tokens and every published theme, generated; do not edit │ │ ├── Resources/ # core's messages per locale, generated; do not edit │ │ ├── Localization/ +│ │ ├── Icon/ # Icon, Icon.spec.md │ │ ├── Text/ # Text │ │ ├── Heading/ # Heading │ │ ├── Button/ # Button @@ -103,6 +104,12 @@ compile. Text sizes grow with Dynamic Type. The components still read some tokens by core's name (`astryx.color("color-accent")`); moving them onto the typed members comes next. +`Icon(.search)` draws a shared icon name as the platform's SF Symbol, at +core's icon size and color, hidden from VoiceOver unless it has a `label`. +`IconName` holds every built-in name as a static member; a theme maps a name +to another symbol, or registers a `:` name, with +`Theme(…, icons: ["acme:rocket": "airplane"])`. + Font families come from the theme's `font-family-*` stacks: the first family the device has wins, and a system family such as `-apple-system` or `sans-serif` is the system font. Icons use core's shared icon names diff --git a/packages/native/apple/Sources/Astryx/Icon/Icon.spec.md b/packages/native/apple/Sources/Astryx/Icon/Icon.spec.md new file mode 100644 index 0000000000000..4ab0649d7a32f --- /dev/null +++ b/packages/native/apple/Sources/Astryx/Icon/Icon.spec.md @@ -0,0 +1,182 @@ +--- +schema_version: 5 +template_version: 8 +kind: component +id: component:swift/Icon +platform_of: component:Icon +authority: draft +archive_reason: null +superseded_by: null +approved_by: null +approved_at: null +owners: [cixzhang] +review_triggers: [public-api, behavior, theming, accessibility] +verified_by: + [ + packages/native/apple/Tests/AstryxTests/IconTests.swift, + packages/native/src/contract/contracts.test.ts, + ] +modules: [] +families: [] +design_specs: [] +architecture: + [ + architecture:component-size-cascade, + architecture:icon-resolution-and-component-slots, + ] +contributing: [] +system_specs: [spec:AST-082, spec:AST-093] +--- + +# Icon (Swift) component contract + +## Contract at a glance + +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `Icon(_ icon: IconName, color: IconColor? = nil, size: IconSize? = nil, label: String? = nil)` and `Icon(_ props: IconProps)`, a SwiftUI `View` in module `Astryx`. `IconName` is a string-backed name with every built-in name as a static member (`.search`, `.close`). | +| Behavior | Draws the SF Symbol the theme or the platform maps the name to, in a square box at core's icon size grown with Dynamic Type, in core's icon color token or the surrounding foreground. A name nothing maps draws nothing. | +| End-user impact | A person sees the platform's own symbol for each shared icon, sized and colored as on the web, and VoiceOver reads only icons that carry a label. | +| Builder impact | An app passes a shared name (`.search`) or a name it maps in its theme (`"acme:rocket"`). An SVG component, which the web also accepts, has no Swift form. | +| Compatibility/readiness | Additive: a new view and a new generated `IconName` type. Draft; built and tested on macOS and the iOS Simulator; device captures are attached to the change. | +| Review checks | Reject a substitute symbol for an unknown name, an image or attributed-text form of the icon slot, a visual parameter beyond the contract, an unlabeled icon that VoiceOver reads, or an owner's size winning over an explicit `size`. | +| Governing rules | `spec:AST-082` FR4, FR7, FR17; `spec:AST-093` FR1–FR5; the Swift component API, styles, slots, theme environment, and accessibility records under `packages/native/specs/swift/library/`. | + +This table is a review projection; the body below is authoritative. + +## Intent + +Icon on Apple platforms draws Astryx's shared icon names as SF Symbols, so an +icon beside system controls looks like the platform's own while its size, color, +and accessibility follow the core `Icon` contract. + +## Compatibility and migration + +- Released default preserved: `not yet released` +- Compatibility class: additive +- Controlled/uncontrolled behavior: not applicable +- Migration decision: none + +Consumer migration instructions belong in consumer docs and release notes. + +## Ownership boundary + +**Owns** + +- Resolving a shared icon name to an SF Symbol: the theme's mapping, else the + platform default. +- Drawing the symbol in a square box at core's icon size, grown with Dynamic + Type, in core's icon color token. +- The icon's VoiceOver exposure. + +**Does not own / non-goals** + +- The meaning of `icon`, `color`, `size`, and `label` and the built-in names — + owned by `component:Icon` and `spec:AST-082` FR17. +- Which size an owner gives the icons in its icon slot — owned by each owning + component and `architecture:component-size-cascade`. +- The default SF Symbol for each name — owned by `@astryxdesign/native`'s icon + defaults, which a test keeps equal to the Swift map. + +## Public concepts + +| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | +| ------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------- | ---------------------- | ------------ | ----------------------------------------------------------------------- | +| icon name | the built-in names; a `:` the theme maps | `IconName`, a string on the wire, with each built-in as a static member | always | required | `component:Icon` | experimental | an unmapped name stops a development build and draws nothing in release | +| symbol source | theme mapping; platform default | `Theme(icons:)` replaces a built-in or registers a library name with an SF Symbol name | always | platform default | `component:swift/Icon` | experimental | a theme entry wins over the default | +| size | `xsm`, `sm`, `md`, `lg`; unset | 12, 16, 20, 24 points at the default text size | always | owner's icon size, else md | `component:Icon` | experimental | unset takes the owner's size, then `md` | +| color | `primary`, `secondary`, `tertiary`, `disabled`, `accent`, `success`, `error`, `warning`, `inherit`; unset | core's icon color tokens; `inherit` keeps the surrounding foreground | always | `inherit` | `component:Icon` | experimental | unset is `inherit` | + +## Behavioral and layout contract + +| ID | Candidate invariant | Basis | Draft review state | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------ | +| FR1 | The view MUST draw the SF Symbol the theme's `icons` maps the name to, else the platform default for a built-in name. A name neither maps MUST stop a development build with a message naming it and draw nothing in a release build, never a substitute. | `spec:AST-082` FR17; Swift slots S2 | verify | +| FR2 | The symbol MUST fit a square box whose side is core's size in points (12, 16, 20, 24 for `xsm`, `sm`, `md`, `lg`), multiplied by the Dynamic Type scale the theme environment reads. | core `Icon` sizes; `spec:AST-093` FR3; theme environment E1 | verify | +| FR3 | The view MUST use its own `size`, else the size its owning component gives icons in its documented icon slot, else `md`. | `architecture:component-size-cascade`; Swift slots S4 | verify | +| FR4 | The view MUST draw in core's token for its `color` — primary and secondary icon tokens, the secondary icon token for `tertiary`, the disabled icon token, and the accent, success, error, and warning colors — and, for `inherit` or no color, MUST keep the surrounding foreground style. | core `Icon` color map; `spec:AST-093` FR4 | verify | +| FR5 | Both initializers MUST build the same `IconProps`; the labeled initializer takes the contract's props with the generated types and defaults, and base props (`id`, `isHidden`) come through `IconProps`. With `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2, A3, A7; `spec:AST-082` FR7 | verify | + +### Allowed variation + +- **AV1 — Glyph shape.** The symbol is the platform's SF Symbol, not the web's + SVG; a theme may map any name to another SF Symbol. + +### Representative states + +| State | Required invariant | Allowed variation | +| --------------------------- | --------------------------------------------- | ---------------------------- | +| every size | box side 12, 16, 20, 24 points at Large text | glyph proportions per symbol | +| every color, light and dark | the color's token in the current mode | none | +| inside an owner's icon slot | the owner's size unless the icon sets its own | none | +| largest text size | the box grows with Dynamic Type | none | +| labeled and unlabeled | VoiceOver reads only the labeled icon | none | + +### Transformation and precedence order + +- **ORD1 — Symbol.** Theme mapping → platform default → nothing. +- **ORD2 — Size.** Explicit `size` → owner's icon size → `md`, then × Dynamic + Type scale. + +### Performance and resources + +- **PR1 — No image loading.** The view draws a system symbol by name and holds + no image data of its own. + +## Accessibility contract + +- **AR1 — Decorative unless labeled.** With no `label`, or an empty one, the + view is hidden from VoiceOver. With a label, it is one image element named by + the label, keeping SwiftUI's image trait and adding none (`spec:AST-082` FR17; + Swift accessibility X1, X4). +- **AR2 — Rendered tree not measured.** Tests assert what the view sets; the + rendered accessibility tree is not measured until an app-hosted target exists. + +## Design relationships + +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | --------------------------------------------- | -------------------------- | -------------- | ------------------ | +| Glyph | SF Symbols (Apple Human Interface Guidelines) | prescribed by the platform | supporting | FR1, FR2 | + +The component implements design requirements without copying their rationale. + +Platform control: SwiftUI `Image(systemName:)`. It is the platform's own icon +view and satisfies the contract, so no custom drawing is needed (`spec:AST-093` +FR2). Guideline pages: SF Symbols and Accessibility in the Apple Human +Interface Guidelines. + +Theme values read: `color.iconPrimary`, `color.iconSecondary`, +`color.iconDisabled`, `color.accent`, `color.success`, `color.error`, +`color.warning`, the theme's `icons` map, and the Dynamic Type scale. + +## Family and system relationships + +- `component:Icon` owns the props, the built-in names, the size scale, and the + color map; this record states only how Swift draws them. +- `architecture:component-size-cascade` owns when an owner's icon size applies; + owners set it through an internal environment value this view reads. + +## Verification map + +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | ----------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------- | -------------------------- | +| FR1 | `IconTests.swift` symbol tests; `contracts.test.ts` IconName test | every built-in; theme replacement; library name | a missing default, a symbol absent on the platform, or a substitute fails | `audit:swift/Icon/symbols` | +| FR2, FR3 | `IconTests.swift` size and Dynamic Type renders | each size; owner size; explicit size; AX5 | a wrong side or an owner size beating an explicit size fails | `audit:swift/Icon/size` | +| FR4 | `IconTests.swift` color map and two-theme render | every color; neutral and butter | a wrong token or a color of the view's own fails | `audit:swift/Icon/color` | +| FR5 | `IconTests.swift` initializer and hidden tests | both initializers; `isHidden` | differing props or a drawn hidden icon fails | `audit:swift/Icon/api` | +| AR1 | `IconTests.swift` accessibility assertions | labeled, unlabeled, empty label | an unlabeled icon with a label, or a labeled one without, fails | `audit:swift/Icon/a11y` | + +## Decision log + +No component-local decision is recorded. + +## Open questions + +- **OQ1 — Can a theme map a name to an image the app ships?** (`human-api`) + Swift slots S2 allows an asset as well as an SF Symbol; `Theme(icons:)` holds + SF Symbol names only. + +## Content boundary + +This file does not duplicate consumer prop tables/examples, current audit +results, implementation steps, or family/system rules. It links to their owners. diff --git a/packages/native/apple/Sources/Astryx/Icon/Icon.swift b/packages/native/apple/Sources/Astryx/Icon/Icon.swift new file mode 100644 index 0000000000000..a3a231e5907a1 --- /dev/null +++ b/packages/native/apple/Sources/Astryx/Icon/Icon.swift @@ -0,0 +1,154 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import AstryxNative +import SwiftUI + +/// One symbol for a shared icon name, at core's icon size and color, hidden +/// from VoiceOver unless it carries a label (core `Icon`). +/// +/// ```swift +/// Icon(.search) +/// Icon(.success, color: .success, label: "Completed") +/// ``` +public struct Icon: View { + /// The contract the view draws, the same for both initializers. + public let props: IconProps + @Environment(\.astryx) private var astryx + @Environment(\.astryxIconDefaultSize) private var contextualSize + + /// An icon from its labeled props. + /// - Parameters: + /// - icon: The shared icon name to draw (core `icon`). + /// - color: The icon color token; `nil` keeps the surrounding foreground (core `color`). + /// - size: The icon size; `nil` takes the owning component's icon size, else `md` (core `size`). + /// - label: The accessible name of a meaningful icon; `nil` or empty keeps it decorative (core `label`). + public init(_ icon: IconName, color: IconColor? = nil, size: IconSize? = nil, label: String? = nil) { + self.init(IconProps(icon: icon, color: color, size: size, label: label)) + } + + /// An icon from its generated contract, for generated and wire-driven callers. + public init(_ props: IconProps) { + self.props = props + } + + public var body: some View { + if props.base.isHidden != true { + if let symbol = Icon.symbol(for: props.icon, themeIcons: astryx.icons) { + let side = Icon.points(for: resolvedSize) * astryx.textScale + Image(systemName: symbol) + .resizable() + .scaledToFit() + .frame(width: side, height: side) + .modifier(IconTint(color: tint)) + .modifier(IconAccessibility(label: accessibilityLabel)) + .modifier(IconIdentifier(id: props.base.id)) + } else { + // AST-082 FR17: a name nothing maps draws nothing, never a substitute. + let _ = assertionFailure("Astryx has no icon named \"\(props.icon.rawValue)\"") + } + } + } + + /// The size the icon draws at: its own, else its owner's icon size, else `md`. + var resolvedSize: IconSize { + props.size ?? contextualSize ?? .md + } + + /// The theme color the icon draws in, or `nil` to keep the surrounding + /// foreground (`inherit`). + var tint: Color? { + Icon.color(for: props.color ?? .inherit, in: astryx.tokens) + } + + /// The name VoiceOver reads, or `nil` when the icon is decorative. + var accessibilityLabel: String? { + guard let label = props.label, !label.isEmpty else { return nil } + return label + } + + /// Core's fixed icon scale in points at the default text size: 12, 16, 20, + /// and 24, as core's 0.75, 1, 1.25, and 1.5 rem. + nonisolated static func points(for size: IconSize) -> CGFloat { + switch size { + case .xsm: 12 + case .sm: 16 + case .md: 20 + case .lg: 24 + } + } + + /// The theme token each color draws with, as core maps them; `inherit` has none. + nonisolated static func color(for color: IconColor, in tokens: Tokens) -> Color? { + switch color { + case .primary: tokens.color.iconPrimary + case .secondary, .tertiary: tokens.color.iconSecondary + case .disabled: tokens.color.iconDisabled + case .accent: tokens.color.accent + case .success: tokens.color.success + case .error: tokens.color.error + case .warning: tokens.color.warning + case .inherit: nil + } + } + + /// The SF Symbol a name draws: the theme's mapping (which also registers a + /// `:` name), else the platform default; `nil` for a name + /// neither maps. + nonisolated static func symbol(for name: IconName, themeIcons: [String: String]) -> String? { + themeIcons[name.rawValue] ?? astryxDefaultIcons[name.rawValue] + } +} + +/// A labeled icon reads as an image with its label; an unlabeled one is +/// hidden from VoiceOver (AST-082 FR17). +private struct IconAccessibility: ViewModifier { + let label: String? + + func body(content: Content) -> some View { + if let label { + content.accessibilityLabel(label) + } else { + content.accessibilityHidden(true) + } + } +} + +/// A theme color, or no style of its own so the surrounding foreground +/// reaches the symbol (`inherit`). +private struct IconTint: ViewModifier { + let color: Color? + + func body(content: Content) -> some View { + if let color { + content.foregroundStyle(color) + } else { + content + } + } +} + +/// The base `id` becomes the accessibility identifier (AST-082 FR7). +private struct IconIdentifier: ViewModifier { + let id: String? + + func body(content: Content) -> some View { + if let id { + content.accessibilityIdentifier(id) + } else { + content + } + } +} + +private struct AstryxIconDefaultSizeKey: EnvironmentKey { + static let defaultValue: IconSize? = nil +} + +extension EnvironmentValues { + /// The icon size a component gives the icons in its documented icon slot; + /// an `Icon` with no size of its own draws at it (core's contextual size). + var astryxIconDefaultSize: IconSize? { + get { self[AstryxIconDefaultSizeKey.self] } + set { self[AstryxIconDefaultSizeKey.self] = newValue } + } +} diff --git a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift index 7b3b7c66d91de..f5af9061a7cf6 100644 --- a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift +++ b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift @@ -13,6 +13,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case divider(DividerNode) case heading(HeadingNode) case hStack(HStackNode) + case icon(IconNode) case progressBar(ProgressBarNode) case selector(SelectorNode) case `switch`(SwitchNode) @@ -29,6 +30,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case .divider(let node): return node.key case .heading(let node): return node.key case .hStack(let node): return node.key + case .icon(let node): return node.key case .progressBar(let node): return node.key case .selector(let node): return node.key case .`switch`(let node): return node.key @@ -53,6 +55,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case "Divider": self = .divider(try DividerNode(from: decoder)) case "Heading": self = .heading(try HeadingNode(from: decoder)) case "HStack": self = .hStack(try HStackNode(from: decoder)) + case "Icon": self = .icon(try IconNode(from: decoder)) case "ProgressBar": self = .progressBar(try ProgressBarNode(from: decoder)) case "Selector": self = .selector(try SelectorNode(from: decoder)) case "Switch": self = .`switch`(try SwitchNode(from: decoder)) diff --git a/packages/native/apple/Sources/AstryxNative/Generated/Icon.swift b/packages/native/apple/Sources/AstryxNative/Generated/Icon.swift new file mode 100644 index 0000000000000..e5a6daa4317bb --- /dev/null +++ b/packages/native/apple/Sources/AstryxNative/Generated/Icon.swift @@ -0,0 +1,106 @@ +// Generated by @astryxdesign/native. Do not edit. +public enum IconColor: String, CaseIterable, Codable, Sendable { + case primary = "primary" + case secondary = "secondary" + case tertiary = "tertiary" + case disabled = "disabled" + case accent = "accent" + case success = "success" + case error = "error" + case warning = "warning" + case inherit = "inherit" +} +public enum IconSize: String, CaseIterable, Codable, Sendable { + case xsm = "xsm" + case sm = "sm" + case md = "md" + case lg = "lg" +} +#if os(iOS) +public struct IconProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var icon: IconName + public var color: IconColor? + public var size: IconSize? + public var label: String? + public init(icon: IconName, color: IconColor? = nil, size: IconSize? = nil, label: String? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.icon = icon + self.color = color + self.size = size + self.label = label + self.base = base + } +} +public struct IconNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var icon: IconName + public var color: IconColor? + public var size: IconSize? + public var label: String? + } + public let key: String + public let base: AstryxBaseProps? + public let props: Props + /// The callbacks the core listens to, by prop name. + public let events: [String]? + + public func listens(_ event: String) -> Bool { events?.contains(event) ?? false } + + /// The contract a Icon view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> IconProps { + return IconProps( + icon: props.icon, + color: props.color, + size: props.size, + label: props.label, + base: base ?? AstryxBaseProps() + ) + } +} +#endif +#if os(macOS) +public struct IconProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var icon: IconName + public var color: IconColor? + public var size: IconSize? + public var label: String? + public init(icon: IconName, color: IconColor? = nil, size: IconSize? = nil, label: String? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.icon = icon + self.color = color + self.size = size + self.label = label + self.base = base + } +} +public struct IconNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var icon: IconName + public var color: IconColor? + public var size: IconSize? + public var label: String? + } + public let key: String + public let base: AstryxBaseProps? + public let props: Props + /// The callbacks the core listens to, by prop name. + public let events: [String]? + + public func listens(_ event: String) -> Bool { events?.contains(event) ?? false } + + /// The contract a Icon view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> IconProps { + return IconProps( + icon: props.icon, + color: props.color, + size: props.size, + label: props.label, + base: base ?? AstryxBaseProps() + ) + } +} +#endif diff --git a/packages/native/apple/Sources/AstryxNative/Generated/IconName.swift b/packages/native/apple/Sources/AstryxNative/Generated/IconName.swift new file mode 100644 index 0000000000000..bb73a7eb0e2c4 --- /dev/null +++ b/packages/native/apple/Sources/AstryxNative/Generated/IconName.swift @@ -0,0 +1,47 @@ +// Generated by @astryxdesign/native. Do not edit. +/// A shared icon name (AST-082 FR17): a built-in name, or a registered +/// `:` name. A view draws the image the theme or the platform +/// maps it to. +public struct IconName: RawRepresentable, ExpressibleByStringLiteral, Hashable, Codable, Sendable { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.rawValue = value } + public init(from decoder: any Decoder) throws { + self.rawValue = try decoder.singleValueContainer().decode(String.self) + } + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + /// Every built-in name, in the order AST-082 FR17 lists them. + public static let builtIn: [IconName] = [.close, .chevronDown, .chevronLeft, .chevronRight, .chevronsLeft, .chevronsRight, .check, .success, .error, .warning, .info, .calendar, .clock, .externalLink, .menu, .moreHorizontal, .search, .upload, .arrowUp, .arrowDown, .arrowsUpDown, .funnel, .eyeSlash, .viewColumns, .copy, .checkDouble, .wrench, .stop, .microphone] + public static let close: IconName = "close" + public static let chevronDown: IconName = "chevronDown" + public static let chevronLeft: IconName = "chevronLeft" + public static let chevronRight: IconName = "chevronRight" + public static let chevronsLeft: IconName = "chevronsLeft" + public static let chevronsRight: IconName = "chevronsRight" + public static let check: IconName = "check" + public static let success: IconName = "success" + public static let error: IconName = "error" + public static let warning: IconName = "warning" + public static let info: IconName = "info" + public static let calendar: IconName = "calendar" + public static let clock: IconName = "clock" + public static let externalLink: IconName = "externalLink" + public static let menu: IconName = "menu" + public static let moreHorizontal: IconName = "moreHorizontal" + public static let search: IconName = "search" + public static let upload: IconName = "upload" + public static let arrowUp: IconName = "arrowUp" + public static let arrowDown: IconName = "arrowDown" + public static let arrowsUpDown: IconName = "arrowsUpDown" + public static let funnel: IconName = "funnel" + public static let eyeSlash: IconName = "eyeSlash" + public static let viewColumns: IconName = "viewColumns" + public static let copy: IconName = "copy" + public static let checkDouble: IconName = "checkDouble" + public static let wrench: IconName = "wrench" + public static let stop: IconName = "stop" + public static let microphone: IconName = "microphone" +} diff --git a/packages/native/apple/Tests/AstryxTests/IconTests.swift b/packages/native/apple/Tests/AstryxTests/IconTests.swift new file mode 100644 index 0000000000000..2ebff77dac634 --- /dev/null +++ b/packages/native/apple/Tests/AstryxTests/IconTests.swift @@ -0,0 +1,142 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import SwiftUI +import Testing +@testable import Astryx + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +// Swift Icon spec (component:swift/Icon): one SF Symbol per shared icon name, +// at core's icon size and color, decorative unless labeled. + +@Test func everyBuiltInNameDrawsASymbolThePlatformHas() { + #expect(IconName.builtIn.count == astryxDefaultIcons.count) + for name in IconName.builtIn { + let symbol = Icon.symbol(for: name, themeIcons: [:]) + #expect(symbol != nil, "no symbol for \(name.rawValue)") + if let symbol { + #if canImport(UIKit) + #expect(UIImage(systemName: symbol) != nil, "\(symbol) is not an SF Symbol here") + #elseif canImport(AppKit) + #expect(NSImage(systemSymbolName: symbol, accessibilityDescription: nil) != nil, "\(symbol) is not an SF Symbol here") + #endif + } + } +} + +@Test func aThemeReplacesASymbolAndRegistersALibraryName() { + #expect(Icon.symbol(for: .search, themeIcons: [:]) == "magnifyingglass") + #expect(Icon.symbol(for: .search, themeIcons: ["search": "sparkle.magnifyingglass"]) == "sparkle.magnifyingglass") + #expect(Icon.symbol(for: "acme:rocket", themeIcons: ["acme:rocket": "airplane"]) == "airplane") + // An unregistered name maps to nothing; the view then draws nothing. + #expect(Icon.symbol(for: "acme:rocket", themeIcons: [:]) == nil) +} + +@Test func iconNamesAreStringsOnTheWire() throws { + let encoded = try JSONEncoder().encode([IconName.chevronDown, "acme:rocket"]) + #expect(String(decoding: encoded, as: UTF8.self) == #"["chevronDown","acme:rocket"]"#) + #expect(try JSONDecoder().decode([IconName].self, from: encoded) == [.chevronDown, "acme:rocket"]) +} + +@MainActor +@Test func iconInitializersBuildTheSameProps() { + let labeled = Icon(.success, color: .success, size: .lg, label: "Completed").props + let fromProps = Icon(IconProps(icon: .success, color: .success, size: .lg, label: "Completed")).props + #expect(labeled.icon == fromProps.icon) + #expect(labeled.color == fromProps.color) + #expect(labeled.size == fromProps.size) + #expect(labeled.label == fromProps.label) + #expect(labeled.base == fromProps.base) + + let defaults = Icon(.search).props + #expect(defaults.color == nil) + #expect(defaults.size == nil) + #expect(defaults.label == nil) +} + +@MainActor +@Test func anExplicitSizeWinsThenTheOwnersThenMedium() { + #expect(Icon(.search).resolvedSize == .md) + #expect(Icon(.search, size: .sm).resolvedSize == .sm) + #expect(Icon.points(for: .xsm) == 12) + #expect(Icon.points(for: .sm) == 16) + #expect(Icon.points(for: .md) == 20) + #expect(Icon.points(for: .lg) == 24) + + let owned = Pixels(Icon(.stop, color: .primary).environment(\.astryxIconDefaultSize, .lg).fixedSize()) + #expect(owned.width == 24) + let explicit = Pixels(Icon(.stop, color: .primary, size: .xsm).environment(\.astryxIconDefaultSize, .lg).fixedSize()) + #expect(explicit.width == 12) +} + +@Test func colorsDrawCoresTokensAndInheritKeepsTheForeground() { + let tokens = Theme.default.light + #expect(Icon.color(for: .primary, in: tokens) == tokens.color.iconPrimary) + #expect(Icon.color(for: .secondary, in: tokens) == tokens.color.iconSecondary) + #expect(Icon.color(for: .tertiary, in: tokens) == tokens.color.iconSecondary) + #expect(Icon.color(for: .disabled, in: tokens) == tokens.color.iconDisabled) + #expect(Icon.color(for: .accent, in: tokens) == tokens.color.accent) + #expect(Icon.color(for: .success, in: tokens) == tokens.color.success) + #expect(Icon.color(for: .error, in: tokens) == tokens.color.error) + #expect(Icon.color(for: .warning, in: tokens) == tokens.color.warning) + #expect(Icon.color(for: .inherit, in: tokens) == nil) +} + +@MainActor +@Test func anInheritIconTakesTheSurroundingForeground() { + let red = [255, 0, 0, 255] + let inherit = Pixels(Icon(.stop, size: .lg).foregroundStyle(Color(red: 1, green: 0, blue: 0)).fixedSize()) + #expect(inherit.contains { close($0, red) }) + let colored = Pixels(Icon(.stop, color: .accent, size: .lg).foregroundStyle(Color(red: 1, green: 0, blue: 0)).fixedSize()) + #expect(!colored.contains { close($0, red) }) +} + +// FR5 accessibility: what the view sets. The rendered accessibility tree is +// not measured until an app-hosted target exists. +@MainActor +@Test func aLabeledIconIsNamedAndAnUnlabeledOneIsDecorative() { + #expect(Icon(.success, label: "Completed").accessibilityLabel == "Completed") + #expect(Icon(.success).accessibilityLabel == nil) + #expect(Icon(.success, label: "").accessibilityLabel == nil) +} + +@MainActor +@Test func anIconDrawsInItsThemeColor() { + let accent = rgba(Theme.default.light.color.accent) + let icon = Pixels(Icon(.stop, color: .accent, size: .lg).fixedSize()) + #expect(icon.width == 24 && icon.height == 24) + #expect(icon.contains { close($0, accent) }) +} + +@MainActor +@Test func anIconRendersFromTwoThemesWithNoColorOfItsOwn() { + let view = Icon(.stop, color: .accent, size: .lg).fixedSize() + let neutral = Pixels(view, theme: .neutral) + let butter = Pixels(view, theme: .butter) + #expect(neutral.contains { close($0, rgba(Theme.neutral.light.color.accent)) }) + #expect(butter.contains { close($0, rgba(Theme.butter.light.color.accent)) }) + #expect(!butter.contains { close($0, rgba(Theme.neutral.light.color.accent)) }) +} + +@MainActor +@Test func anIconGrowsWithTheLargestTextSize() { + let large = Pixels(Icon(.stop, color: .primary).fixedSize()) + let largest = Pixels(Icon(.stop, color: .primary).fixedSize(), typeSize: .accessibility5) + #expect(large.width == 20) + #expect(largest.width > large.width) +} + +@MainActor +@Test func aHiddenIconDrawsNothing() { + let hidden = Pixels( + HStack(spacing: 0) { + Color.clear.frame(width: 1, height: 1) + Icon(IconProps(icon: .stop, base: AstryxBaseProps(isHidden: true))) + }.fixedSize() + ) + #expect(hidden.width == 1) +} diff --git a/packages/native/src/components/Icon.ts b/packages/native/src/components/Icon.ts new file mode 100644 index 0000000000000..f39ede65d0ddb --- /dev/null +++ b/packages/native/src/components/Icon.ts @@ -0,0 +1,18 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file components/Icon.ts + * @input None + * @output What @astryxdesign/native adds to the core Icon contract + * @position Native additions read by fromComponentDoc together with core's + * Icon documentation + */ + +import type {NativeAdditions} from '../contract/fromComponentDoc.js'; + +export const icon: NativeAdditions = { + name: 'Icon', + baseTarget: 'root', + // A shared icon name (AST-082 FR17); an SVG component stays web-only. + slots: {icon: 'icon'}, +}; diff --git a/packages/native/src/components/index.ts b/packages/native/src/components/index.ts index 69997380dba1a..2d2a14635fde1 100644 --- a/packages/native/src/components/index.ts +++ b/packages/native/src/components/index.ts @@ -15,6 +15,7 @@ import {card} from './Card.js'; import {divider} from './Divider.js'; import {heading} from './Heading.js'; import {hStack} from './HStack.js'; +import {icon} from './Icon.js'; import {progressBar} from './ProgressBar.js'; import {selector} from './Selector.js'; import {switchControl} from './Switch.js'; @@ -29,6 +30,7 @@ export const NATIVE_COMPONENTS: readonly NativeAdditions[] = [ divider, heading, hStack, + icon, progressBar, selector, switchControl, diff --git a/packages/native/src/contract/contracts.test.ts b/packages/native/src/contract/contracts.test.ts index 80a9ad408292c..efc703e011655 100644 --- a/packages/native/src/contract/contracts.test.ts +++ b/packages/native/src/contract/contracts.test.ts @@ -14,6 +14,7 @@ import { generateTypeScript, } from './index.js'; import type {ComponentContract} from './types.js'; +import {BUILTIN_ICON_NAMES} from '../icons/defaults.js'; const PACKAGE_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -95,6 +96,32 @@ describe('native contracts', () => { ); }); + it('gives Icon a typed icon slot drawn from the shared icon names', async () => { + const {contracts} = await buildContracts(loadCoreDoc); + const icon = contracts.find(contract => contract.name === 'Icon'); + expect(icon).toBeDefined(); + expect(icon!.core.icon).toMatchObject({type: {kind: 'icon'}}); + expect(icon!.core.icon.optional).toBe(false); + expect(Object.keys(icon!.core)).toEqual(['icon', 'color', 'size', 'label']); + expect(icon!.webOnly.map(prop => prop.name)).toEqual(['xstyle']); + + const swift = generateSwift(contracts); + expect(swift['Icon.swift']).toContain('public var icon: IconName'); + const iconName = swift['IconName.swift']; + for (const name of BUILTIN_ICON_NAMES) { + expect(iconName).toContain( + `public static let ${name}: IconName = "${name}"`, + ); + } + expect(iconName).toContain('ExpressibleByStringLiteral'); + + const types = generateTypeScript(contracts); + expect(types['core.d.ts']).toContain( + "export type IconName = 'close' | 'chevronDown'", + ); + expect(types['core.d.ts']).toContain('icon: IconName;'); + }); + it('reports naming and portability errors', () => { const bad: ComponentContract = { name: 'Bad', diff --git a/packages/native/src/contract/fromComponentDoc.ts b/packages/native/src/contract/fromComponentDoc.ts index 4487d490367c5..0f9898ac91179 100644 --- a/packages/native/src/contract/fromComponentDoc.ts +++ b/packages/native/src/contract/fromComponentDoc.ts @@ -46,9 +46,10 @@ export interface ComponentDocLike { /** * Typed slots (AST-082 FR4) for documented free-form content. `text` is a * string in the core contract, as a badge's label or a heading's words; - * `content` is child Astryx components, as a stack's or a card's children. + * `content` is child Astryx components, as a stack's or a card's children; + * `icon` is a shared icon name (FR17), as an icon's glyph. */ -export type SlotKind = 'text' | 'content'; +export type SlotKind = 'text' | 'content' | 'icon'; /** What the native package adds to one core component. */ export interface NativeAdditions { @@ -120,6 +121,7 @@ function documentedProps( const SLOT_TYPES: Readonly> = { text: {kind: 'string'}, content: {kind: 'content'}, + icon: {kind: 'icon'}, }; export function fromComponentDoc( diff --git a/packages/native/src/contract/generateSwift.ts b/packages/native/src/contract/generateSwift.ts index f94df6eb0da4b..d6948a9bac52f 100644 --- a/packages/native/src/contract/generateSwift.ts +++ b/packages/native/src/contract/generateSwift.ts @@ -13,6 +13,7 @@ * committed under apple/ so a Swift package can pin a commit */ +import {BUILTIN_ICON_NAMES} from '../icons/defaults.js'; import {SWIFT_KEYWORDS} from './names.js'; import type {ComponentContract, PropMap, PropType} from './types.js'; @@ -85,6 +86,8 @@ class SwiftWriter { return `(${type.args.map((arg, i) => this.type(owner, `${prop}Arg${i}`, arg)).join(', ')}) -> Void`; case 'content': return '[AstryxNode]'; + case 'icon': + return 'IconName'; case 'named': return type.name; } @@ -367,6 +370,40 @@ const BASE_FILE = ].join('\n') + '\n'; +/** + * `IconName`: a shared icon name (AST-082 FR17), string-backed so a + * registered `:` name is valid too, with every built-in name + * as a static member. + */ +function iconNameFile(): string { + return ( + HEADER + + [ + '/// A shared icon name (AST-082 FR17): a built-in name, or a registered', + '/// `:` name. A view draws the image the theme or the platform', + '/// maps it to.', + 'public struct IconName: RawRepresentable, ExpressibleByStringLiteral, Hashable, Codable, Sendable {', + ' public let rawValue: String', + ' public init(rawValue: String) { self.rawValue = rawValue }', + ' public init(stringLiteral value: String) { self.rawValue = value }', + ' public init(from decoder: any Decoder) throws {', + ' self.rawValue = try decoder.singleValueContainer().decode(String.self)', + ' }', + ' public func encode(to encoder: any Encoder) throws {', + ' var container = encoder.singleValueContainer()', + ' try container.encode(rawValue)', + ' }', + ' /// Every built-in name, in the order AST-082 FR17 lists them.', + ` public static let builtIn: [IconName] = [${BUILTIN_ICON_NAMES.map(name => `.${ident(name)}`).join(', ')}]`, + ...BUILTIN_ICON_NAMES.map( + name => ` public static let ${ident(name)}: IconName = "${name}"`, + ), + '}', + ].join('\n') + + '\n' + ); +} + /** Returns file name to contents, relative to the generated Swift folder. */ export function generateSwift( contracts: readonly ComponentContract[], @@ -375,6 +412,7 @@ export function generateSwift( 'AstryxBase.swift': BASE_FILE, 'AstryxNode.swift': nodeFile(contracts), 'AstryxWire.swift': WIRE_FILE, + 'IconName.swift': iconNameFile(), }; for (const contract of contracts) { files[`${contract.name}.swift`] = componentFile(contract); diff --git a/packages/native/src/contract/generateTypeScript.ts b/packages/native/src/contract/generateTypeScript.ts index 3cce8e47f32d8..4434111b8f11d 100644 --- a/packages/native/src/contract/generateTypeScript.ts +++ b/packages/native/src/contract/generateTypeScript.ts @@ -8,6 +8,7 @@ * exports map selects one file per type-resolution condition */ +import {BUILTIN_ICON_NAMES} from '../icons/defaults.js'; import {NATIVE_PLATFORMS, type NativePlatform} from '../platforms.js'; import type {ComponentContract, PropMap, PropSpec, PropType} from './types.js'; @@ -37,6 +38,8 @@ function tsType(type: PropType, content = 'AstryxContentNode'): string { return `(${type.args.map((arg, i) => `arg${i}: ${tsType(arg, content)}`).join(', ')}) => void`; case 'content': return `ReadonlyArray<${content}>`; + case 'icon': + return 'IconName'; case 'named': return type.name; } @@ -80,13 +83,19 @@ function coreFile(contracts: readonly ComponentContract[]): string { 'export interface AstryxSharedBaseProps {\n id?: string;\n isHidden?: boolean;\n}\n' + '/** One child Astryx component in a content slot (AST-082 FR4). */\n' + 'export interface AstryxContentNode {\n readonly component: string;\n readonly key: string;\n}\n' + + '/** A shared icon name (AST-082 FR17): a built-in name or a registered `:`. */\n' + + `export type IconName = ${BUILTIN_ICON_NAMES.map(name => `'${name}'`).join(' | ')} | \`\${string}:\${string}\`;\n` + [...typeDecls, ...props].join('\n') + '\n' ); } function reExports(contracts: readonly ComponentContract[]): string { - const names = new Set(['AstryxSharedBaseProps', 'AstryxContentNode']); + const names = new Set([ + 'AstryxSharedBaseProps', + 'AstryxContentNode', + 'IconName', + ]); for (const contract of contracts) { Object.keys(contract.types).forEach(name => names.add(name)); names.add(`${contract.name}CoreProps`); @@ -149,7 +158,7 @@ function platformFile( platform: NativePlatform | null, ): string { const named = contracts.flatMap(c => Object.keys(c.types)); - const imports = `import type {${['AstryxSharedBaseProps', ...new Set(named), ...contracts.map(c => `${c.name}CoreProps`)].join(', ')}} from './core.js';\n`; + const imports = `import type {${['AstryxSharedBaseProps', 'IconName', ...new Set(named), ...contracts.map(c => `${c.name}CoreProps`)].join(', ')}} from './core.js';\n`; const components = contracts.map(contract => { const extra = platform ? (contract.platforms[platform] ?? {}) : {}; const body = Object.keys(extra).length > 0 ? `\n ${members(extra)}\n` : ''; diff --git a/packages/native/src/contract/types.ts b/packages/native/src/contract/types.ts index 0d98539da61dc..598328832a122 100644 --- a/packages/native/src/contract/types.ts +++ b/packages/native/src/contract/types.ts @@ -23,6 +23,8 @@ export type PropType = | {kind: 'fn'; args: readonly PropType[]} /** Child Astryx components: the typed content slot (AST-082 FR4). */ | {kind: 'content'} + /** A shared icon name: the typed icon slot (AST-082 FR4, FR17). */ + | {kind: 'icon'} | {kind: 'named'; name: string}; export interface PropSpec {