From 0b770d970d82da29a84f66e89b11e40019b563d3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 1 Nov 2026 14:15:57 -0800 Subject: [PATCH] feat(native): Swift ListItem draws an Item with its list's density, marker, dividers, and edge --- .../components/List/ListItem.swift.spec.md | 169 ++++++++++++ packages/native/apple/README.md | 1 + .../apple/Sources/Astryx/Item/Item.swift | 106 +++++++- .../apple/Sources/Astryx/List/ListItem.swift | 255 ++++++++++++++++++ .../Sources/Astryx/Wire/AstryxNodeView.swift | 7 + .../AstryxNative/Generated/AstryxNode.swift | 3 + .../AstryxNative/Generated/ListItem.swift | 122 +++++++++ .../Tests/AstryxTests/ListItemTests.swift | 234 ++++++++++++++++ .../specs/swift/library/component-api.md | 4 +- packages/native/src/components/ListItem.ts | 32 +++ packages/native/src/components/index.ts | 2 + .../native/src/contract/contracts.test.ts | 21 ++ 12 files changed, 947 insertions(+), 9 deletions(-) create mode 100644 docs/specs/components/List/ListItem.swift.spec.md create mode 100644 packages/native/apple/Sources/Astryx/List/ListItem.swift create mode 100644 packages/native/apple/Sources/AstryxNative/Generated/ListItem.swift create mode 100644 packages/native/apple/Tests/AstryxTests/ListItemTests.swift create mode 100644 packages/native/src/components/ListItem.ts diff --git a/docs/specs/components/List/ListItem.swift.spec.md b/docs/specs/components/List/ListItem.swift.spec.md new file mode 100644 index 0000000000000..69e84e1df0480 --- /dev/null +++ b/docs/specs/components/List/ListItem.swift.spec.md @@ -0,0 +1,169 @@ +--- +schema_version: 3 +template_version: 7 +kind: component +id: component:swift/ListItem +platform_of: component:ListItem +authority: draft +archive_reason: null +superseded_by: null +approved_by: null +approved_at: null +owners: [cixzhang] +review_triggers: [public-api, behavior, layout, theming, accessibility] +verified_by: + [ + packages/native/apple/Tests/AstryxTests/ListItemTests.swift, + packages/native/src/contract/contracts.test.ts, + ] +modules: [] +families: [] +design_specs: [] +architecture: [] +contributing: [] +system_specs: [spec:AST-082, spec:AST-093] +--- + +# ListItem (Swift) component contract + +## Contract at a glance + +| Area | Contract | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `ListItem(_ label:, description:, align:, href:, isDisabled:, isSelected:, startContent:, endContent:, onClick:)` and `ListItem(_ props: ListItemProps, startContent:, endContent:)`, a generic SwiftUI `View`. | +| Behavior | Draws `component:swift/Item` with the density, marker, dividers, and edge of the `List` around it: a dot, a ring, or its number on the label's first line; square corners between dividers; no inline inset in a flush list. | +| End-user impact | A person sees bulleted and numbered lists, settings lists, and divided lists on iPhone laid out as on the web, with each marker beside its row's first line however the row wraps. | +| Builder impact | An app puts `ListItem`s in a `List`; the row takes the list's look. Swipe actions, `target`, and `rel` have no Swift form. | +| Compatibility/readiness | Additive: a new view and generated `ListItemProps`, and internal list hooks on `Item`. Draft; built and tested on macOS and the iOS Simulator; device captures are attached to the change. | +| Review checks | Reject a marker that leaves the label's first line, a row that ignores its list's density, dividers with rounded corners, an inset in a flush list, or list styling that leaks into rows nested in a slot. | +| Governing rules | `spec:AST-082` FR3, FR4, FR7; `spec:AST-093` FR1–FR5; `component:swift/Item`; the Swift component API, slots, theme environment, and accessibility records under `packages/native/specs/swift/library/`. | + +This table is a review projection; the body below is authoritative. + +## Intent + +ListItem on Apple platforms is a row that belongs to a list: it draws as +`Item` and takes the list's density, marker, dividers, and edge, so every row +of a list matches without the app repeating them. + +## 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** + +- Reading the list around it and handing `Item` its density, marker, inline + inset, and corners. +- Drawing the marker: a dot, a ring, or the row's number. + +**Does not own / non-goals** + +- The row's layout, words, states, and taps — owned by `component:swift/Item`. +- Numbering, dividers between rows, and the list's edge — owned by the list + that holds the rows. +- Swipe actions — web-only for now (`component:swift/Item` DEC-1). + +## Public concepts + +| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | +| ------- | ----------------------- | ---------------------------------------------------------------- | ----------------------------------------- | ------- | -------------------------- | ------------ | ---------------------- | +| marker | none, dot, ring, number | the list's marker for this row, from its list style | inside a list with a marker style | none | `component:ListItem` | experimental | none | +| list | inside a list; alone | inside a list the row takes its look; alone it is a plain `Item` | always | alone | `component:swift/ListItem` | experimental | none | + +## Behavioral and layout contract + +| ID | Candidate invariant | Basis | Draft review state | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------ | +| FR1 | The row MUST draw as `component:swift/Item` with its own label, description, alignment, link, tap, and states, and with the density of the list around it; alone it MUST draw exactly as that `Item`. | core `ListItem` | verify | +| FR2 | In a list with a marker style, the row MUST draw a marker in a column `space.s4` wide, grown with Dynamic Type and the same on every row, before its start content: a 6-point dot filled with `color.textPrimary` for `disc`, a 6-point ring stroked 1 point in `color.textPrimary` for `circle`, or its number and a period in the body type with tabular figures for `decimal`, ending at the column's end so every period in the list lines up, as core's numbered markers do. A number wider than the column reaches into the row's inline inset; a number wider than both widens the column on every row of the list alike, so no number moves its own row's words, overlaps them, or is cut, and every row keeps the list's text edge. The marker's center sits on the center of the label's first line box for every alignment. | core `ListItem` markers | verify | +| FR3 | In a list with dividers the row MUST draw its background with square corners; in a flush list it MUST have no inline inset, so its content and background reach the list's edges. | core `ListItem` `withDivider`, `edgeFlush` | verify | +| FR4 | The list's treatment, look, and numbering MUST apply to this row only, not to rows nested in its start or end content. | core `ListContext` scope | verify | +| FR5 | Both initializers MUST build the same `ListItemProps`. Slots are generic `@ViewBuilder` closures with empty defaults; a single trailing closure is the start content and `onClick` comes last. Base props come through `ListItemProps`. A ListItem node sent by a logic core draws its start and end content nodes in those slots (`spec:AST-082` FR19). | Swift component API A2–A4, A7; slots S3 | verify | + +### Allowed variation + +- **AV1 — Marker drawing.** The dot and ring are SwiftUI shapes at core's size, + not CSS boxes. + +### Representative states + +| State | Required invariant | Allowed variation | +| ----------------------------- | --------------------------------------------- | ----------------- | +| alone | the same as `Item` | none | +| in a list: each density | the list's padding | none | +| disc, circle, decimal markers | on the label's first line for every alignment | AV1 | +| dividers; flush | square corners; no inline inset | none | +| light and dark | each theme's colors | none | + +### Transformation and precedence order + +- **ORD1 — Density.** The list's density → `balanced`. + +### Performance and resources + +- **PR1 — One extra measure.** Inside a list with markers, a centre-aligned row + measures its words to place the marker on the first line; other rows measure + nothing. +- **PR2 — Numbering.** Inside a list, the row reports its identity to the list + once, and the list numbers its rows in the order they appear. A numbered row + also reports its number's width, from which the list sizes one shared column. + +## Accessibility contract + +- **AR1 — One row.** The row is the `Item`'s one element; a number marker is + read first, as part of the row ("3. Mix the batter"); a dot or ring is hidden + (Swift accessibility X5). +- **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 | +| ---------------- | -------------------------------------------------- | ------------------------ | -------------- | ------------------ | +| Row and marker | Apple Human Interface Guidelines, Lists and tables | prescribed by core | supporting | FR1–FR3 | + +The component implements design requirements without copying their rationale. + +Platform control: none of its own; the row is `component:swift/Item`, which +names the SwiftUI controls it uses (`spec:AST-093` FR2). Guideline pages: +Lists and tables, and Accessibility in the Apple Human Interface Guidelines. + +Theme values read: `space.s4`, `color.textPrimary`, the body type, and through +`Item` its tokens. + +## Family and system relationships + +- `component:swift/Item` owns the row; this record states only what a list + row adds. +- `component:ListItem` owns the props and markers. + +## Verification map + +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| FR1 | `ListItemTests.swift` alone and density renders | alone; compact and spacious lists | a row that differs from `Item` alone or ignores density fails | `audit:swift/ListItem/row` | +| FR2, AR1 | `ListItemTests.swift` number, numbering, column, period-alignment, shared-column, first-line, largest-text, two-theme, and read-aloud tests | decimal; numbering from 3; "1." and "10."; a number wider than the column and inset; "9." at the largest text size; disc with each alignment and at the largest text size; neutral and butter | a missing or wrong number, periods that do not line up, a column that widens for a number that fits the inset or does not widen for every row, a marker off the first line, a color of its own, or a read dot fails | `audit:swift/ListItem/marker` | +| FR3 | `ListItemTests.swift` flush and divider renders | padded and flush; with and without dividers | an inset in a flush list or a rounded corner between dividers fails | `audit:swift/ListItem/edge` | +| FR4 | `ListItemTests.swift` nested-row test | a row in a row's end content | a nested row with the list's marker or inset fails | `audit:swift/ListItem/scope` | +| FR5 | `ListItemTests.swift` initializer and wire-slot tests; `contracts.test.ts` | both initializers; a wire node with start content | differing props, or a wire node whose slots do not draw, fail | `audit:swift/ListItem/api` | + +## Decision log + +No component-local decision is recorded. + +## Open questions + +None. + +## 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/README.md b/packages/native/apple/README.md index 7ad616aa7f925..2596830b880c1 100644 --- a/packages/native/apple/README.md +++ b/packages/native/apple/README.md @@ -29,6 +29,7 @@ apple/ │ │ ├── Icon/ # Icon │ │ ├── Avatar/ # Avatar │ │ ├── Item/ # Item +│ │ ├── List/ # ListItem │ │ ├── Text/ # Text │ │ ├── Heading/ # Heading │ │ ├── Button/ # Button diff --git a/packages/native/apple/Sources/Astryx/Item/Item.swift b/packages/native/apple/Sources/Astryx/Item/Item.swift index 2813be44ab257..5ee007d8fbd2e 100644 --- a/packages/native/apple/Sources/Astryx/Item/Item.swift +++ b/packages/native/apple/Sources/Astryx/Item/Item.swift @@ -29,6 +29,8 @@ public struct Item: View { let startContent: StartContent let endContent: EndContent @Environment(\.astryx) private var astryx + @Environment(\.astryxItemListTreatment) private var listTreatment + @State private var wordsHeight: CGFloat = 0 /// A row from its labeled props. A single trailing closure is the start /// content; set `onClick` by label or as a trailing `onClick:` closure. @@ -131,29 +133,31 @@ public struct Item: View { /// A link or button row's fill and its press, painted by the button style /// so the press sits above a selected or highlighted fill, as core's does. private var pressStyle: ItemPressStyle { - ItemPressStyle(radius: astryx.tokens.radius.element, fill: background, pressed: astryx.tokens.color.overlayPressed) + ItemPressStyle(radius: cornerRadius, fill: background, pressed: astryx.tokens.color.overlayPressed) } /// The row's drawing; a tappable row is at least `minHeight` tall. A static /// row paints its own fill; a link or button row's style paints it. private func row(minHeight: CGFloat?, paintsFill: Bool) -> some View { HStack(alignment: rowAlignment, spacing: astryx.tokens.space.s2) { - slot(marker) - slot(startContent) + markerSlot + // A list's treatment is for this row only, not for rows nested in its slots. + slot(startContent.modifier(ItemSlotOutsideList())) // A disabled row dims its words and end content, as core does; the // marker and start content keep their own look (a checkbox shows its // own disabled state). words .frame(maxWidth: .infinity, alignment: .leading) + .modifier(ItemWordsHeight(isMeasured: measuresWords, height: $wordsHeight)) .opacity(isDisabled ? 0.5 : 1) - slot(endContent) + slot(endContent.modifier(ItemSlotOutsideList())) .opacity(isDisabled ? 0.5 : 1) } .padding(.horizontal, inlinePadding) .padding(.vertical, blockPadding) .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .leading) .background( - RoundedRectangle(cornerRadius: astryx.tokens.radius.element, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(paintsFill ? background ?? .clear : .clear) ) .contentShape(Rectangle()) @@ -170,6 +174,43 @@ public struct Item: View { } } + /// The marker. Inside a list it centers on the label's first line, + /// whatever the row's alignment, as core's list markers do. + @ViewBuilder private var markerSlot: some View { + if listTreatment?.markerOnFirstLine == true { + let center = firstLineBoxHeight / 2 + switch resolvedAlign { + case .firstLine: + slot(marker) + case .start: + marker.alignmentGuide(.top) { d in d.height / 2 - center } + case .center: + let words = wordsHeight + marker.alignmentGuide(VerticalAlignment.center) { d in words / 2 - center + d.height / 2 } + } + } else { + slot(marker) + } + } + + /// Whether the row measures its words: only a centre-aligned row in a list + /// with markers, which places its marker from the words' height. + private var measuresWords: Bool { + listTreatment?.markerOnFirstLine == true && resolvedAlign == .center + } + + /// The height of the label's first line box: the theme's leading at the + /// current text size. + private var firstLineBoxHeight: CGFloat { + astryx.tokens.font.body.leading * astryx.tokens.font.body.size * astryx.textScale + } + + /// The row's corner radius: the theme's element radius, or square inside a + /// list with dividers. + private var cornerRadius: CGFloat { + listTreatment?.squareCorners == true ? 0 : astryx.tokens.radius.element + } + @ViewBuilder private var words: some View { let label = SwiftUI.Text(props.label) .font(astryx.font(size: "text-body-size", 14, weight: "text-body-weight")) @@ -237,9 +278,15 @@ public struct Item: View { } } - /// Inline padding: spacing 2, or 3 when spacious. + /// Inline padding: the list's own inset when a list sets one, else core's + /// for the density. var inlinePadding: CGFloat { - (props.density ?? .balanced) == .spacious ? astryx.tokens.space.s3 : astryx.tokens.space.s2 + listTreatment?.inlineInset ?? Item.inlinePadding(density: props.density, tokens: astryx.tokens) + } + + /// Core's inline padding for a density: spacing 2, or 3 when spacious. + static func inlinePadding(density: ItemDensity?, tokens: Tokens) -> CGFloat { + (density ?? .balanced) == .spacious ? tokens.space.s3 : tokens.space.s2 } /// The space core's line height adds to the font's own line, grown with @@ -352,3 +399,48 @@ private struct ItemAccessibility: ViewModifier { content.accessibilityAddTraits(Item.addedTraits(isSelected: isSelected)) } } + +/// Measures the words' height only when the row needs it. +private struct ItemWordsHeight: ViewModifier { + let isMeasured: Bool + @Binding var height: CGFloat + + func body(content: Content) -> some View { + if isMeasured { + content.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height = $0 } + } else { + content + } + } +} + +/// A row's slots sit outside its list: a row nested in a slot takes neither +/// the list's treatment, its look, nor a number from it. +private struct ItemSlotOutsideList: ViewModifier { + func body(content: Content) -> some View { + content + .environment(\.astryxItemListTreatment, nil) + .environment(\.astryxListContext, nil) + .environment(\.astryxListNumbers, nil) + } +} + +/// How a list draws the rows it holds: the marker on the first line, its own +/// inline inset (zero for a flush list), and square corners between dividers. +struct ItemListTreatment: Equatable { + var markerOnFirstLine: Bool + var inlineInset: CGFloat? + var squareCorners: Bool +} + +private struct ItemListTreatmentKey: EnvironmentKey { + static let defaultValue: ItemListTreatment? = nil +} + +extension EnvironmentValues { + /// Set by `ListItem` on the `Item` it draws; `nil` for an `Item` alone. + var astryxItemListTreatment: ItemListTreatment? { + get { self[ItemListTreatmentKey.self] } + set { self[ItemListTreatmentKey.self] = newValue } + } +} diff --git a/packages/native/apple/Sources/Astryx/List/ListItem.swift b/packages/native/apple/Sources/Astryx/List/ListItem.swift new file mode 100644 index 0000000000000..f4804f2eec488 --- /dev/null +++ b/packages/native/apple/Sources/Astryx/List/ListItem.swift @@ -0,0 +1,255 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import AstryxNative +import SwiftUI + +/// A row of a `List` (core `ListItem`): an `Item` that takes its density, +/// marker, dividers, and edge from the list that holds it. Outside a list it +/// draws as a plain `Item`. +/// +/// ```swift +/// ListItem("Preheat the oven", description: "To 180 degrees") +/// ListItem("Wi-Fi", description: "Home") { +/// Icon(.check) +/// } onClick: { openWiFi() } +/// ``` +public struct ListItem: View { + /// The contract the view draws, the same for both initializers. + public let props: ListItemProps + let startContent: StartContent + let endContent: EndContent + @Environment(\.astryx) private var astryx + @Environment(\.astryxListContext) private var list + @Environment(\.astryxListNumbers) private var numbers + /// This row's identity among its list's rows, for its number. + @State private var rowID = UUID() + + /// A list row from its labeled props. A single trailing closure is the start + /// content; set `onClick` by label or as a trailing `onClick:` closure. + /// - Parameters: + /// - label: The row's main words (core `label`). + /// - description: Supporting words under the label (core `description`). + /// - align: How the start and end content line up with the words; `nil` is `center` (core `align`). + /// - href: A URL the system opens when the row is tapped; the row becomes a link (core `href`). + /// - isDisabled: Dims the row; a button or link row stays reachable and does nothing (core `isDisabled`). + /// - isSelected: Draws the row as selected and reads it as selected (core `isSelected`). + /// - startContent: Content before the words, such as an `Icon` or an `Avatar` (core `startContent`). + /// - endContent: Content after the words (core `endContent`). + /// - onClick: Runs when the row is tapped; the row becomes a button when it has no `href` (core `onClick`). + public init( + _ label: String, + description: String? = nil, + align: ListItemAlign? = nil, + href: String? = nil, + isDisabled: Bool? = nil, + isSelected: Bool? = nil, + @ViewBuilder startContent: () -> StartContent = { EmptyView() }, + @ViewBuilder endContent: () -> EndContent = { EmptyView() }, + onClick: (() -> Void)? = nil + ) { + self.init( + ListItemProps( + label: label, description: description, onClick: onClick, href: href, align: align, + isDisabled: isDisabled, isSelected: isSelected), + startContent: startContent, endContent: endContent) + } + + /// A list row from its generated contract, for generated and wire-driven callers. + public init( + _ props: ListItemProps, + @ViewBuilder startContent: () -> StartContent = { EmptyView() }, + @ViewBuilder endContent: () -> EndContent = { EmptyView() } + ) { + self.props = props + self.startContent = startContent() + self.endContent = endContent() + } + + public var body: some View { + Item(itemProps, startContent: { startContent }, endContent: { endContent }, marker: { marker }) + .environment(\.astryxItemListTreatment, treatment) + // A row in a list announces itself so the list can number its rows in + // order, as core's CSS counter counts list items. + .preference(key: ListRowsKey.self, value: list == nil ? [] : [rowID]) + } + + /// The `Item` this row draws: its own props, with the list's density. + var itemProps: ItemProps { + ItemProps( + label: props.label, description: props.description, + align: props.align.flatMap { ItemAlign(rawValue: $0.rawValue) }, + density: list?.density, onClick: props.onClick, href: props.href, isSelected: props.isSelected, + isDisabled: props.isDisabled, base: props.base) + } + + /// How the list draws this row: the marker on the first line, no inline + /// inset in a flush list, and square corners between dividers. + var treatment: ItemListTreatment? { + guard let list else { return nil } + return ItemListTreatment( + markerOnFirstLine: list.marker != nil, + inlineInset: list.isFlush ? 0 : nil, + squareCorners: list.hasDividers) + } + + /// The list's marker for this row: a dot, a ring, or its number. + @ViewBuilder var marker: some View { + switch list?.marker { + case .disc: + Circle() + .fill(astryx.tokens.color.textPrimary) + .frame(width: ListItem.markerDot, height: ListItem.markerDot) + .frame(width: ListItem.markerColumn(astryx)) + .accessibilityHidden(!ListItem.markerIsRead(.disc)) + case .circle: + Circle() + .strokeBorder(astryx.tokens.color.textPrimary, lineWidth: 1) + .frame(width: ListItem.markerDot, height: ListItem.markerDot) + .frame(width: ListItem.markerColumn(astryx)) + .accessibilityHidden(!ListItem.markerIsRead(.circle)) + case .decimal: + ListMarkerNumber( + text: ListItem.number(ordinal: numbers?.number(for: rowID) ?? 1), row: rowID, numbers: numbers, + inset: rowInset) + case nil: + EmptyView() + } + } + + /// The row's inline inset, which a wide number may reach into: none in a + /// flush list, else core's padding for the list's density. + var rowInset: CGFloat { + guard let list else { return 0 } + return list.isFlush ? 0 : Item.inlinePadding(density: list.density, tokens: astryx.tokens) + } + + /// Whether VoiceOver reads a marker: a number is read as part of the row; + /// a dot or ring is decoration and hidden. + nonisolated static func markerIsRead(_ marker: ListContext.Marker) -> Bool { marker == .decimal } + + /// The marker column: core's `space.s4`, grown with Dynamic Type as the + /// number in it grows, and the same width on every row. + static func markerColumn(_ astryx: AstryxStyle) -> CGFloat { + (astryx.tokens.space.s4 * max(astryx.textScale, 1)).rounded() + } + + /// A number marker's column: the marker column, or wider when the list's + /// widest number does not fit it and the row's inline inset together, so + /// every row of the list widens alike and no number moves its own row's + /// words (core's numbered markers; `spec:AST-061` FR11). + nonisolated static func numberColumn(base: CGFloat, widest: CGFloat, inset: CGFloat) -> CGFloat { + max(base, widest - inset) + } + + /// Core's marker dot: 6 points. + nonisolated static var markerDot: CGFloat { 6 } + + /// A decimal marker's words: the row's number and a period, as core's + /// `counter(…) "."`. + nonisolated static func number(ordinal: Int) -> String { "\(ordinal)." } +} + +/// A decimal marker: the number and its period in the body type with tabular +/// figures, ending at the column's end so every period in the list lines up, +/// as core's numbered markers do. A number wider than the column reaches into +/// the row's inline inset; past that, the list's shared column widens. +struct ListMarkerNumber: View { + let text: String + var row: UUID? = nil + var numbers: ListNumbers? = nil + var inset: CGFloat = 0 + @Environment(\.astryx) private var astryx + @State private var natural: CGFloat = 0 + + var body: some View { + let column = ListItem.numberColumn( + base: ListItem.markerColumn(astryx), widest: numbers?.widest ?? natural, inset: inset) + SwiftUI.Text(text) + .font(astryx.font(size: "text-body-size", 14, weight: "text-body-weight")) + .monospacedDigit() + .foregroundStyle(astryx.tokens.color.textPrimary) + .fixedSize() + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + natural = width + if let row { numbers?.report(row, width: width) } + } + .frame(width: column, alignment: .trailing) + } +} + +/// The list a `ListItem` sits in, as `List` describes it to its rows. +struct ListContext: Equatable { + /// The kind of marker each row draws. + enum Marker: Equatable { case disc, circle, decimal } + + var density: ItemDensity? + var hasDividers: Bool + var isFlush: Bool + var marker: Marker? +} + +private struct ListContextKey: EnvironmentKey { + static let defaultValue: ListContext? = nil +} + +/// The numbers a list gives its rows, by row, in the order the rows appear. +@MainActor @Observable +final class ListNumbers { + private(set) var numbers: [UUID: Int] = [:] + + nonisolated init() {} + + /// The widest number any row of the list draws, for the shared column. + private(set) var widest: CGFloat = 0 + private var widths: [UUID: CGFloat] = [:] + + /// Numbers the rows in order, the first one `start`, and forgets the widths + /// of rows that left the list. + func assign(_ rows: [UUID], start: Int) { + let numbered = Dictionary(rows.enumerated().map { ($0.element, start + $0.offset) }, uniquingKeysWith: { first, _ in first }) + if numbered != numbers { numbers = numbered } + let kept = widths.filter { numbered[$0.key] != nil } + if kept.count != widths.count { + widths = kept + updateWidest() + } + } + + /// Records the width a row's number draws at. + func report(_ row: UUID, width: CGFloat) { + guard widths[row] != width else { return } + widths[row] = width + updateWidest() + } + + private func updateWidest() { + let next = widths.values.max() ?? 0 + if next != widest { widest = next } + } + + func number(for row: UUID) -> Int? { numbers[row] } +} + +/// The rows of a list, in order: each `ListItem` adds its own identity. +struct ListRowsKey: PreferenceKey { + static let defaultValue: [UUID] = [] + static func reduce(value: inout [UUID], nextValue: () -> [UUID]) { value.append(contentsOf: nextValue()) } +} + +private struct ListNumbersKey: EnvironmentKey { + static let defaultValue: ListNumbers? = nil +} + +extension EnvironmentValues { + /// The list around a row; `nil` outside a list. + var astryxListContext: ListContext? { + get { self[ListContextKey.self] } + set { self[ListContextKey.self] = newValue } + } + + /// The numbers of the rows of the list around a row. + var astryxListNumbers: ListNumbers? { + get { self[ListNumbersKey.self] } + set { self[ListNumbersKey.self] = newValue } + } +} diff --git a/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift b/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift index ffa26ea39b64e..9949292b3014b 100644 --- a/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift +++ b/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift @@ -33,6 +33,12 @@ public struct AstryxNodeView: View { } marker: { AstryxNodeList(nodes: item.props.marker ?? [], send: send) } + case .listItem(let row): + ListItem(row.viewProps(send: send)) { + AstryxNodeList(nodes: row.props.startContent ?? [], send: send) + } endContent: { + AstryxNodeList(nodes: row.props.endContent ?? [], send: send) + } default: AstryxUnsupportedNode(component: Self.componentName(node)) } } @@ -50,6 +56,7 @@ public struct AstryxNodeView: View { case .hStack: "HStack" case .icon: "Icon" case .item: "Item" + case .listItem: "ListItem" case .progressBar: "ProgressBar" case .selector: "Selector" case .switch: "Switch" diff --git a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift index eeddd55bc843d..4fa1dc19ec978 100644 --- a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift +++ b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift @@ -17,6 +17,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case hStack(HStackNode) case icon(IconNode) case item(ItemNode) + case listItem(ListItemNode) case progressBar(ProgressBarNode) case selector(SelectorNode) case `switch`(SwitchNode) @@ -38,6 +39,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case .hStack(let node): return node.key case .icon(let node): return node.key case .item(let node): return node.key + case .listItem(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 @@ -67,6 +69,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case "HStack": self = .hStack(try HStackNode(from: decoder)) case "Icon": self = .icon(try IconNode(from: decoder)) case "Item": self = .item(try ItemNode(from: decoder)) + case "ListItem": self = .listItem(try ListItemNode(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/ListItem.swift b/packages/native/apple/Sources/AstryxNative/Generated/ListItem.swift new file mode 100644 index 0000000000000..7cc82493e1f05 --- /dev/null +++ b/packages/native/apple/Sources/AstryxNative/Generated/ListItem.swift @@ -0,0 +1,122 @@ +// Generated by @astryxdesign/native. Do not edit. +public enum ListItemAlign: String, CaseIterable, Codable, Sendable { + case center = "center" + case start = "start" + case firstLine = "first-line" +} +#if os(iOS) +public struct ListItemProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var label: String + public var description: String? + public var onClick: (() -> Void)? + public var href: String? + public var align: ListItemAlign? + public var isDisabled: Bool? + public var isSelected: Bool? + public init(label: String, description: String? = nil, onClick: (() -> Void)? = nil, href: String? = nil, align: ListItemAlign? = nil, isDisabled: Bool? = nil, isSelected: Bool? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.label = label + self.description = description + self.onClick = onClick + self.href = href + self.align = align + self.isDisabled = isDisabled + self.isSelected = isSelected + self.base = base + } +} +public struct ListItemNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var label: String + public var description: String? + public var startContent: [AstryxNode]? + public var endContent: [AstryxNode]? + public var href: String? + public var align: ListItemAlign? + public var isDisabled: Bool? + public var isSelected: Bool? + } + 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 ListItem view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> ListItemProps { + let key = self.key + return ListItemProps( + label: props.label, + description: props.description, + onClick: listens("onClick") ? { send(AstryxAction(key: key, component: "ListItem", event: "onClick", args: [])) } : nil, + href: props.href, + align: props.align, + isDisabled: props.isDisabled, + isSelected: props.isSelected, + base: base ?? AstryxBaseProps() + ) + } +} +#endif +#if os(macOS) +public struct ListItemProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var label: String + public var description: String? + public var onClick: (() -> Void)? + public var href: String? + public var align: ListItemAlign? + public var isDisabled: Bool? + public var isSelected: Bool? + public init(label: String, description: String? = nil, onClick: (() -> Void)? = nil, href: String? = nil, align: ListItemAlign? = nil, isDisabled: Bool? = nil, isSelected: Bool? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.label = label + self.description = description + self.onClick = onClick + self.href = href + self.align = align + self.isDisabled = isDisabled + self.isSelected = isSelected + self.base = base + } +} +public struct ListItemNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var label: String + public var description: String? + public var startContent: [AstryxNode]? + public var endContent: [AstryxNode]? + public var href: String? + public var align: ListItemAlign? + public var isDisabled: Bool? + public var isSelected: Bool? + } + 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 ListItem view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> ListItemProps { + let key = self.key + return ListItemProps( + label: props.label, + description: props.description, + onClick: listens("onClick") ? { send(AstryxAction(key: key, component: "ListItem", event: "onClick", args: [])) } : nil, + href: props.href, + align: props.align, + isDisabled: props.isDisabled, + isSelected: props.isSelected, + base: base ?? AstryxBaseProps() + ) + } +} +#endif diff --git a/packages/native/apple/Tests/AstryxTests/ListItemTests.swift b/packages/native/apple/Tests/AstryxTests/ListItemTests.swift new file mode 100644 index 0000000000000..bd1cb39f43371 --- /dev/null +++ b/packages/native/apple/Tests/AstryxTests/ListItemTests.swift @@ -0,0 +1,234 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import AstryxNative +import SwiftUI +import Testing +@testable import Astryx + +// Swift ListItem spec (component:swift/ListItem): an Item that takes its +// density, marker, dividers, and edge from its list. + +private let red = [255, 0, 0, 255] +@MainActor private func swatch() -> some View { Color(red: 1, green: 0, blue: 0).frame(width: 10, height: 10) } + +@MainActor +private func inList(_ view: V, _ context: ListContext) -> some View { + view.environment(\.astryxListContext, context).frame(width: 240) +} + +@MainActor +@Test func listItemInitializersBuildTheSameProps() { + let labeled = ListItem("Ada", description: "Mathematician", align: .start, href: "https://example.com", isDisabled: true, isSelected: true).props + let fromProps = ListItem(ListItemProps(label: "Ada", description: "Mathematician", href: "https://example.com", align: .start, isDisabled: true, isSelected: true)).props + #expect(labeled.label == fromProps.label) + #expect(labeled.description == fromProps.description) + #expect(labeled.align == fromProps.align) + #expect(labeled.href == fromProps.href) + #expect(labeled.isDisabled == fromProps.isDisabled) + #expect(labeled.isSelected == fromProps.isSelected) + #expect(labeled.base == fromProps.base) +} + +@MainActor +@Test func aListItemAloneDrawsAsAnItem() { + let row = Pixels(ListItem("Ada", description: "Mathematician").frame(width: 240)) + let item = Pixels(Item("Ada", description: "Mathematician").frame(width: 240)) + #expect(row.width == item.width && row.height == item.height) +} + +@MainActor +@Test func aListItemTakesItsListsDensity() { + let compact = Pixels(inList(ListItem("Ada"), ListContext(density: .compact, hasDividers: false, isFlush: false))) + let spacious = Pixels(inList(ListItem("Ada"), ListContext(density: .spacious, hasDividers: false, isFlush: false))) + let tokens = Theme.default.light.space + #expect(spacious.height - compact.height == Int(2 * (tokens.s3 - tokens.s1))) +} + +@MainActor +@Test func aFlushListDropsTheRowsInlineInset() { + let padded = Pixels(inList(ListItem("Ada", startContent: { swatch() }), ListContext(hasDividers: false, isFlush: false))) + let flush = Pixels(inList(ListItem("Ada", startContent: { swatch() }), ListContext(hasDividers: false, isFlush: true))) + #expect(!close(padded.at(1, padded.height / 2), red)) + #expect(close(flush.at(1, flush.height / 2), red)) +} + +@MainActor +@Test func rowsBetweenDividersHaveSquareCorners() { + let accent = Theme.default.light.color.accentMuted + let rounded = Pixels(inList(ListItem("Ada", isSelected: true), ListContext(hasDividers: false, isFlush: false))) + let square = Pixels(inList(ListItem("Ada", isSelected: true), ListContext(hasDividers: true, isFlush: false))) + #expect(rounded.at(0, 0)[3] < 8) + #expect(close(square.at(0, 0), plate(accent), within: 6)) +} + +@MainActor +@Test func aDecimalListNumbersItsRows() { + #expect(ListItem.number(ordinal: 3) == "3.") + let numbers = ListNumbers() + let first = UUID(), second = UUID() + numbers.assign([first, second], start: 3) + #expect(numbers.number(for: first) == 3 && numbers.number(for: second) == 4) + let numbered = Pixels(inList(ListItem("MMMMMM"), ListContext(hasDividers: false, isFlush: false, marker: .decimal))) + let plain = Pixels(inList(ListItem("MMMMMM"), ListContext(hasDividers: false, isFlush: false))) + // The number takes a marker column, so a run of blank columns opens + // between it and the words; without a marker the words start at once. + func longestBlankRun(_ p: Pixels) -> Int { + var longest = 0, run = 0 + for x in 8..<80 { + let blank = (0..= 5) + #expect(longestBlankRun(plain) < 5) +} + +@MainActor +@Test func aMarkerSitsOnTheLabelsFirstLineWhateverTheAlignment() { + func dotCenter(_ p: Pixels) -> Double { + let rows = (0.. 200 } } + return Double(rows.first! + rows.last!) / 2 + } + func labelCenter(_ p: Pixels) -> Double { + let ink = (0.. 128 } } + let top = ink.firstIndex(of: true)! + var bottom = top + while bottom + 1 < ink.count && ink[bottom + 1] { bottom += 1 } + return Double(top + bottom) / 2 + } + let context = ListContext(hasDividers: false, isFlush: false, marker: .disc) + for align in [ListItemAlign.center, .start, .firstLine] { + let row = Pixels(inList(ListItem("Ada", description: "Mathematician", align: align), context)) + #expect(abs(dotCenter(row) - labelCenter(row)) <= 1.5, "\(align)") + } +} + +/// A translucent theme color as the renderer stores it, premultiplied. +private func plate(_ color: Color) -> [Int] { + let c = rgba(color) + return [c[0] * c[3] / 255, c[1] * c[3] / 255, c[2] * c[3] / 255, c[3]] +} + +@MainActor +@Test func aWideNumberKeepsTheListsTextEdge() { + // The column is space.s4 wide while a number fits it and the row's inset, + // so labels line up; "10." reaches into a balanced row's 8-point inset. + let s4 = Int(Theme.default.light.space.s4) + #expect(Pixels(ListMarkerNumber(text: "1.", inset: 8)).width == s4) + #expect(Pixels(ListMarkerNumber(text: "10.", inset: 8)).width == s4) +} + +@MainActor +@Test func numbersEndAtTheColumnsEndSoPeriodsLineUp() { + // Core's numbered markers end where the marker ends, with tabular figures, + // so "1." and "10." put their periods at the same place. + func rightmostInk(_ p: Pixels) -> Int { + (0.. 64 } } ?? -1 + } + let one = Pixels(ListMarkerNumber(text: "1.", inset: 8)) + let ten = Pixels(ListMarkerNumber(text: "10.", inset: 8)) + #expect(rightmostInk(one) >= 0) + #expect(abs(rightmostInk(one) - rightmostInk(ten)) <= 1) + // The number sits at the column's end, not its start. + #expect(rightmostInk(one) >= one.width - 3) +} + +@Test func aNumberWiderThanTheColumnAndInsetWidensTheSharedColumn() { + typealias Row = ListItem + #expect(Row.numberColumn(base: 16, widest: 20, inset: 8) == 16) + #expect(Row.numberColumn(base: 16, widest: 40, inset: 8) == 32) + #expect(Row.numberColumn(base: 16, widest: 30, inset: 0) == 30) +} + +@MainActor +@Test func aListSharesItsWidestNumber() { + let numbers = ListNumbers() + let first = UUID(), second = UUID() + numbers.assign([first, second], start: 1) + numbers.report(first, width: 20) + numbers.report(second, width: 30) + #expect(numbers.widest == 30) + // A row that leaves the list no longer widens it. + numbers.assign([first], start: 1) + #expect(numbers.widest == 20) +} + +@Test func onlyANumberMarkerIsRead() { + #expect(ListItem.markerIsRead(.decimal)) + #expect(!ListItem.markerIsRead(.disc)) + #expect(!ListItem.markerIsRead(.circle)) +} + +@MainActor +@Test func aMarkerStaysOnTheFirstLineAtTheLargestTextSize() { + let context = ListContext(hasDividers: false, isFlush: false, marker: .disc) + let large = Pixels(inList(ListItem("Ada", description: "Mathematician"), context)) + let largest = Pixels(inList(ListItem("Ada", description: "Mathematician"), context), typeSize: .accessibility5) + #expect(largest.height > large.height) + // The dot sits in the top part of the row, by the first line, not its middle. + // The marker column grows with text, so look across it. + let rows = (0.. 200 } } + #expect(!rows.isEmpty && rows.last! < largest.height / 2) +} + +@MainActor +@Test func markersDrawInEachThemesPrimaryText() { + for theme in [Theme.neutral, Theme.butter] { + let drawn = Pixels(inList(ListItem("Ada"), ListContext(hasDividers: false, isFlush: false, marker: .disc)), theme: theme) + let ink = (0.. 128 } } ?? -1 } + #expect(start(nested, 140) - 140 == start(alone, 0)) +} + +@MainActor +@Test func aNumberFitsItsColumnAtTheLargestTextSize() { + // The column grows with the number, so a one-digit number never runs into + // its label at the largest text size. + let column = Pixels(ListMarkerNumber(text: "9."), typeSize: .accessibility5).width + let drawn = Pixels( + ZStack(alignment: .leading) { + Color.clear.frame(width: 200, height: 80) + ListMarkerNumber(text: "9.") + }, + typeSize: .accessibility5) + let rightmostInk = (0..<200).last { x in (0.. 64 } } ?? 0 + #expect(column > Int(Theme.default.light.space.s4)) + #expect(rightmostInk < column) +} + +// AST-082 FR19: a ListItem node draws as a ListItem, with its content slots. +@MainActor +@Test func aListItemNodeDrawsItsContentSlots() { + let json = """ + {"component":"ListItem","key":"r","props":{"label":"Ada Lovelace", + "startContent":[{"component":"Avatar","key":"a","props":{"name":"Ada Lovelace","size":"sm"}}]}} + """ + guard let node = try? JSONDecoder().decode(AstryxNode.self, from: Data(json.utf8)) else { + Issue.record("the row did not decode") + return + } + #expect(AstryxNodeView.componentName(node) == "ListItem") + let wired = Pixels(AstryxNodeView(node) { _ in }.frame(width: 240)) + let direct = Pixels(ListItem("Ada Lovelace") { Avatar("Ada Lovelace", size: .sm) }.frame(width: 240)) + let bare = Pixels(ListItem("Ada Lovelace").frame(width: 240)) + #expect(wired.width == direct.width && wired.height == direct.height) + // The slot draws: the leading 40 points hold the avatar, which a bare row + // leaves empty. + var leadingDiffers = false + for y in 0..Props`; Banner has no generated +- Avatar, AvatarStatusDot, Button, Card, Heading, Icon, Item, ListItem, Text, TextInput, and Thumbnail take their generated `Props`; Banner has no generated contract. -- `AstryxNodeView` draws Avatar (with its status dot), AvatarStatusDot, Button, Card, Heading, Icon, Item, Text, TextInput, and Thumbnail wire nodes. Any other +- `AstryxNodeView` draws Avatar (with its status dot), AvatarStatusDot, Button, Card, Heading, Icon, Item, ListItem, Text, TextInput, and Thumbnail wire nodes. Any other node, HStack and VStack included, renders in its place as unsupported, and its siblings still render, until the logic host draws stacks (A9). - The generated iOS `SwitchProps` still declares `size`, and `HStackProps` and diff --git a/packages/native/src/components/ListItem.ts b/packages/native/src/components/ListItem.ts new file mode 100644 index 0000000000000..36c8ac02c44f1 --- /dev/null +++ b/packages/native/src/components/ListItem.ts @@ -0,0 +1,32 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file components/ListItem.ts + * @input None + * @output What @astryxdesign/native adds to the core ListItem contract + * @position Native additions read by fromComponentDoc together with core's + * ListItem documentation + */ + +import type {NativeAdditions} from '../contract/fromComponentDoc.js'; + +export const listItem: NativeAdditions = { + name: 'ListItem', + doc: {file: 'List/ListItem'}, + baseTarget: 'root', + slots: { + label: 'text', + description: 'text', + startContent: 'content', + endContent: 'content', + }, + callbacks: ['onClick'], + sameMeaning: {onClick: 'a press, as in Compose'}, + webOnly: { + target: 'HTML link target', + rel: 'HTML link relationship', + swipeActions: + "a native platform's swipe actions work only inside its own scrolling list, so a row elsewhere could not honor them", + swipeBehavior: 'applies only with swipeActions', + }, +}; diff --git a/packages/native/src/components/index.ts b/packages/native/src/components/index.ts index 99573b5101957..b554e23186089 100644 --- a/packages/native/src/components/index.ts +++ b/packages/native/src/components/index.ts @@ -19,6 +19,7 @@ import {heading} from './Heading.js'; import {hStack} from './HStack.js'; import {icon} from './Icon.js'; import {item} from './Item.js'; +import {listItem} from './ListItem.js'; import {progressBar} from './ProgressBar.js'; import {selector} from './Selector.js'; import {switchControl} from './Switch.js'; @@ -38,6 +39,7 @@ export const NATIVE_COMPONENTS: readonly NativeAdditions[] = [ hStack, icon, item, + listItem, progressBar, selector, switchControl, diff --git a/packages/native/src/contract/contracts.test.ts b/packages/native/src/contract/contracts.test.ts index 15f59493bec9e..c5d36a14e2e35 100644 --- a/packages/native/src/contract/contracts.test.ts +++ b/packages/native/src/contract/contracts.test.ts @@ -246,6 +246,27 @@ describe('native contracts', () => { ); }); + it('gives ListItem the row contract without density or markers, which come from its list', async () => { + const {contracts} = await buildContracts(loadCoreDoc); + const row = contracts.find(contract => contract.name === 'ListItem'); + expect(row).toBeDefined(); + expect(row!.core.label).toMatchObject({ + type: {kind: 'string'}, + optional: false, + }); + expect(row!.core.startContent?.type).toEqual({kind: 'content'}); + expect(Object.keys(row!.core)).not.toContain('density'); + expect(row!.webOnly.map(prop => prop.name)).toEqual( + expect.arrayContaining([ + 'swipeActions', + 'swipeBehavior', + 'target', + 'rel', + 'interactiveRef', + ]), + ); + }); + it('reports naming and portability errors', () => { const bad: ComponentContract = { name: 'Bad',