From f7468f0d89a28ec832e2169b291ee4ae1c517993 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 07:13:03 -0700 Subject: [PATCH] feat(native): Swift Thumbnail previews an image attachment with open and remove actions --- packages/native/apple/Package.swift | 2 +- packages/native/apple/README.md | 1 + .../Astryx/Localization/AstryxStrings.swift | 13 + .../Resources/af.lproj/Localizable.strings | 12 + .../Resources/ar.lproj/Localizable.strings | 12 + .../Resources/ca.lproj/Localizable.strings | 12 + .../Resources/cs.lproj/Localizable.strings | 12 + .../Resources/da.lproj/Localizable.strings | 12 + .../Resources/de.lproj/Localizable.strings | 12 + .../Resources/el.lproj/Localizable.strings | 12 + .../Resources/en.lproj/Localizable.strings | 12 + .../Resources/es.lproj/Localizable.strings | 12 + .../Resources/fi.lproj/Localizable.strings | 12 + .../Resources/fr.lproj/Localizable.strings | 12 + .../Resources/he.lproj/Localizable.strings | 12 + .../Resources/hu.lproj/Localizable.strings | 12 + .../Resources/it.lproj/Localizable.strings | 12 + .../Resources/ja.lproj/Localizable.strings | 12 + .../Resources/ko.lproj/Localizable.strings | 12 + .../Resources/nb.lproj/Localizable.strings | 12 + .../Resources/nl.lproj/Localizable.strings | 12 + .../Resources/pl.lproj/Localizable.strings | 12 + .../Resources/pt-BR.lproj/Localizable.strings | 12 + .../Resources/pt-PT.lproj/Localizable.strings | 12 + .../Resources/ro.lproj/Localizable.strings | 12 + .../Resources/ru.lproj/Localizable.strings | 12 + .../Resources/sr.lproj/Localizable.strings | 12 + .../Resources/sv.lproj/Localizable.strings | 12 + .../Resources/tr.lproj/Localizable.strings | 12 + .../Resources/uk.lproj/Localizable.strings | 12 + .../Resources/vi.lproj/Localizable.strings | 12 + .../zh-Hans.lproj/Localizable.strings | 12 + .../zh-Hant.lproj/Localizable.strings | 12 + .../Astryx/Thumbnail/Thumbnail.spec.md | 188 +++++++++ .../Sources/Astryx/Thumbnail/Thumbnail.swift | 357 ++++++++++++++++++ .../Astryx/Tooltip/TooltipPresenter.swift | 21 +- .../Sources/Astryx/Wire/AstryxNodeView.swift | 2 + .../AstryxNative/Generated/AstryxNode.swift | 3 + .../AstryxNative/Generated/Thumbnail.swift | 123 ++++++ .../Tests/AstryxTests/ThumbnailTests.swift | 204 ++++++++++ packages/native/scripts/swift-ios-strings.mjs | 4 + .../specs/swift/library/component-api.md | 6 +- .../specs/swift/library/tooltip.spec.md | 3 + packages/native/src/components/Thumbnail.ts | 21 ++ packages/native/src/components/index.ts | 2 + .../native/src/contract/contracts.test.ts | 20 + 46 files changed, 1322 insertions(+), 8 deletions(-) create mode 100644 packages/native/apple/Sources/Astryx/Thumbnail/Thumbnail.spec.md create mode 100644 packages/native/apple/Sources/Astryx/Thumbnail/Thumbnail.swift create mode 100644 packages/native/apple/Sources/AstryxNative/Generated/Thumbnail.swift create mode 100644 packages/native/apple/Tests/AstryxTests/ThumbnailTests.swift create mode 100644 packages/native/src/components/Thumbnail.ts diff --git a/packages/native/apple/Package.swift b/packages/native/apple/Package.swift index 60555733073cf..e5c1c044f266b 100644 --- a/packages/native/apple/Package.swift +++ b/packages/native/apple/Package.swift @@ -28,7 +28,7 @@ let package = Package( dependencies: ["AstryxNative"], path: "Sources/Astryx", // Each component's spec lives beside its view (AST-093 FR1). - exclude: ["Avatar/Avatar.spec.md", "Avatar/AvatarStatusDot.spec.md", "Icon/Icon.spec.md"], + exclude: ["Avatar/Avatar.spec.md", "Avatar/AvatarStatusDot.spec.md", "Icon/Icon.spec.md", "Thumbnail/Thumbnail.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 aada711627fb7..4a36fa92dc7bd 100644 --- a/packages/native/apple/README.md +++ b/packages/native/apple/README.md @@ -32,6 +32,7 @@ apple/ │ │ ├── Heading/ # Heading │ │ ├── Button/ # Button │ │ ├── TextInput/ # TextInput +│ │ ├── Thumbnail/ # Thumbnail, Thumbnail.spec.md │ │ ├── Card/ # Card │ │ └── Banner/ # Banner │ └── AstryxNative/ # contracts, wire, logic host (module AstryxNative) diff --git a/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift b/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift index d8b3a97d61641..d47b1c90c2c4a 100644 --- a/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift +++ b/packages/native/apple/Sources/Astryx/Localization/AstryxStrings.swift @@ -31,6 +31,19 @@ enum AstryxStrings { return result + rest } + /// What a busy view announces when it starts loading. + static var spinnerLoading: String { localized("@astryx.spinner.loading", "Loading") } + /// A thumbnail's name when it has no alt or label. + static var thumbnailFallbackName: String { localized("@astryx.thumbnail.fallbackName", "Thumbnail") } + /// The name of a thumbnail's open button. + static func thumbnailOpen(_ accessibleName: String) -> String { + fill(localized("@astryx.thumbnail.open", "Open {accessibleName}"), ["accessibleName": accessibleName]) + } + /// The name of a thumbnail's remove button. + static func thumbnailRemove(_ accessibleName: String) -> String { + fill(localized("@astryx.thumbnail.remove", "Remove {accessibleName}"), ["accessibleName": accessibleName]) + } + static func localized(_ key: String, _ english: String) -> String { resourceBundle.localizedString(forKey: key, value: english, table: nil) } diff --git a/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings index 12fe2829f682b..5c48cd3544f3d 100644 --- a/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/af.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Laai tans"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Laai tans"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Duimnael"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Maak {accessibleName} oop"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Verwyder {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings index 7ace10d499d13..28b13b1206713 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ar.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "جارٍ التحميل"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "جارٍ التحميل"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "صورة مصغّرة"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "فتح {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "إزالة {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings index 70271808f1fb4..1dacd534bf3bb 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ca.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "S’està carregant"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "S’està carregant"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatura"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Obre {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Elimina {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings index 0a2471f21b059..abb4edff0224b 100644 --- a/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/cs.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Načítání"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Načítání"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Náhled"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Otevřít {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Odebrat {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings index e98fba8c747f9..e62276d392285 100644 --- a/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/da.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Indlæser"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Indlæser"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniature"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Åbn {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Fjern {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings index cb126f783ceb9..2f2bfdfc1e958 100644 --- a/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/de.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Wird geladen"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Wird geladen"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniaturansicht"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "{accessibleName} öffnen"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "{accessibleName} entfernen"; diff --git a/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings index a7eded260a386..c7eb587c219c2 100644 --- a/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/el.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Φόρτωση"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Φόρτωση"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Μικρογραφία"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Άνοιγμα {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Αφαίρεση {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings index c0c9a7dd38bfa..266decb03cde1 100644 --- a/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/en.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Loading"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label; also announced once when a busy input or selector starts loading, in place of its decorative Spinner. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Loading"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Thumbnail"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Open {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Remove {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings index 5e429443e6145..b082e667f5813 100644 --- a/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/es.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Cargando"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Cargando"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatura"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Abrir {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Quitar {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings index 4d03fc3ceaac7..f4fd53b253707 100644 --- a/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/fi.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Ladataan"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Ladataan"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Pikkukuva"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Avaa {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Poista {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings index 1b52873feb357..004e6988a9352 100644 --- a/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/fr.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Chargement"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Chargement"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniature"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Ouvrir {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Supprimer {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings index 23631767faae0..f3fa2d3a5caad 100644 --- a/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/he.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "טוען"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "טוען"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "תמונה ממוזערת"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "פתח את {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "הסר את {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings index 93ad04d8e9e48..a502bf40a4054 100644 --- a/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/hu.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Betöltés"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Betöltés"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Bélyegkép"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "{accessibleName} megnyitása"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "{accessibleName} eltávolítása"; diff --git a/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings index c1f1a907c536c..fb9fd5369a770 100644 --- a/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/it.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Caricamento"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Caricamento"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatura"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Apri {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Rimuovi {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings index 210c487cc2a26..aa2ca32b09408 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ja.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "読み込み中"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "読み込み中"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "サムネイル"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "{accessibleName} を開く"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "{accessibleName} を削除"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings index d7f1cf09567b2..9864380f3d0ca 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ko.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "로드 중"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "로드 중"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "썸네일"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "{accessibleName} 열기"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "{accessibleName} 제거"; diff --git a/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings index 9b61659136b4e..bcc569b6cca31 100644 --- a/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/nb.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Laster"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Laster"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatyrbilde"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Åpne {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Fjern {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings index 30dc94017b2b5..72acfc89079c2 100644 --- a/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/nl.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Laden"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Laden"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatuur"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "{accessibleName} openen"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "{accessibleName} verwijderen"; diff --git a/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings index fd4f32ca983ab..0120d169028ed 100644 --- a/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/pl.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Ładowanie"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Ładowanie"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatura"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Otwórz {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Usuń {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings index 50d6b03dd06eb..d7e1d419150d7 100644 --- a/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/pt-BR.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Carregando"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Carregando"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatura"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Abrir {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Remover {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings index 3ebbd3226bb65..0840f436badf8 100644 --- a/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/pt-PT.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "A carregar"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "A carregar"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatura"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Abrir {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Remover {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings index 731b8ead1a280..46e66ab474441 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ro.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Se încarcă"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Se încarcă"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatură"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Deschide {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Elimină {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings index 57d3da1843ae3..7b3330669ae4a 100644 --- a/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/ru.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Загрузка"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Загрузка"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Миниатюра"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Открыть {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Удалить {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings index b66737be0d000..dd3913d4dc6bc 100644 --- a/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/sr.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Учитавање"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Учитавање"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Сличица"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Отвори {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Уклони {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings index 96865cd6587b5..ac0f9996b22fc 100644 --- a/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/sv.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Läser in"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Läser in"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Miniatyr"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Öppna {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Ta bort {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings index 4cfb6c0877992..6b40e57f2d385 100644 --- a/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/tr.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Yükleniyor"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Yükleniyor"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Küçük resim"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "{accessibleName} aç"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "{accessibleName} kaldır"; diff --git a/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings index eaf10a273ba39..ccf2ac2f95c50 100644 --- a/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/uk.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Завантаження"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Завантаження"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Мініатюра"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Відкрити {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Видалити {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings index 559c838e97401..d740a9480f1fe 100644 --- a/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/vi.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "Đang tải"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "Đang tải"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "Hình thu nhỏ"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "Mở {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "Xóa {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings index ab37bc74f97a1..f5eb737a7a385 100644 --- a/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/zh-Hans.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "加载中"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "加载中"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "缩略图"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "打开 {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "移除 {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings b/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings index 91afa80077de5..9d4123ee6ca2e 100644 --- a/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings +++ b/packages/native/apple/Sources/Astryx/Resources/zh-Hant.lproj/Localizable.strings @@ -5,3 +5,15 @@ /* Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense ("work in progress"), not a noun. Kept separate from `typeahead.loading` — translations may diverge. */ "@astryx.button.loading" = "載入中"; + +/* Screen-reader-only default name for a standalone Spinner's role="status" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge. */ +"@astryx.spinner.loading" = "載入中"; + +/* Generic screen-reader name for a Thumbnail (image preview) that has no `alt` or `label`. Last-resort fallback so the control is never nameless; prefer a real name via `alt` or `label`. */ +"@astryx.thumbnail.fallbackName" = "縮圖"; + +/* "Open" = launch/reveal (not "unfold" or the adjective). Screen-reader-only name on a clickable Thumbnail — example: `Open Sunset photo`. */ +"@astryx.thumbnail.open" = "開啟 {accessibleName}"; + +/* Screen-reader-only label on the "×" on a Thumbnail (image preview chip). `{accessibleName}` is the thumbnail's name — example: `Remove profile.jpg`. */ +"@astryx.thumbnail.remove" = "移除 {accessibleName}"; diff --git a/packages/native/apple/Sources/Astryx/Thumbnail/Thumbnail.spec.md b/packages/native/apple/Sources/Astryx/Thumbnail/Thumbnail.spec.md new file mode 100644 index 0000000000000..689883aa9cf8c --- /dev/null +++ b/packages/native/apple/Sources/Astryx/Thumbnail/Thumbnail.spec.md @@ -0,0 +1,188 @@ +--- +schema_version: 3 +template_version: 7 +kind: component +id: component:swift/Thumbnail +platform_of: component:Thumbnail +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/ThumbnailTests.swift, + packages/native/src/contract/contracts.test.ts, + packages/native/src/theme/swiftIOSStrings.test.ts, + ] +modules: [] +families: [] +design_specs: [] +architecture: [] +contributing: [] +system_specs: [spec:AST-082, spec:AST-093] +--- + +# Thumbnail (Swift) component contract + +## Contract at a glance + +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Public contract | `Thumbnail(src:, alt:, label:, isLoading:, isDisabled:, showRemoveOn:, onClick:, onRemove:)` and `Thumbnail(_ props: ThumbnailProps)`, a SwiftUI `View` in module `Astryx`. | +| Behavior | A 64-point square on the theme's neutral plate showing the image, else a placeholder silhouette; a skeleton while loading with no image and an upload look over one; a remove button in the top trailing corner; a tap that opens the thumbnail. | +| End-user impact | A person attaching images on iPhone sees the same previews as on the web, can open or remove each one, and VoiceOver reads each by its file name and description and announces once when an upload starts. | +| Builder impact | An app passes the image URL, a label such as a file name, and the actions it supports. A single trailing closure is `onClick`. | +| Compatibility/readiness | Additive: a new view, generated `ThumbnailProps`, and four of core's messages in the Swift string catalogs. Draft; built and tested on macOS and the iOS Simulator; device captures are attached to the change. | +| Review checks | Reject a hidden remove button on a touch device, a tap or remove that works while disabled, a tap that works while loading, an unnamed thumbnail, an upload announced on every render, or a color that is not a theme token. | +| Governing rules | `spec:AST-082` FR3, FR7; `spec:AST-086` FR8; `spec:AST-093` FR1–FR5; the Swift component API, theme environment, and accessibility (X1, X3, X4, X6, X7) records under `packages/native/specs/swift/library/`. | + +This table is a review projection; the body below is authoritative. + +## Intent + +Thumbnail on Apple platforms is core's preview of an image attachment, drawn +with SwiftUI's own image loading, progress view, and buttons. + +## 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** + +- The 64-point tile, its image, placeholder, skeleton, and upload look. +- The remove button, its visibility, and the open tap. +- The thumbnail's names and its upload announcement. + +**Does not own / non-goals** + +- The meaning of the props and the words — owned by `component:Thumbnail` and + core's message catalogs. +- Laying out several thumbnails, which belongs to the screen that holds them. + +## Public concepts + +| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | +| ------------ | ------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------- | -------- | --------------------- | ------------ | ------------------------------------ | +| picture | image, placeholder, skeleton, upload | what the tile shows | always | — | `component:Thumbnail` | experimental | a failed image shows the placeholder | +| showRemoveOn | `hover`, `always` | `hover` shows remove under a pointer and always on touch; `always` at rest | with `onRemove` | `hover` | `component:Thumbnail` | experimental | none | +| name | label and alt, label, alt, fallback | what VoiceOver reads for the thumbnail | always | fallback | `component:Thumbnail` | experimental | blank strings count as absent | + +## Behavioral and layout contract + +| ID | Candidate invariant | Basis | Draft review state | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------ | +| FR1 | The tile MUST be 64 by 64 points, clipped to `radius.element`, on `color.neutral`. It MUST show the image at `src` cropped to fill the tile, with a 1-point `color.border` inset edge on all four sides of the tile; with no `src`, or when the image fails, a 24-point image silhouette in `color.iconSecondary`. | core `Thumbnail` styles | verify | +| FR2 | `isLoading` with no `src` MUST show a skeleton in `color.skeleton` at 25% opacity; after 1 second it climbs to 92.5% in ten equal held steps over core's default 550 ms medium duration and back, repeating, as core's does, and it rests at 25% under Reduce Motion; under Increase Contrast its color moves 30% toward `color.textPrimary`, as core's does under `prefers-contrast: more`. `isLoading` with a `src` MUST draw `color.overlay` over the image with the platform's small progress view in `color.onDark`, which keeps its size at any text size. | core `Thumbnail`; Swift accessibility X6 | verify | +| FR3 | With `onRemove`, a 20-point remove button MUST sit `space.s1` from the top trailing corner on `color.overlay` with the shared `close` `Icon` at `xsm` in `color.onDark`, which the theme can remap and which keeps its size at any text size. It is visible at rest with `always` and on touch devices; with `hover` under a pointer, it appears while the pointer is over an enabled thumbnail or while its open or remove button has keyboard focus; a pointer reveals nothing on a disabled thumbnail, as core turns pointer events off there. | core `Thumbnail` `showRemoveOn`; `spec:AST-082` FR3 | verify | +| FR4 | With `onClick`, not disabled, and not loading, the tile MUST be a SwiftUI `Button` that paints `color.overlayPressed` while pressed. `isDisabled` MUST draw the thumbnail at half opacity and turn off its tap and remove button. The open button while loading or disabled, and the remove button while disabled, MUST stay mounted but inert, so focus on them stays put and VoiceOver reads them dimmed (`spec:AST-085`; Swift accessibility X2); during an upload the remove button stays active, as core's does. | core `Thumbnail` | verify | +| FR5 | With a `label`, the thumbnail MUST show it through the Astryx tooltip (`architecture:native-swift-library-tooltip`), as core's tooltip wraps the whole thumbnail: not a tab stop itself, keyboard focus on its open or remove button shows the label, and a direct tap opens it while an open action still runs, as the Tooltip by input table below states. A disabled thumbnail shows none, and the label is not a hint, since it is in the thumbnail's name. | core `Thumbnail` `label`; core `Tooltip`; `spec:AST-082` FR3 | verify | +| FR6 | Both initializers MUST build the same `ThumbnailProps`; a single trailing closure is `onClick`. Base props come through `ThumbnailProps`: with `isHidden` the view draws nothing; `id` becomes the accessibility identifier. | Swift component API A2–A4, A7; `spec:AST-082` FR7 | verify | + +**Tooltip by input (FR5).** The Astryx tooltip's rules apply; this table +states Thumbnail's choices. + +| Input | With a `label` | Disabled, or no `label` | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| Pointer | the bubble shows the label above the tile after 200 ms, stays while the pointer is on it, and a press hides it | nothing | +| Keyboard focus | the label at once when keyboard focus is on the open or remove button, as focus within core's tooltip wrapper shows it; the thumbnail itself is not a tab stop | nothing | +| Touch tap | a direct tap opens the bubble with the label, while an open action and an enclosing row still run; another tap on it, or a tap anywhere else, closes it, and that tap still reaches what it lands on | nothing | +| VoiceOver | the label is read once, in the thumbnail's name; the bubble is hidden and the tap is left alone | nothing | + +### Allowed variation + +- **AV1 — Glyphs and spinner.** The placeholder is the SF Symbol `photo.fill` + and the spinner the platform's progress view. + +### Representative states + +| State | Required invariant | Allowed variation | +| ------------------------------ | --------------------------------------------- | ----------------- | +| image | the image fills the tile with an inset edge | image content | +| no image, failed image | the placeholder silhouette | AV1 | +| loading without and with image | skeleton; upload overlay with a progress view | AV1 | +| remove, hover and always | visible on touch; at rest with `always` | none | +| tappable, disabled | a button; dimmed with no tap or remove | none | +| light and dark, two themes | each theme's colors | none | + +### Transformation and precedence order + +- **ORD1 — Picture.** Skeleton (loading, no `src`) → image → placeholder. +- **ORD2 — Name.** `label — alt` → `label` → `alt` → core's fallback word. + +### Performance and resources + +- **PR1 — System loading.** Images load through `AsyncImage` and the system + URL cache. + +## Accessibility contract + +- **AR1 — Named group.** The thumbnail is a container named by ORD2. The image + reads as its `alt` and is hidden without one (X4). +- **AR2 — Named actions.** The open button reads "Open" with the name and the + remove button "Remove" with the name, from core's message catalogs in the + person's language (X1). +- **AR3 — Upload announced once.** Each upload start, including one already + in progress when the thumbnail appears, announces core's "Loading" once, as + core does; the spinner is hidden (`spec:AST-086` FR8; X3). +- **AR4 — Targets.** The remove button answers taps across 44 by 44 points + without moving its drawing (X7). +- **AR5 — Rendered tree not measured.** Tests assert what the view sets; the + rendered accessibility tree, the hit region, and the announcement are not + measured until an app-hosted target exists. + +## Design relationships + +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | ---------------------------------------- | ------------------------ | -------------- | ------------------ | +| Image, overlays | Apple Human Interface Guidelines, Images | prescribed by core | supporting | FR1–FR4 | + +The component implements design requirements without copying their rationale. + +Platform controls: SwiftUI `AsyncImage` for the image, `ProgressView` for the +upload spinner, and `Button` for the open tap and remove action +(`spec:AST-093` FR2). SwiftUI has no thumbnail control, so the tile and its +overlays are drawn here. Guideline pages: Images, Progress indicators, Buttons, +and Accessibility in the Apple Human Interface Guidelines. + +Theme values read: `color.neutral`, `color.border`, `color.iconSecondary`, +`color.overlay`, `color.onDark`, `color.overlayPressed`, `radius.element`, +`space.s1`, `color.skeleton`, `color.textPrimary`, the theme's `close` icon, Reduce Motion, and Increase Contrast; and, for the Astryx tooltip's bubble, `color.textPrimary`, `color.backgroundSurface`, `space.s1`, `space.s2`, `radius.container`, and the body text size and weight. + +## Family and system relationships + +- `component:Thumbnail` owns the props, the words, and the look; this record + states only how Swift draws them. + +## Verification map + +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| FR1 | `ThumbnailTests.swift` size, plate, and two-theme renders | no image; default, neutral, butter; the largest text size | a wrong size or a color of its own fails | `audit:swift/Thumbnail/tile` | +| FR2 | `ThumbnailTests.swift` loading states, skeleton color, contrast mix, and largest-text render | loading with and without `src`; Increase Contrast; the largest text size | a skeleton over an image, no upload look, a wrong skeleton color, or a tile that grows fails | `audit:swift/Thumbnail/loading` | +| FR3, FR4 | `ThumbnailTests.swift` remove, tap, disabled, and mounted-while-inert tests and render | `always`, `hover`; disabled; loading | a hidden remove on touch or under keyboard focus, a hard-coded remove glyph, or a tap while disabled or loading fails; an open or remove button that unmounts while loading or disabled fails; a pointer that reveals the remove button on a disabled thumbnail fails | `audit:swift/Thumbnail/actions` | +| FR5 | `TooltipPresenterTests.swift` for each input; `ThumbnailTests.swift` name and focus-within tests | enabled and disabled; blank label | a tooltip on a disabled thumbnail, or none for keyboard focus on its open or remove button, fails; that the tile keeps one structure across a disabled toggle or an upload ending is not measured, since a test cannot see a rebuild | `audit:swift/Thumbnail/tooltip` | +| FR6 | `ThumbnailTests.swift` initializer, trailing-closure, and hidden tests; `contracts.test.ts` | both initializers; `isHidden` | differing props or a drawn hidden thumbnail fails | `audit:swift/Thumbnail/api` | +| AR1, AR2 | `ThumbnailTests.swift` name tests; `swiftIOSStrings.test.ts` | label and alt, each alone, neither | a wrong name or a missing catalog message fails | `audit:swift/Thumbnail/a11y` | + +## 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/Sources/Astryx/Thumbnail/Thumbnail.swift b/packages/native/apple/Sources/Astryx/Thumbnail/Thumbnail.swift new file mode 100644 index 0000000000000..1d6e9178b61c2 --- /dev/null +++ b/packages/native/apple/Sources/Astryx/Thumbnail/Thumbnail.swift @@ -0,0 +1,357 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import AstryxNative +import SwiftUI + +/// A square preview of an image attachment (core `Thumbnail`): the image, +/// a placeholder when there is none or it fails, a loading look, an optional +/// remove button, and an optional tap to open it. +/// +/// ```swift +/// Thumbnail(src: "https://example.com/beach.jpg", alt: "Beach at sunset", label: "beach.jpg") +/// Thumbnail(src: url, label: "beach.jpg", isLoading: true) // uploading +/// Thumbnail(src: url, label: "beach.jpg", onClick: { open() }, onRemove: { remove() }) +/// ``` +public struct Thumbnail: View { + /// The contract the view draws, the same for both initializers. + public let props: ThumbnailProps + @Environment(\.astryx) private var astryx + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast + @State private var isHovering = false + @FocusState private var focusedPart: Part? + + /// The thumbnail's focusable parts. + enum Part: Hashable { case open, remove } + + /// A thumbnail from its labeled props. + /// - Parameters: + /// - src: The image URL (core `src`). + /// - alt: What the image shows; without it the image is decorative (core `alt`). + /// - label: The thumbnail's name, such as a file name; also its tooltip (core `label`). + /// - isLoading: A placeholder while there is no image, or an upload look over the image (core `isLoading`). + /// - isDisabled: Dims the thumbnail and turns off its tap and remove button (core `isDisabled`). + /// - showRemoveOn: `hover` shows the remove button under a pointer and always on touch; `always` shows it at rest; `nil` is `hover` (core `showRemoveOn`). + /// - onClick: Makes the thumbnail a button that runs this; a single trailing closure is this (core `onClick`). + /// - onRemove: Adds a remove button that runs this (core `onRemove`). + public init( + src: String? = nil, + alt: String? = nil, + label: String? = nil, + isLoading: Bool? = nil, + isDisabled: Bool? = nil, + showRemoveOn: ThumbnailShowRemoveOn? = nil, + onClick: (() -> Void)? = nil, + onRemove: (() -> Void)? = nil + ) { + self.init( + ThumbnailProps( + src: src, alt: alt, label: label, onRemove: onRemove, onClick: onClick, isLoading: isLoading, + isDisabled: isDisabled, showRemoveOn: showRemoveOn)) + } + + /// A thumbnail from its generated contract, for generated and wire-driven callers. + public init(_ props: ThumbnailProps) { + self.props = props + } + + public var body: some View { + if props.base.isHidden != true { + ZStack(alignment: .topTrailing) { + tile + if hasRemove { + // Mounted while disabled too, inert, so focus on it stays put and + // VoiceOver reads it dimmed (AST-085; Swift accessibility X2). + removeButton + .disabled(!removes) + .padding(astryx.tokens.space.s1) + .opacity(isRemoveVisible ? 1 : 0) + } + } + .frame(width: Thumbnail.side, height: Thumbnail.side) + .opacity(isDisabled ? 0.5 : 1) + .onHover { isHovering = $0 } + // The Astryx tooltip (library/tooltip.spec.md), as core's Thumbnail wraps + // itself in core's tooltip: not a tab stop, and core's `auto` touch opens + // it on a tap, since the wrapper is not a control, while an open action + // still runs. The label is already in the name, so no hint. + .astryxTooltip(Thumbnail.meaningful(props.label), focus: .inside(focusedPart != nil), isDisabled: isDisabled) + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibleName) + .modifier(ThumbnailIdentifier(id: props.base.id)) + .onChange(of: showUploadOverlay, initial: true) { _, isUploading in + // AST-086 FR8: each busy start is announced once, in place of the + // decorative spinner, including one already uploading as the + // thumbnail appears, as core announces on mount. + if isUploading { + AccessibilityNotification.Announcement(AstryxStrings.spinnerLoading).post() + } + } + } + } + + /// The image area, with a button over it that opens the thumbnail when it + /// has `onClick`. The button stays mounted while loading or disabled, inert, + /// so focus on it stays put and VoiceOver reads it dimmed (AST-085; Swift + /// accessibility X2), and the picture never reloads as those states change. + /// The button names the tile, so the picture is hidden. + private var tile: some View { + picture + .accessibilityHidden(hasOpenButton) + .overlay { + if let onClick = props.onClick { + SwiftUI.Button(action: onClick) { Color.clear.contentShape(Rectangle()) } + .buttonStyle(ThumbnailPressStyle(radius: radius, pressed: astryx.tokens.color.overlayPressed)) + .disabled(!isInteractive) + .focused($focusedPart, equals: .open) + .accessibilityLabel(openLabel) + } + } + } + + private var picture: some View { + ZStack { + astryx.tokens.color.neutral + if showSkeleton { + ThumbnailSkeleton(color: skeletonColor, isStill: reduceMotion) + } else if let url = imageURL { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + // Cropped to the tile first, so the inset edge below follows + // the tile on all four sides, whatever the photo's shape. + .frame(width: Thumbnail.side, height: Thumbnail.side) + .clipped() + .modifier(ThumbnailImageName(alt: Thumbnail.meaningful(props.alt))) + .overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(astryx.tokens.color.border, lineWidth: 1) + ) + } else if phase.error != nil { + placeholder + } else { + Color.clear + } + } + } else { + placeholder + } + if showUploadOverlay { + astryx.tokens.color.overlay + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + // Core's small spinner: it stays inside the fixed-size tile at any + // text size. + .dynamicTypeSize(...DynamicTypeSize.large) + .tint(astryx.tokens.color.onDark) + .accessibilityHidden(true) + } + } + .frame(width: Thumbnail.side, height: Thumbnail.side) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } + + /// An image silhouette in the secondary icon color. + private var placeholder: some View { + Image(systemName: "photo.fill") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(astryx.tokens.color.iconSecondary) + .accessibilityHidden(true) + } + + private var removeButton: some View { + SwiftUI.Button(action: { props.onRemove?() }) { + // Core's remove glyph: the shared `close` icon at `xsm`, which the + // theme can remap (AST-082 FR17). + Icon(.close, size: .xsm) + .foregroundStyle(astryx.tokens.color.onDark) + // The button keeps its 20-point size at any text size. + .dynamicTypeSize(.large) + .frame(width: 20, height: 20) + .background( + RoundedRectangle(cornerRadius: max(0, radius - astryx.tokens.space.s1), style: .continuous) + .fill(astryx.tokens.color.overlay) + ) + } + .buttonStyle(.plain) + .focused($focusedPart, equals: .remove) + // A 44 by 44 point target without moving the drawn button (Swift accessibility, X7). + .contentShape(.interaction, Rectangle().inset(by: -12)) + .accessibilityLabel(removeLabel) + } + + private var radius: CGFloat { astryx.tokens.radius.element } + + /// Core's skeleton color; under Increase Contrast it moves 30% toward the + /// primary text color, as core's does under `prefers-contrast: more`. + var skeletonColor: Color { + Thumbnail.skeletonColor(astryx.tokens, increasedContrast: contrast == .increased) + } + + nonisolated static func skeletonColor(_ tokens: Tokens, increasedContrast: Bool) -> Color { + guard increasedContrast else { return tokens.color.skeleton } + return mix(tokens.color.skeleton, tokens.color.textPrimary, by: 0.3) + } + + /// `amount` of `other` mixed into `base`, in sRGB, as CSS `color-mix` does. + nonisolated static func mix(_ base: Color, _ other: Color, by amount: Double) -> Color { + let environment = EnvironmentValues() + let a = base.resolve(in: environment), b = other.resolve(in: environment) + let t = Float(amount) + return Color( + .sRGB, + red: Double(a.red + (b.red - a.red) * t), + green: Double(a.green + (b.green - a.green) * t), + blue: Double(a.blue + (b.blue - a.blue) * t), + opacity: Double(a.opacity + (b.opacity - a.opacity) * t) + ) + } + + var isDisabled: Bool { props.isDisabled == true } + var isLoading: Bool { props.isLoading == true } + var imageURL: URL? { + guard let src = props.src, !src.isEmpty else { return nil } + return URL(string: src) + } + /// Loading with no image yet: a skeleton. + var showSkeleton: Bool { isLoading && props.src == nil } + /// Loading over an image: the upload look. + var showUploadOverlay: Bool { isLoading && props.src != nil } + /// A tap opens the thumbnail unless it is disabled or loading, as core does. + var isInteractive: Bool { props.onClick != nil && !isDisabled && !isLoading } + + + /// Whether the open button is mounted: whenever there is `onClick`. + var hasOpenButton: Bool { props.onClick != nil } + /// Whether the remove button is mounted: whenever there is `onRemove`. + var hasRemove: Bool { props.onRemove != nil } + /// Whether the remove button acts: mounted and not disabled. + var removes: Bool { hasRemove && !isDisabled } + + /// `hover` shows the remove button under a pointer, while either button + /// has keyboard focus, and always on touch devices, as core does; `always` + /// shows it at rest. + var isRemoveVisible: Bool { + Thumbnail.isRemoveVisible( + showRemoveOn: props.showRemoveOn ?? .hover, isTouch: Thumbnail.isTouchDevice, isHovering: isHovering, + isFocused: focusedPart != nil, isDisabled: isDisabled) + } + + /// A pointer reveals the remove button only on an enabled thumbnail, as + /// core turns pointer events off on a disabled one. + nonisolated static func isRemoveVisible( + showRemoveOn: ThumbnailShowRemoveOn, isTouch: Bool, isHovering: Bool, isFocused: Bool, isDisabled: Bool = false + ) -> Bool { + showRemoveOn == .always || isTouch || (isHovering && !isDisabled) || isFocused + } + + /// The thumbnail's name: label and alt, else either, else core's fallback. + var accessibleName: String { + switch (Thumbnail.meaningful(props.label), Thumbnail.meaningful(props.alt)) { + case let (label?, alt?): "\(label) — \(alt)" + case let (label?, nil): label + case let (nil, alt?): alt + case (nil, nil): AstryxStrings.thumbnailFallbackName + } + } + + var openLabel: String { AstryxStrings.thumbnailOpen(accessibleName) } + var removeLabel: String { AstryxStrings.thumbnailRemove(accessibleName) } + + /// Core's thumbnail side in points. + static var side: CGFloat { 64 } + + static var isTouchDevice: Bool { + #if os(iOS) + true + #else + false + #endif + } + + nonisolated static func meaningful(_ value: String?) -> String? { + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return value + } +} + +/// Core's skeleton while the image has not arrived: `color.skeleton` at 25% +/// opacity; after a second it climbs to 92.5% in ten equal steps and back, +/// over and over, as core's does. Still at rest under Reduce Motion. +struct ThumbnailSkeleton: View { + let color: Color + let isStill: Bool + @State private var appeared = Date() + + /// Core's resting and brightest opacities. + nonisolated static var restingOpacity: Double { 0.25 } + nonisolated static var brightestOpacity: Double { 0.925 } + /// Core's wait before the pulse starts, so fast loads never flash. + nonisolated static var delay: Double { 1 } + /// One way of the pulse: core's default `duration-medium-max`. The Swift + /// theme carries no motion tokens yet. + nonisolated static var pulseDuration: Double { 0.55 } + + var body: some View { + TimelineView(.animation(paused: isStill)) { context in + color.opacity( + isStill ? Self.restingOpacity : Self.opacity(elapsed: context.date.timeIntervalSince(appeared))) + } + .onAppear { appeared = Date() } + .accessibilityHidden(true) + } + + /// The opacity `elapsed` seconds after the skeleton appears: at rest for + /// the delay, then core's ten held steps up and back down. + nonisolated static func opacity(elapsed: Double) -> Double { + let time = elapsed - delay + guard time > 0 else { return restingOpacity } + let phase = time.truncatingRemainder(dividingBy: 2 * pulseDuration) / pulseDuration + let climb = phase <= 1 ? phase : 2 - phase + let step = min((climb * 10).rounded(.down), 9) + return restingOpacity + step * (brightestOpacity - restingOpacity) / 9 + } +} + +/// Paints the pressed overlay over the picture. +private struct ThumbnailPressStyle: ButtonStyle { + let radius: CGFloat + let pressed: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(configuration.isPressed ? pressed : .clear) + ) + } +} + +/// The image reads as its `alt`; without one it is decorative. +private struct ThumbnailImageName: ViewModifier { + let alt: String? + + func body(content: Content) -> some View { + if let alt { + content.accessibilityLabel(alt) + } else { + content.accessibilityHidden(true) + } + } +} + +/// The base `id` becomes the accessibility identifier (AST-082 FR7). +private struct ThumbnailIdentifier: ViewModifier { + let id: String? + + func body(content: Content) -> some View { + if let id { + content.accessibilityIdentifier(id) + } else { + content + } + } +} diff --git a/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift b/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift index 654e12f5526ab..c02c3362a0dae 100644 --- a/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift +++ b/packages/native/apple/Sources/Astryx/Tooltip/TooltipPresenter.swift @@ -13,6 +13,16 @@ enum TooltipFocus: Equatable, Sendable { /// The view is made a tab stop so focus can show the tooltip, as core gives /// a static element with a tooltip a tab stop. case tabStop + /// The view holds controls that take focus; `true` while one of them has + /// it. Focus there shows the tooltip, as core's tooltip on a wrapper shows + /// on focus within it. + case inside(Bool) + + /// Whether a control inside the view has focus. + var isInsideFocused: Bool { + if case .inside(let focused) = self { return focused } + return false + } } /// What a direct tap on a touch screen does, as core's `touchTrigger`. @@ -206,9 +216,9 @@ struct TooltipPresenter: ViewModifier { focusIsKeyboard = Self.keyboardFocusHolds(afterHover: inside, holds: focusIsKeyboard) update() } - .onChange(of: isFocused) { + .onChange(of: anyFocused) { focusIsKeyboard = - isFocused && Self.isKeyboardFocus(isHovering: isHovering, sincePress: Date().timeIntervalSince(pressedAt)) + anyFocused && Self.isKeyboardFocus(isHovering: isHovering, sincePress: Date().timeIntervalSince(pressedAt)) update() } .onChange(of: text) { @@ -268,12 +278,15 @@ struct TooltipPresenter: ViewModifier { isTapShown = false } + /// Focus on the view itself, or on a control inside it (`inside`). + private var anyFocused: Bool { isFocused || focus.isInsideFocused } + /// Shows the bubble after the delay; hides it after the bridge. private func update() { pending?.cancel() - let keyboardFocused = Self.holdsByFocus(isFocused: isFocused, focusIsKeyboard: focusIsKeyboard) + let keyboardFocused = Self.holdsByFocus(isFocused: anyFocused, focusIsKeyboard: focusIsKeyboard) let wants = Self.showsBubble( - hasText: text != nil, isHovering: isHovering, isHoveringBubble: isHoveringBubble, isFocused: isFocused, + hasText: text != nil, isHovering: isHovering, isHoveringBubble: isHoveringBubble, isFocused: anyFocused, focusIsKeyboard: focusIsKeyboard, focus: focus) guard wants != isShown else { return } let byFocus = keyboardFocused && focus != .none && !isHovering && !isHoveringBubble diff --git a/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift b/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift index cbcf99b47d6d5..69f7e241e3358 100644 --- a/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift +++ b/packages/native/apple/Sources/Astryx/Wire/AstryxNodeView.swift @@ -22,6 +22,7 @@ public struct AstryxNodeView: View { case .avatar(let avatar): Avatar(avatar.viewProps(send: send)) { AstryxNodeList(nodes: avatar.props.status ?? [], send: send) } case .avatarStatusDot(let dot): AvatarStatusDot(dot.viewProps(send: send)) + case .thumbnail(let thumbnail): Thumbnail(thumbnail.viewProps(send: send)) default: AstryxUnsupportedNode(component: Self.componentName(node)) } } @@ -42,6 +43,7 @@ public struct AstryxNodeView: View { case .selector: "Selector" case .switch: "Switch" case .text: "Text" + case .thumbnail: "Thumbnail" case .textInput: "TextInput" case .vStack: "VStack" case .unsupported(let component, _): component diff --git a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift index a5cf515dac7c1..2c025079fd459 100644 --- a/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift +++ b/packages/native/apple/Sources/AstryxNative/Generated/AstryxNode.swift @@ -20,6 +20,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case selector(SelectorNode) case `switch`(SwitchNode) case text(TextNode) + case thumbnail(ThumbnailNode) case textInput(TextInputNode) case vStack(VStackNode) case unsupported(component: String, key: String) @@ -39,6 +40,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case .selector(let node): return node.key case .`switch`(let node): return node.key case .text(let node): return node.key + case .thumbnail(let node): return node.key case .textInput(let node): return node.key case .vStack(let node): return node.key case .unsupported(_, let key): return key @@ -66,6 +68,7 @@ public enum AstryxNode: Decodable, Equatable, Sendable { case "Selector": self = .selector(try SelectorNode(from: decoder)) case "Switch": self = .`switch`(try SwitchNode(from: decoder)) case "Text": self = .text(try TextNode(from: decoder)) + case "Thumbnail": self = .thumbnail(try ThumbnailNode(from: decoder)) case "TextInput": self = .textInput(try TextInputNode(from: decoder)) case "VStack": self = .vStack(try VStackNode(from: decoder)) default: self = .unsupported(component: component, key: key) diff --git a/packages/native/apple/Sources/AstryxNative/Generated/Thumbnail.swift b/packages/native/apple/Sources/AstryxNative/Generated/Thumbnail.swift new file mode 100644 index 0000000000000..71223262c2a5c --- /dev/null +++ b/packages/native/apple/Sources/AstryxNative/Generated/Thumbnail.swift @@ -0,0 +1,123 @@ +// Generated by @astryxdesign/native. Do not edit. +public enum ThumbnailShowRemoveOn: String, CaseIterable, Codable, Sendable { + case always = "always" + case hover = "hover" +} +#if os(iOS) +public struct ThumbnailProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var src: String? + public var alt: String? + public var label: String? + public var onRemove: (() -> Void)? + public var onClick: (() -> Void)? + public var isLoading: Bool? + public var isDisabled: Bool? + public var showRemoveOn: ThumbnailShowRemoveOn? + public init(src: String? = nil, alt: String? = nil, label: String? = nil, onRemove: (() -> Void)? = nil, onClick: (() -> Void)? = nil, isLoading: Bool? = nil, isDisabled: Bool? = nil, showRemoveOn: ThumbnailShowRemoveOn? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.src = src + self.alt = alt + self.label = label + self.onRemove = onRemove + self.onClick = onClick + self.isLoading = isLoading + self.isDisabled = isDisabled + self.showRemoveOn = showRemoveOn + self.base = base + } +} +public struct ThumbnailNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var src: String? + public var alt: String? + public var label: String? + public var isLoading: Bool? + public var isDisabled: Bool? + public var showRemoveOn: ThumbnailShowRemoveOn? + } + 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 Thumbnail view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> ThumbnailProps { + let key = self.key + return ThumbnailProps( + src: props.src, + alt: props.alt, + label: props.label, + onRemove: listens("onRemove") ? { send(AstryxAction(key: key, component: "Thumbnail", event: "onRemove", args: [])) } : nil, + onClick: listens("onClick") ? { send(AstryxAction(key: key, component: "Thumbnail", event: "onClick", args: [])) } : nil, + isLoading: props.isLoading, + isDisabled: props.isDisabled, + showRemoveOn: props.showRemoveOn, + base: base ?? AstryxBaseProps() + ) + } +} +#endif +#if os(macOS) +public struct ThumbnailProps { + /// Base props attach to this part on every platform. + public static let baseTarget: AstryxBaseTarget = .root + public var base: AstryxBaseProps + public var src: String? + public var alt: String? + public var label: String? + public var onRemove: (() -> Void)? + public var onClick: (() -> Void)? + public var isLoading: Bool? + public var isDisabled: Bool? + public var showRemoveOn: ThumbnailShowRemoveOn? + public init(src: String? = nil, alt: String? = nil, label: String? = nil, onRemove: (() -> Void)? = nil, onClick: (() -> Void)? = nil, isLoading: Bool? = nil, isDisabled: Bool? = nil, showRemoveOn: ThumbnailShowRemoveOn? = nil, base: AstryxBaseProps = AstryxBaseProps()) { + self.src = src + self.alt = alt + self.label = label + self.onRemove = onRemove + self.onClick = onClick + self.isLoading = isLoading + self.isDisabled = isDisabled + self.showRemoveOn = showRemoveOn + self.base = base + } +} +public struct ThumbnailNode: Decodable, Equatable, Sendable { + public struct Props: Decodable, Equatable, Sendable { + public var src: String? + public var alt: String? + public var label: String? + public var isLoading: Bool? + public var isDisabled: Bool? + public var showRemoveOn: ThumbnailShowRemoveOn? + } + 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 Thumbnail view takes; its callbacks send actions to the core. + public func viewProps(send: @escaping @Sendable (AstryxAction) -> Void) -> ThumbnailProps { + let key = self.key + return ThumbnailProps( + src: props.src, + alt: props.alt, + label: props.label, + onRemove: listens("onRemove") ? { send(AstryxAction(key: key, component: "Thumbnail", event: "onRemove", args: [])) } : nil, + onClick: listens("onClick") ? { send(AstryxAction(key: key, component: "Thumbnail", event: "onClick", args: [])) } : nil, + isLoading: props.isLoading, + isDisabled: props.isDisabled, + showRemoveOn: props.showRemoveOn, + base: base ?? AstryxBaseProps() + ) + } +} +#endif diff --git a/packages/native/apple/Tests/AstryxTests/ThumbnailTests.swift b/packages/native/apple/Tests/AstryxTests/ThumbnailTests.swift new file mode 100644 index 0000000000000..ba72659c28a45 --- /dev/null +++ b/packages/native/apple/Tests/AstryxTests/ThumbnailTests.swift @@ -0,0 +1,204 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import AstryxNative +import SwiftUI +import Testing +@testable import Astryx + +// Swift Thumbnail spec (component:swift/Thumbnail): core's 64-point preview, +// its placeholder, loading, remove, and open states, and its names. + +@MainActor +@Test func thumbnailInitializersBuildTheSameProps() { + let labeled = Thumbnail(src: "a", alt: "Beach", label: "beach.jpg", isLoading: true, isDisabled: true, showRemoveOn: .always).props + let fromProps = Thumbnail(ThumbnailProps(src: "a", alt: "Beach", label: "beach.jpg", isLoading: true, isDisabled: true, showRemoveOn: .always)).props + #expect(labeled.src == fromProps.src) + #expect(labeled.alt == fromProps.alt) + #expect(labeled.label == fromProps.label) + #expect(labeled.isLoading == fromProps.isLoading) + #expect(labeled.isDisabled == fromProps.isDisabled) + #expect(labeled.showRemoveOn == fromProps.showRemoveOn) + #expect(labeled.base == fromProps.base) +} + +@MainActor +@Test func aTrailingClosureOpensTheThumbnail() { + let thumbnail = Thumbnail(src: "a", label: "beach.jpg") {} + #expect(thumbnail.props.onClick != nil) + #expect(thumbnail.props.onRemove == nil) +} + +@MainActor +@Test func theNameJoinsLabelAndAltElseFallsBack() { + #expect(Thumbnail(alt: "Beach at sunset", label: "beach.jpg").accessibleName == "beach.jpg — Beach at sunset") + #expect(Thumbnail(label: "beach.jpg").accessibleName == "beach.jpg") + #expect(Thumbnail(alt: "Beach at sunset").accessibleName == "Beach at sunset") + #expect(Thumbnail().accessibleName == "Thumbnail") + #expect(Thumbnail(label: "beach.jpg").openLabel == "Open beach.jpg") + #expect(Thumbnail(label: "beach.jpg").removeLabel == "Remove beach.jpg") +} + +@MainActor +@Test func loadingShowsASkeletonWithoutAnImageAndTheUploadLookOverOne() { + #expect(Thumbnail(isLoading: true).showSkeleton) + #expect(!Thumbnail(isLoading: true).showUploadOverlay) + #expect(Thumbnail(src: "https://example.com/a.png", isLoading: true).showUploadOverlay) + #expect(!Thumbnail(src: "https://example.com/a.png", isLoading: true).showSkeleton) + #expect(!Thumbnail(src: "https://example.com/a.png").showUploadOverlay) +} + +@MainActor +@Test func disabledOrLoadingTurnsOffTheTapAndDisabledTurnsOffRemove() { + #expect(Thumbnail {}.isInteractive) + #expect(!Thumbnail(isDisabled: true) {}.isInteractive) + #expect(!Thumbnail(src: "a", isLoading: true) {}.isInteractive) + #expect(Thumbnail(onRemove: {}).hasRemove) + #expect(Thumbnail(onRemove: {}).removes) + // AST-085: while disabled or loading the buttons stay mounted, inert, so + // focus on them stays put. + #expect(Thumbnail(isDisabled: true, onRemove: {}).hasRemove) + #expect(!Thumbnail(isDisabled: true, onRemove: {}).removes) + #expect(Thumbnail(isDisabled: true) {}.hasOpenButton) + #expect(Thumbnail(src: "a", isLoading: true) {}.hasOpenButton) + #expect(!Thumbnail(src: "a").hasOpenButton) +} + +@Test func hoverRevealsRemoveUnderAPointerOrKeyboardFocusAndAlwaysOnTouch() { + typealias T = Thumbnail + #expect(!T.isRemoveVisible(showRemoveOn: .hover, isTouch: false, isHovering: false, isFocused: false)) + #expect(T.isRemoveVisible(showRemoveOn: .hover, isTouch: false, isHovering: true, isFocused: false)) + #expect(T.isRemoveVisible(showRemoveOn: .hover, isTouch: false, isHovering: false, isFocused: true)) + #expect(T.isRemoveVisible(showRemoveOn: .hover, isTouch: true, isHovering: false, isFocused: false)) + #expect(T.isRemoveVisible(showRemoveOn: .always, isTouch: false, isHovering: false, isFocused: false)) + // Core FR6: a pointer reveals nothing on a disabled thumbnail. + #expect(!T.isRemoveVisible(showRemoveOn: .hover, isTouch: false, isHovering: true, isFocused: false, isDisabled: true)) +} + +// Core FR3: keyboard focus on the open or remove button shows the label, as +// focus within core's tooltip wrapper does. +@Test func focusOnAThumbnailButtonShowsItsLabel() { + typealias P = TooltipPresenter + #expect(TooltipFocus.inside(true).isInsideFocused) + #expect(!TooltipFocus.inside(false).isInsideFocused) + #expect(P.showsBubble( + hasText: true, isHovering: false, isHoveringBubble: false, isFocused: true, focusIsKeyboard: true, + focus: .inside(true))) + // `auto` touch still opens on a tap: the wrapper is not a control. + #expect(P.resolvedTouch(.auto, focus: .inside(false)) == .tap) +} + +@MainActor +@Test func theRemoveButtonShowsAtRestWithAlwaysAndOnTouchDevices() { + #expect(Thumbnail(showRemoveOn: .always, onRemove: {}).isRemoveVisible) + #expect(Thumbnail(onRemove: {}).isRemoveVisible == Thumbnail.isTouchDevice) +} + +@MainActor +@Test func aThumbnailIs64PointsOnTheNeutralPlate() { + let drawn = Pixels(Thumbnail().background(Color.white)) + #expect(drawn.width == 64 && drawn.height == 64) + #expect(close(drawn.at(8, 32), onWhite(rgba(Theme.default.light.color.neutral)), within: 3)) + // The placeholder silhouette sits in the middle in the secondary icon color. + let icon = onWhite(rgba(Theme.default.light.color.neutral)) + #expect(drawn.contains { !close($0, icon, within: 12) }) +} + +@MainActor +@Test func aThumbnailRendersFromTwoThemesWithNoColorOfItsOwn() { + let view = Thumbnail().background(Color.white) + #expect(close(Pixels(view, theme: .neutral).at(8, 32), onWhite(rgba(Theme.neutral.light.color.neutral)), within: 3)) + #expect(close(Pixels(view, theme: .butter).at(8, 32), onWhite(rgba(Theme.butter.light.color.neutral)), within: 3)) +} + +@MainActor +@Test func aThumbnailKeepsItsSizeAtTheLargestTextSize() { + let large = Pixels(Thumbnail(src: "https://example.com/a.png", isLoading: true, onRemove: {})) + let largest = Pixels(Thumbnail(src: "https://example.com/a.png", isLoading: true, onRemove: {}), typeSize: .accessibility5) + #expect(largest.width == 64 && largest.height == 64) + #expect(large.width == largest.width && large.height == largest.height) +} + +@MainActor +@Test func theSkeletonUsesCoresSkeletonColor() { + let tokens = Theme.default.light + #expect(Thumbnail.skeletonColor(tokens, increasedContrast: false) == tokens.color.skeleton) + #expect(Thumbnail.skeletonColor(tokens, increasedContrast: true) != tokens.color.skeleton) + // At rest, under Reduce Motion: core's 25% opacity. + let still = Pixels(ThumbnailSkeleton(color: Color(red: 1, green: 0, blue: 0), isStill: true).frame(width: 8, height: 8)) + #expect(abs(still.at(4, 4)[3] - 64) <= 2) + #expect(ThumbnailSkeleton.restingOpacity == 0.25) + #expect(ThumbnailSkeleton.brightestOpacity == 0.925) +} + +@Test func theSkeletonWaitsASecondThenStepsUpAndBackAsCoresDoes() { + let opacity = ThumbnailSkeleton.opacity(elapsed:) + let d = ThumbnailSkeleton.pulseDuration + #expect(opacity(0) == 0.25) + #expect(opacity(0.99) == 0.25) + // Ten held steps of 0.075 on the way up… + #expect(abs(opacity(1 + d * 0.05) - 0.25) < 1e-9) + #expect(abs(opacity(1 + d * 0.15) - 0.325) < 1e-9) + #expect(abs(opacity(1 + d * 0.55) - 0.625) < 1e-9) + #expect(abs(opacity(1 + d * 0.95) - 0.925) < 1e-9) + // …and back down. + #expect(abs(opacity(1 + d * 1.05) - 0.925) < 1e-9) + #expect(abs(opacity(1 + d * 1.95) - 0.25) < 1e-9) +} + +@MainActor +@Test func theRemoveButtonDrawsTheSharedCloseIconTheThemeMaps() { + // `always` shows the button on every platform. A theme that maps `close` + // to a filled circle fills far more of the button than the default glyph. + func onDarkPixels(_ theme: Theme) -> Int { + let drawn = Pixels(Thumbnail(showRemoveOn: .always, onRemove: {}), theme: theme) + let onDark = rgba(theme.light.color.onDark) + var count = 0 + for y in 4..<24 { for x in 40..<60 where close(drawn.at(x, y), onDark, within: 40) { count += 1 } } + return count + } + var filled = Theme.default + filled.icons["close"] = "circle.fill" + #expect(onDarkPixels(filled) > onDarkPixels(.default) * 2) +} + +@Test func increaseContrastMovesTheSkeletonTowardText() { + let black = Color(red: 0, green: 0, blue: 0), white = Color(red: 1, green: 1, blue: 1) + #expect(close(rgba(Thumbnail.mix(white, black, by: 0.3)), [179, 179, 179, 255], within: 1)) +} + +@MainActor +@Test func aDisabledThumbnailIsDimmed() { + let plain = Pixels(Thumbnail()).at(8, 32) + let dimmed = Pixels(Thumbnail(isDisabled: true)).at(8, 32) + #expect(dimmed[3] < plain[3]) +} + +@MainActor +@Test func aHiddenThumbnailDrawsNothing() { + let hidden = Pixels( + HStack(spacing: 0) { + Color.clear.frame(width: 1, height: 1) + Thumbnail(ThumbnailProps(base: AstryxBaseProps(isHidden: true))) + }.fixedSize() + ) + #expect(hidden.width == 1) +} + +/// A theme color drawn over white. +private func onWhite(_ c: [Int]) -> [Int] { + (0..<3).map { (c[$0] * c[3] + 255 * (255 - c[3])) / 255 } + [255] +} + +// AST-082 FR19: a Thumbnail node from a logic core draws as Thumbnail. +@MainActor +@Test func aThumbnailNodeDrawsAsAThumbnail() { + let json = #"{"component":"Thumbnail","key":"t","props":{"label":"beach.jpg"}}"# + guard let node = try? JSONDecoder().decode(AstryxNode.self, from: Data(json.utf8)) else { + Issue.record("the thumbnail did not decode") + return + } + #expect(AstryxNodeView.componentName(node) == "Thumbnail") + let wired = Pixels(AstryxNodeView(node) { _ in }) + #expect(wired.width == 64 && wired.height == 64) +} + diff --git a/packages/native/scripts/swift-ios-strings.mjs b/packages/native/scripts/swift-ios-strings.mjs index fbdaa1f9b0ddc..d2b507f896d4b 100644 --- a/packages/native/scripts/swift-ios-strings.mjs +++ b/packages/native/scripts/swift-ios-strings.mjs @@ -29,6 +29,10 @@ export const SWIFT_IOS_STRINGS_DIR = path.join( export const SWIFT_IOS_STRING_KEYS = [ '@astryx.avatar.nameWithStatus', '@astryx.button.loading', + '@astryx.spinner.loading', + '@astryx.thumbnail.fallbackName', + '@astryx.thumbnail.open', + '@astryx.thumbnail.remove', ]; // Apple names a localization by its language alone unless one language has diff --git a/packages/native/specs/swift/library/component-api.md b/packages/native/specs/swift/library/component-api.md index bd2926c6a54a8..ac42639a50935 100644 --- a/packages/native/specs/swift/library/component-api.md +++ b/packages/native/specs/swift/library/component-api.md @@ -148,12 +148,12 @@ same generated contract. (SwiftUI views with core's names, `Button`, `Text`, and so on, re-exporting SwiftUI and the contracts) and the `AstryxNative` product (generated contract types, unprefixed: `ButtonProps`, `ButtonVariant`). -- Avatar, AvatarStatusDot, Card, Heading, Icon, and Text take their generated - `Props`; Button and +- Avatar, AvatarStatusDot, Card, Heading, Icon, Text, and Thumbnail take their + generated `Props`; Button and TextInput still take hand-written parameters until each moves onto its generated props; Banner has no generated contract. - `AstryxNodeView` draws Avatar (with its status dot), AvatarStatusDot, Card, - Heading, Icon, and Text wire nodes. Any other + Heading, Icon, Text, 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/specs/swift/library/tooltip.spec.md b/packages/native/specs/swift/library/tooltip.spec.md index ad2fd2816c512..49faeea4f56e4 100644 --- a/packages/native/specs/swift/library/tooltip.spec.md +++ b/packages/native/specs/swift/library/tooltip.spec.md @@ -51,6 +51,9 @@ on the view the tooltip describes. focusable), `control` (the view is a control that takes focus already), or `tabStop` (a static view is made a tab stop while it has words, as core gives a static element with a tooltip a tab stop). + `inside` marks a view that holds controls of its own, such as a thumbnail's + open and remove buttons, and carries whether one of them has focus: focus + there shows the tooltip, as focus within core's tooltip wrapper does. - `touch` says what a direct tap does, as core's `touchTrigger`: `auto` (the default) opens the tooltip unless the view is a control, `tap` always opens it, and `none` never does. diff --git a/packages/native/src/components/Thumbnail.ts b/packages/native/src/components/Thumbnail.ts new file mode 100644 index 0000000000000..2ffd2768d9841 --- /dev/null +++ b/packages/native/src/components/Thumbnail.ts @@ -0,0 +1,21 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file components/Thumbnail.ts + * @input None + * @output What @astryxdesign/native adds to the core Thumbnail contract + * @position Native additions read by fromComponentDoc together with core's + * Thumbnail documentation + */ + +import type {NativeAdditions} from '../contract/fromComponentDoc.js'; + +export const thumbnail: NativeAdditions = { + name: 'Thumbnail', + baseTarget: 'root', + callbacks: ['onClick', 'onRemove'], + sameMeaning: {onClick: 'a press, as in Compose'}, + webOnly: { + 'data-testid': 'web test selector; native platforms use the base id', + }, +}; diff --git a/packages/native/src/components/index.ts b/packages/native/src/components/index.ts index ee8eda9080c77..a6422494a92e0 100644 --- a/packages/native/src/components/index.ts +++ b/packages/native/src/components/index.ts @@ -22,6 +22,7 @@ import {progressBar} from './ProgressBar.js'; import {selector} from './Selector.js'; import {switchControl} from './Switch.js'; import {text} from './Text.js'; +import {thumbnail} from './Thumbnail.js'; import {textInput} from './TextInput.js'; import {vStack} from './VStack.js'; @@ -39,6 +40,7 @@ export const NATIVE_COMPONENTS: readonly NativeAdditions[] = [ selector, switchControl, text, + thumbnail, textInput, vStack, ]; diff --git a/packages/native/src/contract/contracts.test.ts b/packages/native/src/contract/contracts.test.ts index 17346bdec9ce1..424164abac721 100644 --- a/packages/native/src/contract/contracts.test.ts +++ b/packages/native/src/contract/contracts.test.ts @@ -179,6 +179,26 @@ describe('native contracts', () => { expect(dot!.webOnly.map(prop => prop.name)).toContain('icon'); }); + it('gives Thumbnail its image, names, states, a press, and a remove action', async () => { + const {contracts} = await buildContracts(loadCoreDoc); + const thumbnail = contracts.find(contract => contract.name === 'Thumbnail'); + expect(thumbnail).toBeDefined(); + expect(Object.keys(thumbnail!.core)).toEqual([ + 'src', + 'alt', + 'label', + 'onRemove', + 'onClick', + 'isLoading', + 'isDisabled', + 'showRemoveOn', + ]); + expect(thumbnail!.core.onRemove?.type).toEqual({kind: 'fn', args: []}); + expect(thumbnail!.webOnly.map(prop => prop.name)).toEqual( + expect.arrayContaining(['xstyle', 'className', 'style', 'data-testid']), + ); + }); + it('reports naming and portability errors', () => { const bad: ComponentContract = { name: 'Bad',