diff --git a/.claude/skills/cds-rn-to-compose/SKILL.md b/.claude/skills/cds-rn-to-compose/SKILL.md new file mode 100644 index 0000000000..dcabe0d770 --- /dev/null +++ b/.claude/skills/cds-rn-to-compose/SKILL.md @@ -0,0 +1,24 @@ +--- +name: cds-rn-to-compose +description: | + Guide for porting CDS React Native components to Jetpack Compose in packages/cds-android, and for auditing existing Android ports for completeness and quality. + USE THIS whenever the user asks to port, migrate, or bring a CDS mobile/RN component to Android/Compose/Kotlin, audit an Android CDS component against mobile parity, or review whether a cds-android port is done correctly. + Also trigger for phrases like "RN to Compose", "mobile to Android CDS", "port Button/Chip/Card to cds-android", "public API boundary", or "does our Android Button match mobile". + Loads the CDS Compose Best Practices document from Linear, which is the source of truth for porting rules and learnings. + Load jetpack-best-practices alongside this skill for Compose API shape. +--- + +# CDS React Native → Jetpack Compose + +The porting rules and learnings live in the Linear document **CDS Compose Best Practices**, attached to the [Migrate CDS components to native](https://linear.app/coinbase/project/migrate-cds-components-to-native-f5911543a456) project. + +- Document ID: `fbd2c503-d78f-42aa-bed9-fd9dd8b2af77` +- URL: https://linear.app/coinbase/document/cds-compose-best-practices-8810460c4b23 + +## Before starting + +Fetch the document with the Linear MCP `get_document` tool, using the ID above, and follow it for the whole port or audit. If the Linear MCP server is unavailable, stop and ask the user to connect it. Do not port from memory. + +## After finishing + +If the port or audit taught something generalizable (a non-obvious pattern, a user correction, a gap in the doc, a deliberate decision not to port something), add it to the document with `save_document` using `patch`. Put it in the matching section, and tell the user what you changed. Skip one-off component trivia. diff --git a/.claude/skills/swiftui-best-practices/SKILL.md b/.claude/skills/swiftui-best-practices/SKILL.md index 47f7344149..52838ada77 100644 --- a/.claude/skills/swiftui-best-practices/SKILL.md +++ b/.claude/skills/swiftui-best-practices/SKILL.md @@ -170,8 +170,10 @@ UIColor that duplicates a token. 5. Third-party (Lottie): wrap in a CDS type. Call sites never import `Lottie` for a CDS animation. 6. Accessibility: keep system traits from the HIG control. Don't replace `Button` with a `onTapGesture` `View` just to draw chrome — that's what `ButtonStyle.makeBody` is for. -7. Add a gallery section in `apps/ios-gallery/Sources/ComponentsGallery.swift` using the **real - call site** (HIG control + Style, or the CDS view for overlays). +7. Add a gallery section — a new `GalleryView.swift` registered in `GalleryDestination` and + `ComponentGalleryView`, or a section inside `OtherComponentsGalleryView.swift` for components + without their own destination — using the **real call site** (HIG control + Style, or the CDS + view for overlays). 8. Tests: `yarn nx run cds-ios:test`. Then `yarn nx run cds-ios:build` if the gallery or package graph changed. Do not run unscoped `yarn test`. diff --git a/AGENTS.md b/AGENTS.md index 8448b72e4f..2e3dce7a02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,7 @@ defaults remain Node-oriented, and [`docs/`](docs/README.md) for setup and CI ar [`packages/cds-ios/AGENTS.md`](packages/cds-ios/AGENTS.md). - Load the `jetpack-best-practices` skill when writing Compose. - Load the `swiftui-best-practices` skill when writing SwiftUI or migrating a RN component to `packages/cds-ios`. +- Load the `cds-rn-to-compose` skill when porting mobile components to `packages/cds-android` or auditing Android/mobile parity. - Do not copy package-local consumer or release rules into this root file. ## Skills diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index dcf7f6a13a..1df1012692 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -8,6 +8,7 @@ lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.8.0" kotlin = "2.2.10" composeBom = "2026.02.01" +robolectric = "4.16.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -26,6 +27,7 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-animation-core = { group = "androidx.compose.animation", name = "animation-core" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt index 5a9e66f612..ce5546be96 100644 --- a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/MainActivity.kt @@ -6,34 +6,30 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview +import com.coinbase.cds.androidapp.gallery.ButtonGallerySection +import com.coinbase.cds.androidapp.gallery.ButtonGalleryScreen import com.coinbase.cds.androidapp.gallery.CdsThemeGallery +import com.coinbase.cds.androidapp.gallery.ComponentGalleryScreen +import com.coinbase.cds.androidapp.gallery.GalleryRoute +import com.coinbase.cds.androidapp.gallery.HomeGalleryScreen +import com.coinbase.cds.androidapp.theme.AcmeTheme import com.coinbase.cds.theme.CdsColorScheme import com.coinbase.cds.theme.CdsDefaultTheme import com.coinbase.cds.theme.CdsTheme import com.coinbase.cds.theme.CdsThemeProvider -import com.coinbase.cds.androidapp.theme.AcmeTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -42,30 +38,30 @@ class MainActivity : ComponentActivity() { setContent { var darkTheme by remember { mutableStateOf(false) } var customBrand by remember { mutableStateOf(false) } - var showGallery by remember { mutableStateOf(false) } + var route by remember { mutableStateOf(GalleryRoute.Home) } - // Theme and color scheme are independent axes, so they multiply instead of combining: - // two themes times two schemes is two one-line choices here, not a four-branch `when`. - val theme: CdsTheme = if (customBrand) AcmeTheme else CdsDefaultTheme + val theme = if (customBrand) AcmeTheme else CdsDefaultTheme val colorScheme = if (darkTheme) CdsColorScheme.Dark else CdsColorScheme.Light - BackHandler(enabled = showGallery) { showGallery = false } + BackHandler(enabled = route != GalleryRoute.Home) { + route = GalleryRoute.Home + } CdsThemeProvider(theme = theme, colorScheme = colorScheme) { - if (showGallery) { - CdsThemeGallery( - theme = theme, - colorScheme = colorScheme, - modifier = Modifier.fillMaxSize(), - onBack = { showGallery = false }, - ) - } else { - CdsSampleScreen( + Box( + modifier = Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) { + GalleryApp( + route = route, + onRouteChange = { route = it }, darkTheme = darkTheme, onToggleDarkTheme = { darkTheme = !darkTheme }, customBrand = customBrand, onToggleBrand = { customBrand = !customBrand }, - onShowGallery = { showGallery = true }, + theme = theme, + colorScheme = colorScheme, ) } } @@ -73,211 +69,88 @@ class MainActivity : ComponentActivity() { } } -/** - * A bespoke screen built on CDS theme tokens (color, space, radius, typography). CDS components - * (`Text`, `Button`, `SlideButton`) are temporarily internal for the first AAR release, so this - * screen uses Compose Foundation primitives plus tokens — the way a consumer would until those - * components return to the public API. - */ @Composable -fun CdsSampleScreen( +private fun GalleryApp( + route: GalleryRoute, + onRouteChange: (GalleryRoute) -> Unit, darkTheme: Boolean, onToggleDarkTheme: () -> Unit, customBrand: Boolean, onToggleBrand: () -> Unit, - onShowGallery: () -> Unit, + theme: com.coinbase.cds.theme.CdsTheme, + colorScheme: CdsColorScheme, modifier: Modifier = Modifier, ) { - // var slideConfirmed by remember { mutableStateOf(false) } - // LaunchedEffect(slideConfirmed) { - // if (slideConfirmed) { - // delay(1500) - // slideConfirmed = false - // } - // } - - Box( - modifier = modifier - .fillMaxSize() - .background(CdsTheme.colors.bg) - .systemBarsPadding() - .padding(CdsTheme.space.x3), - ) { - // Scrollable rather than a fixed-height assumption: a theme like Acme (bigger space/type - // scale) makes this content taller than the default theme's, and the screen should adapt - // rather than silently clip whatever doesn't fit the current theme's sizing. - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), - ) { - SampleText( - text = "Coinbase Design System", - style = CdsTheme.typography.title1, - ) - SampleText( - text = "Jetpack Compose port of the default theme.", - style = CdsTheme.typography.body, - color = CdsTheme.colors.fgMuted, - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CdsTheme.borderRadius.radius400)) - .background(CdsTheme.colors.bgSecondary) - .padding(CdsTheme.space.x2), - ) { - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5)) { - SampleText( - text = "Primary action surface", - style = CdsTheme.typography.headline, - ) - SampleText( - text = "This card's colors, spacing, corner radius, and type all come " + - "from CdsTheme.", - style = CdsTheme.typography.body, - color = CdsTheme.colors.fgMuted, - ) - SampleControl( - text = if (darkTheme) "Switch to light theme" else "Switch to dark theme", - onClick = onToggleDarkTheme, - ) - SampleControl( - text = if (customBrand) "Switch to default CDS theme" else "Switch to Acme brand theme", - onClick = onToggleBrand, - emphasized = false, - ) - SampleText( - text = "View theme gallery", - style = CdsTheme.typography.headline, - color = CdsTheme.colors.fgPrimary, - modifier = Modifier - .clickable(onClick = onShowGallery) - .padding(vertical = CdsTheme.space.x0_5), - ) - } - } - - // CDS components (Text / Button / SlideButton) are temporarily internal for the first - // AAR release. Restore this gallery when they return to the public API. - /* - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CdsTheme.borderRadius.radius400)) - .background(CdsTheme.colors.bgSecondary) - .padding(CdsTheme.space.x2), - ) { - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5)) { - Text( - text = "CDS Components", - font = CdsFontToken.Headline, - ) - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x0_5)) { - Text(text = "Display3 heading", font = CdsFontToken.Display3) - Text(text = "Title3 heading", font = CdsFontToken.Title3) - Text( - text = "Label1, in the theme's positive color", - font = CdsFontToken.Label1, - color = CdsTheme.colors.fgPositive, - ) - Text(text = "Caption is auto-uppercased", font = CdsFontToken.Caption) - Text(text = "Legal, muted and fine-print sized", font = CdsFontToken.Legal, color = CdsTheme.colors.fgMuted) - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), - verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), - ) { - Button(text = "Primary", onClick = {}, variant = ButtonVariant.Primary, size = ButtonSize.S) - Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary, size = ButtonSize.S) - Button(text = "Tertiary", onClick = {}, variant = ButtonVariant.Tertiary, size = ButtonSize.S) - Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive, size = ButtonSize.S) - Button(text = "Negative", onClick = {}, variant = ButtonVariant.Negative, size = ButtonSize.S) - } - SlideButton( - checked = slideConfirmed, - onCheckedChange = { slideConfirmed = it }, - uncheckedLabel = "Slide to confirm", - checkedLabel = "Confirming...", - ) - } - } - */ - } - } -} + when (route) { + GalleryRoute.Home -> HomeGalleryScreen( + darkTheme = darkTheme, + onToggleDarkTheme = onToggleDarkTheme, + customBrand = customBrand, + onToggleBrand = onToggleBrand, + onOpenThemeTokens = { onRouteChange(GalleryRoute.ThemeTokens) }, + onOpenComponent = { onRouteChange(GalleryRoute.Component(it)) }, + modifier = modifier, + ) -/** Theme-token text. CDS [com.coinbase.cds.components.text.Text] is temporarily internal. */ -@Composable -private fun SampleText( - text: String, - style: TextStyle, - modifier: Modifier = Modifier, - color: Color = CdsTheme.colors.fg, -) { - BasicText(text = text, modifier = modifier, style = style.copy(color = color)) -} + GalleryRoute.ThemeTokens -> CdsThemeGallery( + theme = theme, + colorScheme = colorScheme, + modifier = modifier + .fillMaxSize() + .testTag("gallery-destination-theme-tokens"), + onBack = { onRouteChange(GalleryRoute.Home) }, + ) -/** Theme-token control. CDS [com.coinbase.cds.components.button.Button] is temporarily internal. */ -@Composable -private fun SampleControl( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - emphasized: Boolean = true, -) { - val container = if (emphasized) CdsTheme.colors.bgPrimary else CdsTheme.colors.bgTertiary - val content = if (emphasized) CdsTheme.colors.fgInverse else CdsTheme.colors.fg - Box( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(CdsTheme.borderRadius.radius900)) - .background(container) - .clickable(onClick = onClick) - .padding(horizontal = CdsTheme.space.x3, vertical = CdsTheme.space.x1_5), - ) { - SampleText(text = text, style = CdsTheme.typography.headline, color = content) + is GalleryRoute.Component -> ComponentGalleryScreen( + destination = route.destination, + onBack = { onRouteChange(GalleryRoute.Home) }, + onNavigateToComponent = { onRouteChange(GalleryRoute.Component(it)) }, + modifier = modifier, + ) } } @Preview(showBackground = true) @Composable -fun CdsSampleScreenLightPreview() { +fun HomeGalleryScreenLightPreview() { CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { - CdsSampleScreen( + HomeGalleryScreen( darkTheme = false, onToggleDarkTheme = {}, customBrand = false, onToggleBrand = {}, - onShowGallery = {}, + onOpenThemeTokens = {}, + onOpenComponent = {}, ) } } @Preview(showBackground = true) @Composable -fun CdsSampleScreenDarkPreview() { +fun HomeGalleryScreenDarkPreview() { CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Dark) { - CdsSampleScreen( + HomeGalleryScreen( darkTheme = true, onToggleDarkTheme = {}, customBrand = false, onToggleBrand = {}, - onShowGallery = {}, + onOpenThemeTokens = {}, + onOpenComponent = {}, ) } } @Preview(showBackground = true) @Composable -fun CdsSampleScreenAcmeBrandPreview() { +fun HomeGalleryScreenAcmeBrandPreview() { CdsThemeProvider(theme = AcmeTheme, colorScheme = CdsColorScheme.Light) { - CdsSampleScreen( + HomeGalleryScreen( darkTheme = false, onToggleDarkTheme = {}, customBrand = true, onToggleBrand = {}, - onShowGallery = {}, + onOpenThemeTokens = {}, + onOpenComponent = {}, ) } } @@ -288,10 +161,18 @@ fun CdsThemeGalleryPreview() { CdsThemeGallery(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) } -// CDS Button is temporarily internal for the first AAR release. Restore this preview when it -// returns to the public API. -/* -@Preview(showBackground = true, heightDp = 620) +@Preview(showBackground = true, heightDp = 1200) +@Composable +fun ButtonGalleryScreenPreview() { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + ButtonGalleryScreen( + onBack = {}, + onNavigateToComponent = {}, + ) + } +} + +@Preview(showBackground = true, heightDp = 1200) @Composable fun ButtonShowcasePreview() { CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { @@ -301,22 +182,7 @@ fun ButtonShowcasePreview() { .background(CdsTheme.colors.bg) .padding(CdsTheme.space.x2), ) { - Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5)) { - Button(text = "Primary", onClick = {}, variant = ButtonVariant.Primary) - Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary) - Button(text = "Tertiary", onClick = {}, variant = ButtonVariant.Tertiary) - Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive) - Button(text = "Negative", onClick = {}, variant = ButtonVariant.Negative) - Button(text = "Transparent", onClick = {}, transparent = true) - Button(text = "Disabled", onClick = {}, enabled = false) - Button(text = "Loading", onClick = {}, loading = true) - Button(text = "Full width", onClick = {}, fullWidth = true) - Row(horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1)) { - Button(text = "Small", onClick = {}, size = ButtonSize.S) - Button(text = "XSmall", onClick = {}, size = ButtonSize.Xs) - } - } + ButtonGallerySection() } } } -*/ diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGalleryScreen.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGalleryScreen.kt new file mode 100644 index 0000000000..2a9e5d9d8d --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGalleryScreen.kt @@ -0,0 +1,26 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal fun ButtonGalleryScreen( + onBack: () -> Unit, + onNavigateToComponent: (GalleryDestination) -> Unit, + modifier: Modifier = Modifier, +) { + val destination = GalleryDestination.Button + GalleryScreenScaffold( + title = destination.title, + subtitle = destination.subtitle, + onBack = onBack, + modifier = modifier.systemBarsPadding(), + testTag = destination.testTag, + previousDestination = destination.previous(), + nextDestination = destination.next(), + onNavigateToComponent = onNavigateToComponent, + ) { + ButtonGallerySection() + } +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt new file mode 100644 index 0000000000..581234006e --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ButtonGallerySection.kt @@ -0,0 +1,164 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import com.coinbase.cds.components.button.Button +import com.coinbase.cds.components.button.ButtonSize +import com.coinbase.cds.components.button.ButtonVariant +import com.coinbase.cds.theme.CdsTheme + +@Composable +fun ButtonGallerySection(modifier: Modifier = Modifier) { + var clickCount by remember { mutableIntStateOf(0) } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), + ) { + GalleryText( + text = "Clicks on the interactive primary button: $clickCount", + style = CdsTheme.typography.body, + color = CdsTheme.colors.fgMuted, + ) + + GallerySubsectionTitle("Variants") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button( + text = "Primary", + onClick = { clickCount++ }, + modifier = Modifier.testTag("gallery-button-primary-interactive"), + ) + Button(text = "Secondary", onClick = {}, variant = ButtonVariant.Secondary) + Button(text = "Tertiary", onClick = {}, variant = ButtonVariant.Tertiary) + Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive) + Button(text = "Negative", onClick = {}, variant = ButtonVariant.Negative) + Button(text = "Inverse", onClick = {}, variant = ButtonVariant.Inverse) + } + + GallerySubsectionTitle("Transparent") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button(text = "Primary", onClick = {}, transparent = true) + Button(text = "Positive", onClick = {}, variant = ButtonVariant.Positive, transparent = true) + Button(text = "Inverse", onClick = {}, variant = ButtonVariant.Inverse, transparent = true) + } + + GallerySubsectionTitle("States") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button( + text = "Disabled", + onClick = {}, + enabled = false, + modifier = Modifier.testTag("gallery-button-disabled"), + ) + Button( + text = "Loading", + onClick = {}, + loading = true, + modifier = Modifier.testTag("gallery-button-loading"), + ) + Button( + text = "Transparent loading", + onClick = {}, + loading = true, + transparent = true, + variant = ButtonVariant.Secondary, + ) + } + + GallerySubsectionTitle("Sizes") + Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1)) { + Button(text = "Extra small", onClick = {}, size = ButtonSize.Xs) + Button(text = "Small", onClick = {}, size = ButtonSize.S) + Button(text = "Medium", onClick = {}, size = ButtonSize.M) + Button(text = "Large", onClick = {}, size = ButtonSize.L) + } + + GallerySubsectionTitle("Icons") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Button( + text = "Back", + onClick = {}, + startIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = true) }, + ) + Button( + text = "Next", + onClick = {}, + endIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = false) }, + ) + Button( + text = "Both", + onClick = {}, + startIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = true) }, + endIcon = { tint, size -> GalleryArrowIcon(tint, size, pointsLeft = false) }, + ) + } + + GallerySubsectionTitle("Layout") + Column(verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1)) { + Button( + text = "Full width via Modifier.fillMaxWidth()", + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + Button( + text = "Long label that should truncate when the button cannot grow any wider", + onClick = {}, + modifier = Modifier.fillMaxWidth(), + maxLines = 1, + ) + } + } +} + +@Composable +private fun GalleryArrowIcon(color: Color, iconSize: Dp, pointsLeft: Boolean) { + Canvas(modifier = Modifier.size(iconSize)) { + val strokeWidth = size.minDimension * 0.14f + val path = Path().apply { + if (pointsLeft) { + moveTo(size.width * 0.62f, size.height * 0.12f) + lineTo(size.width * 0.3f, size.height * 0.5f) + lineTo(size.width * 0.62f, size.height * 0.88f) + } else { + moveTo(size.width * 0.38f, size.height * 0.12f) + lineTo(size.width * 0.7f, size.height * 0.5f) + lineTo(size.width * 0.38f, size.height * 0.88f) + } + } + drawPath( + path = path, + color = color, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ComponentGalleryScreen.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ComponentGalleryScreen.kt new file mode 100644 index 0000000000..75b625b38a --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/ComponentGalleryScreen.kt @@ -0,0 +1,20 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal fun ComponentGalleryScreen( + destination: GalleryDestination, + onBack: () -> Unit, + onNavigateToComponent: (GalleryDestination) -> Unit, + modifier: Modifier = Modifier, +) { + when (destination) { + GalleryDestination.Button -> ButtonGalleryScreen( + onBack = onBack, + onNavigateToComponent = onNavigateToComponent, + modifier = modifier, + ) + } +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt index 41d282e89b..8266252e35 100644 --- a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryComponents.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -88,7 +89,33 @@ internal fun GalleryChevronLeftIcon(color: Color, iconSize: Dp, modifier: Modifi } } -/** An unlabeled square swatch, used for dense grids like the spectrum ramp. */ +/** A minimal right-pointing chevron for destination rows. */ +@Composable +internal fun GalleryChevronRightIcon(color: Color, iconSize: Dp, modifier: Modifier = Modifier) { + Canvas(modifier = modifier.size(iconSize)) { + val strokeWidth = size.minDimension * 0.14f + val path = Path().apply { + moveTo(size.width * 0.38f, size.height * 0.12f) + lineTo(size.width * 0.7f, size.height * 0.5f) + lineTo(size.width * 0.38f, size.height * 0.88f) + } + drawPath( + path = path, + color = color, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } +} + +@Composable +internal fun GallerySubsectionTitle(text: String) { + GalleryText( + text = text, + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + modifier = Modifier.padding(top = CdsTheme.space.x0_5), + ) +} @Composable internal fun GalleryColorChip(color: Color, size: Dp) { Box( diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryDestination.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryDestination.kt new file mode 100644 index 0000000000..494c85bda8 --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryDestination.kt @@ -0,0 +1,22 @@ +package com.coinbase.cds.androidapp.gallery + +/** + * Registered component galleries in display order. Visreg and manual QA navigate directly to a + * single destination instead of scrolling a monolithic home screen. + */ +internal enum class GalleryDestination( + val title: String, + val subtitle: String, + val testTag: String, +) { + Button( + title = "Button", + subtitle = "Variants, states, sizes, icons, and layout", + testTag = "gallery-component-button", + ), + ; + + fun previous(): GalleryDestination? = entries.getOrNull(ordinal - 1) + + fun next(): GalleryDestination? = entries.getOrNull(ordinal + 1) +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryNavigation.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryNavigation.kt new file mode 100644 index 0000000000..0bb5b6024c --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryNavigation.kt @@ -0,0 +1,195 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.coinbase.cds.theme.CdsTheme + +/** + * Shared chrome for full-screen gallery destinations: back affordance, title, optional prev/next + * pager between component galleries, and a single scrollable content region. + */ +@Composable +internal fun GalleryScreenScaffold( + title: String, + subtitle: String?, + onBack: () -> Unit, + modifier: Modifier = Modifier, + testTag: String? = null, + previousDestination: GalleryDestination? = null, + nextDestination: GalleryDestination? = null, + onNavigateToComponent: ((GalleryDestination) -> Unit)? = null, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(CdsTheme.colors.bg) + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier), + ) { + GalleryScreenHeader( + title = title, + subtitle = subtitle, + onBack = onBack, + ) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = CdsTheme.space.x3) + .padding(bottom = CdsTheme.space.x2), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), + ) { + content() + } + if (onNavigateToComponent != null && (previousDestination != null || nextDestination != null)) { + GalleryComponentPager( + previousDestination = previousDestination, + nextDestination = nextDestination, + onNavigateToComponent = onNavigateToComponent, + ) + } + } +} + +@Composable +private fun GalleryScreenHeader( + title: String, + subtitle: String?, + onBack: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CdsTheme.space.x3) + .padding(top = CdsTheme.space.x3, bottom = CdsTheme.space.x2), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x0_5), + modifier = Modifier + .clickable(onClick = onBack) + .padding(vertical = CdsTheme.space.x0_5) + .testTag("gallery-nav-back"), + ) { + GalleryChevronLeftIcon(color = CdsTheme.colors.fgPrimary, iconSize = 16.dp) + GalleryText( + text = "Back", + style = CdsTheme.typography.label1, + color = CdsTheme.colors.fgPrimary, + ) + } + GalleryText(text = title, style = CdsTheme.typography.title1, color = CdsTheme.colors.fg) + if (subtitle != null) { + GalleryText( + text = subtitle, + style = CdsTheme.typography.body, + color = CdsTheme.colors.fgMuted, + ) + } + } +} + +@Composable +internal fun GalleryDestinationRow( + title: String, + subtitle: String, + onClick: () -> Unit, + testTag: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CdsTheme.borderRadius.radius300)) + .background(CdsTheme.colors.bgSecondary) + .clickable(onClick = onClick) + .padding(CdsTheme.space.x2) + .testTag(testTag), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x0_5), + ) { + GalleryText(text = title, style = CdsTheme.typography.headline, color = CdsTheme.colors.fg) + GalleryText(text = subtitle, style = CdsTheme.typography.body, color = CdsTheme.colors.fgMuted) + } + GalleryChevronRightIcon(color = CdsTheme.colors.fgMuted, iconSize = 16.dp) + } +} + +@Composable +private fun GalleryComponentPager( + previousDestination: GalleryDestination?, + nextDestination: GalleryDestination?, + onNavigateToComponent: (GalleryDestination) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(CdsTheme.colors.bgSecondary) + .padding(horizontal = CdsTheme.space.x3, vertical = CdsTheme.space.x1_5), + horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1), + ) { + GalleryPagerButton( + label = previousDestination?.title?.let { "← $it" } ?: "← Previous", + enabled = previousDestination != null, + onClick = { previousDestination?.let(onNavigateToComponent) }, + modifier = Modifier + .weight(1f) + .testTag("gallery-nav-previous"), + ) + GalleryPagerButton( + label = nextDestination?.title?.let { "$it →" } ?: "Next →", + enabled = nextDestination != null, + onClick = { nextDestination?.let(onNavigateToComponent) }, + modifier = Modifier + .weight(1f) + .testTag("gallery-nav-next"), + ) + } +} + +@Composable +private fun GalleryPagerButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = if (enabled) CdsTheme.colors.bgTertiary else CdsTheme.colors.bgSecondary + val foreground = if (enabled) CdsTheme.colors.fg else CdsTheme.colors.fgMuted + GalleryText( + text = label, + style = CdsTheme.typography.label1, + color = foreground, + modifier = modifier + .clip(RoundedCornerShape(CdsTheme.borderRadius.radius300)) + .background(background) + .then( + if (enabled) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + }, + ) + .padding(vertical = CdsTheme.space.x1_5, horizontal = CdsTheme.space.x1), + ) +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryRoute.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryRoute.kt new file mode 100644 index 0000000000..0358427241 --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/GalleryRoute.kt @@ -0,0 +1,9 @@ +package com.coinbase.cds.androidapp.gallery + +internal sealed interface GalleryRoute { + data object Home : GalleryRoute + + data object ThemeTokens : GalleryRoute + + data class Component(val destination: GalleryDestination) : GalleryRoute +} diff --git a/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/HomeGalleryScreen.kt b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/HomeGalleryScreen.kt new file mode 100644 index 0000000000..576b4d476f --- /dev/null +++ b/apps/android-app/src/main/java/com/coinbase/cds/androidapp/gallery/HomeGalleryScreen.kt @@ -0,0 +1,107 @@ +package com.coinbase.cds.androidapp.gallery + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import com.coinbase.cds.components.button.Button +import com.coinbase.cds.components.button.ButtonVariant +import com.coinbase.cds.theme.CdsTheme + +@Composable +internal fun HomeGalleryScreen( + darkTheme: Boolean, + onToggleDarkTheme: () -> Unit, + customBrand: Boolean, + onToggleBrand: () -> Unit, + onOpenThemeTokens: () -> Unit, + onOpenComponent: (GalleryDestination) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(CdsTheme.colors.bg) + .systemBarsPadding() + .padding(CdsTheme.space.x3) + .testTag("gallery-home"), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x2), + ) { + GalleryText( + text = "Coinbase Design System", + style = CdsTheme.typography.title1, + color = CdsTheme.colors.fg, + ) + GalleryText( + text = "Android gallery for theme tokens and CDS components.", + style = CdsTheme.typography.body, + color = CdsTheme.colors.fgMuted, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CdsTheme.borderRadius.radius400)) + .background(CdsTheme.colors.bgSecondary) + .padding(CdsTheme.space.x2), + verticalArrangement = Arrangement.spacedBy(CdsTheme.space.x1_5), + ) { + GalleryText( + text = "Theme", + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + ) + Button( + text = if (darkTheme) "Switch to light theme" else "Switch to dark theme", + onClick = onToggleDarkTheme, + modifier = Modifier.fillMaxWidth(), + ) + Button( + text = if (customBrand) { + "Switch to default CDS theme" + } else { + "Switch to Acme brand theme" + }, + onClick = onToggleBrand, + modifier = Modifier.fillMaxWidth(), + variant = ButtonVariant.Tertiary, + ) + } + + GalleryText( + text = "Galleries", + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + ) + + GalleryDestinationRow( + title = "Theme tokens", + subtitle = "Colors, spacing, typography, and the full token scale", + onClick = onOpenThemeTokens, + testTag = "gallery-destination-theme-tokens", + ) + + GalleryText( + text = "Components", + style = CdsTheme.typography.headline, + color = CdsTheme.colors.fg, + ) + + GalleryDestination.entries.forEach { destination -> + GalleryDestinationRow( + title = destination.title, + subtitle = destination.subtitle, + onClick = { onOpenComponent(destination) }, + testTag = destination.testTag, + ) + } + } +} diff --git a/apps/ios-gallery/Sources/ButtonGalleryView.swift b/apps/ios-gallery/Sources/ButtonGalleryView.swift new file mode 100644 index 0000000000..f6f47b5414 --- /dev/null +++ b/apps/ios-gallery/Sources/ButtonGalleryView.swift @@ -0,0 +1,73 @@ +@testable import CDSDesignSystem +import SwiftUI + +struct ButtonGalleryView: View { + @Environment(\.cdsTheme) private var cds + let onBack: () -> Void + let onNavigateToComponent: (GalleryDestination) -> Void + + private let destination = GalleryDestination.button + + var body: some View { + GalleryScreenScaffold( + title: destination.title, + subtitle: destination.subtitle, + onBack: onBack, + testTag: destination.testTag, + previousDestination: destination.previous(), + nextDestination: destination.next(), + onNavigateToComponent: onNavigateToComponent + ) { + VStack(alignment: .leading, spacing: cds.space.x1) { + Text("ButtonStyle").cdsText(.label1, color: cds.colors.fgMuted) + Button("Primary") {} + .buttonStyle(.cds(.primary)) + Button("Secondary") {} + .buttonStyle(.cds(.secondary)) + Button("Tertiary") {} + .buttonStyle(.cds(.tertiary)) + Button("Positive") {} + .buttonStyle(.cds(.positive)) + Button("Negative") {} + .buttonStyle(.cds(.negative)) + Button("Ghost") {} + .buttonStyle(.cds(.primary, transparent: true)) + Button("Disabled") {} + .buttonStyle(.cds(.primary)) + .disabled(true) + Button("Loading") {} + .buttonStyle(.cds(.primary, loading: true)) + Button("Full width") {} + .buttonStyle(.cds(.primary, fullWidth: true)) + + Text("CDSButtonLabel (icon spacing + tint)").cdsText(.label1, color: cds.colors.fgMuted) + .padding(.top, cds.space.x2) + Button(action: {}) { + CDSButtonLabel("Continue", trailing: Image(systemName: "chevron.right")) + } + .buttonStyle(.cds(.primary)) + Button(action: {}) { + CDSButtonLabel("Add", leading: Image(systemName: "plus")) + } + .buttonStyle(.cds(.secondary)) + Button(action: {}) { + CDSButtonLabel( + "Wallet", + leading: Image(systemName: "creditcard"), + trailing: Image(systemName: "chevron.right") + ) + } + .buttonStyle(.cds(.tertiary)) + Button(action: {}) { + CDSButtonLabel("Continue", trailing: Image(systemName: "chevron.right")) + } + .buttonStyle(.cds(.primary, size: .s)) + Button(action: {}) { + CDSButtonLabel("Disabled", trailing: Image(systemName: "chevron.right")) + } + .buttonStyle(.cds(.primary)) + .disabled(true) + } + } + } +} diff --git a/apps/ios-gallery/Sources/ComponentGalleryView.swift b/apps/ios-gallery/Sources/ComponentGalleryView.swift new file mode 100644 index 0000000000..ad50c5ba7f --- /dev/null +++ b/apps/ios-gallery/Sources/ComponentGalleryView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct ComponentGalleryView: View { + let destination: GalleryDestination + let onBack: () -> Void + let onNavigateToComponent: (GalleryDestination) -> Void + + var body: some View { + switch destination { + case .button: + ButtonGalleryView( + onBack: onBack, + onNavigateToComponent: onNavigateToComponent + ) + case .otherComponents: + OtherComponentsGalleryView( + onBack: onBack, + onNavigateToComponent: onNavigateToComponent + ) + } + } +} diff --git a/apps/ios-gallery/Sources/ComponentsGallery.swift b/apps/ios-gallery/Sources/ComponentsGallery.swift deleted file mode 100644 index beab9ec6ed..0000000000 --- a/apps/ios-gallery/Sources/ComponentsGallery.swift +++ /dev/null @@ -1,197 +0,0 @@ -@testable import CDSDesignSystem -import SwiftUI - -/// Button styles, SlideButton, and the inverted theme. The components are `internal`, reached via -/// `@testable import`. -struct ComponentsGallery: View { - @Environment(\.cdsTheme) private var cds - @State private var slideChecked = false - - var body: some View { - SectionCard( - "Components", - subtitle: "ButtonStyle · SlideButton · inverted theme" - ) { - VStack(alignment: .leading, spacing: cds.space.x3) { - buttons - slideButton - invertedDemo - } - } - } - - private var buttons: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { - Text("ButtonStyle").cdsText(.label1, color: cds.colors.fgMuted) - Button("Primary") {} - .buttonStyle(.cds(.primary)) - Button("Secondary") {} - .buttonStyle(.cds(.secondary)) - Button("Tertiary") {} - .buttonStyle(.cds(.tertiary)) - Button("Positive") {} - .buttonStyle(.cds(.positive)) - Button("Negative") {} - .buttonStyle(.cds(.negative)) - Button("Ghost") {} - .buttonStyle(.cds(.primary, transparent: true)) - Button("Disabled") {} - .buttonStyle(.cds(.primary)) - .disabled(true) - Button("Loading") {} - .buttonStyle(.cds(.primary, loading: true)) - Button("Full width") {} - .buttonStyle(.cds(.primary, fullWidth: true)) - - Text("CDSButtonLabel (icon spacing + tint)").cdsText(.label1, color: cds.colors.fgMuted) - .padding(.top, cds.space.x2) - Button(action: {}) { - CDSButtonLabel("Continue", trailing: Image(systemName: "chevron.right")) - } - .buttonStyle(.cds(.primary)) - Button(action: {}) { - CDSButtonLabel("Add", leading: Image(systemName: "plus")) - } - .buttonStyle(.cds(.secondary)) - Button(action: {}) { - CDSButtonLabel( - "Wallet", - leading: Image(systemName: "creditcard"), - trailing: Image(systemName: "chevron.right") - ) - } - .buttonStyle(.cds(.tertiary)) - Button(action: {}) { - CDSButtonLabel("Continue", trailing: Image(systemName: "chevron.right")) - } - .buttonStyle(.cds(.primary, size: .s)) - Button(action: {}) { - CDSButtonLabel("Disabled", trailing: Image(systemName: "chevron.right")) - } - .buttonStyle(.cds(.primary)) - .disabled(true) - } - } - - private var slideButton: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { - Text("SlideButton").cdsText(.label1, color: cds.colors.fgMuted) - SlideButton( - checked: $slideChecked, - uncheckedLabel: "Slide to confirm", - checkedLabel: "Confirming…" - ) - Button("Reset slider") { slideChecked = false } - .buttonStyle(.cds(.secondary, size: .s)) - } - } - - /// Same content rendered under `InvertedThemeProvider`, which flips the scheme for its subtree. - private var invertedDemo: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { - Text("InvertedThemeProvider").cdsText(.label1, color: cds.colors.fgMuted) - InvertedThemeProvider { - InvertedCard() - } - } - } -} - -/// SwiftUI `Text` styled with `.cdsText`. -struct TextGallery: View { - @Environment(\.cdsTheme) private var cds - - var body: some View { - SectionCard("Text", subtitle: "Text + .cdsText") { - VStack(alignment: .leading, spacing: cds.space.x1) { - Text("Default foreground").cdsText(.body) - Text("Muted foreground").cdsText(.body, color: cds.colors.fgMuted) - Text("Underlined").cdsText(.body, underline: true) - Text("Monospace 1234567890").cdsText(.body, mono: true) - Text("Disabled").cdsText(.body).disabled(true) - Text("Caption").cdsText(.caption) - } - } - } -} - -/// SwiftUI `ProgressView` styled with `.progressViewStyle(.cds)`. -struct ProgressGallery: View { - @Environment(\.cdsTheme) private var cds - - var body: some View { - SectionCard("ProgressView", subtitle: "ProgressView + .progressViewStyle(.cds)") { - HStack(spacing: cds.space.x3) { - ProgressView() - .progressViewStyle(.cds(.s)) - ProgressView() - .progressViewStyle(.cds(.m)) - ProgressView() - .progressViewStyle(.cds(.l)) - ProgressView(value: 0.65) - .progressViewStyle(.cds(.l, color: cds.colors.fgPrimary)) - } - } - } -} - -/// SwiftUI `Toggle` styled with `.toggleStyle(.cds)`. -struct ToggleGallery: View { - @Environment(\.cdsTheme) private var cds - @State private var notificationsOn = true - @State private var biometricsOn = false - @State private var sellOn = true - - var body: some View { - SectionCard("Toggle", subtitle: "Toggle + .toggleStyle(.cds)") { - VStack(alignment: .leading, spacing: cds.space.x1) { - Toggle("Notifications (primary)", isOn: $notificationsOn) - .toggleStyle(.cds(.primary)) - Toggle("Biometrics (positive)", isOn: $biometricsOn) - .toggleStyle(.cds(.positive)) - Toggle("Sell (negative)", isOn: $sellOn) - .toggleStyle(.cds(.negative)) - Toggle("Disabled", isOn: .constant(true)) - .toggleStyle(.cds(.primary)) - .disabled(true) - } - } - } -} - -/// SwiftUI `.alert`, the OS dialog, shown for contrast with CDS styling. -struct AlertGallery: View { - @Environment(\.cdsTheme) private var cds - @State private var showDeleteAlert = false - - var body: some View { - SectionCard("Alert", subtitle: "System .alert") { - VStack(alignment: .leading, spacing: cds.space.x1) { - Text("OS dialog, not the CDS Alert component.") - .cdsText(.body, color: cds.colors.fgMuted) - Button("Show system alert") { showDeleteAlert = true } - .buttonStyle(.cds(.negative)) - .alert("Delete wallet?", isPresented: $showDeleteAlert) { - Button("Delete", role: .destructive) {} - Button("Cancel", role: .cancel) {} - } message: { - SwiftUI.Text("This cannot be undone.") - } - } - } - } -} - -/// Reads the (inverted) theme from the environment so its background/foreground come from the -/// flipped scheme. -private struct InvertedCard: View { - @Environment(\.cdsTheme) private var cds - - var body: some View { - Text("Content on the opposite scheme").cdsText(.body) - .padding(cds.space.x2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(cds.colors.bg) - .cdsBorderedCard(radius: cds.borderRadius.radius300) - } -} diff --git a/apps/ios-gallery/Sources/GalleryDestination.swift b/apps/ios-gallery/Sources/GalleryDestination.swift new file mode 100644 index 0000000000..68b23400e3 --- /dev/null +++ b/apps/ios-gallery/Sources/GalleryDestination.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Registered component galleries in display order. Visreg and manual QA navigate directly to a +/// single destination instead of scrolling a monolithic home screen. +enum GalleryDestination: String, CaseIterable, Identifiable { + case button + case otherComponents + + var id: String { rawValue } + + var title: String { + switch self { + case .button: return "Button" + case .otherComponents: return "Other components" + } + } + + var subtitle: String { + switch self { + case .button: return "Variants, states, sizes, and layout" + case .otherComponents: return "Text, SlideButton, ProgressView, Toggle, Alert, and inverted theme" + } + } + + var testTag: String { + switch self { + case .button: return "gallery-component-button" + case .otherComponents: return "gallery-component-other" + } + } + + func previous() -> GalleryDestination? { + let all = Self.allCases + guard let index = all.firstIndex(of: self), index > 0 else { return nil } + return all[index - 1] + } + + func next() -> GalleryDestination? { + let all = Self.allCases + guard let index = all.firstIndex(of: self), index < all.count - 1 else { return nil } + return all[index + 1] + } +} + +enum GalleryRoute: Equatable { + case home + case themeTokens + case component(GalleryDestination) +} diff --git a/apps/ios-gallery/Sources/GalleryNavigation.swift b/apps/ios-gallery/Sources/GalleryNavigation.swift new file mode 100644 index 0000000000..38e2f30198 --- /dev/null +++ b/apps/ios-gallery/Sources/GalleryNavigation.swift @@ -0,0 +1,149 @@ +@testable import CDSDesignSystem +import SwiftUI + +/// Shared chrome for full-screen gallery destinations: back affordance, title, optional prev/next +/// pager between component galleries, and a single scrollable content region. +struct GalleryScreenScaffold: View { + @Environment(\.cdsTheme) private var cds + let title: String + let subtitle: String? + let onBack: () -> Void + let testTag: String? + let previousDestination: GalleryDestination? + let nextDestination: GalleryDestination? + let onNavigateToComponent: ((GalleryDestination) -> Void)? + @ViewBuilder let content: Content + + init( + title: String, + subtitle: String? = nil, + onBack: @escaping () -> Void, + testTag: String? = nil, + previousDestination: GalleryDestination? = nil, + nextDestination: GalleryDestination? = nil, + onNavigateToComponent: ((GalleryDestination) -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.onBack = onBack + self.testTag = testTag + self.previousDestination = previousDestination + self.nextDestination = nextDestination + self.onNavigateToComponent = onNavigateToComponent + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + ScrollView { + VStack(alignment: .leading, spacing: cds.space.x2) { + content + } + .padding(.horizontal, cds.space.x3) + .padding(.bottom, cds.space.x2) + } + if onNavigateToComponent != nil, + previousDestination != nil || nextDestination != nil { + componentPager + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(cds.colors.bg) + .accessibilityIdentifier(testTag ?? "") + } + + private var header: some View { + VStack(alignment: .leading, spacing: cds.space.x1) { + Button(action: onBack) { + HStack(spacing: cds.space.x0_5) { + Image(systemName: "chevron.left") + .font(.caption.weight(.semibold)) + Text("Back").cdsText(.label1, color: cds.colors.fgPrimary) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("gallery-nav-back") + + Text(title).cdsText(.title1) + if let subtitle { + Text(subtitle).cdsText(.body, color: cds.colors.fgMuted) + } + } + .padding(.horizontal, cds.space.x3) + .padding(.top, cds.space.x3) + .padding(.bottom, cds.space.x2) + } + + private var componentPager: some View { + HStack(spacing: cds.space.x1) { + GalleryPagerButton( + label: previousDestination.map { "← \($0.title)" } ?? "← Previous", + enabled: previousDestination != nil, + action: { previousDestination.map { onNavigateToComponent?($0) } } + ) + .accessibilityIdentifier("gallery-nav-previous") + + GalleryPagerButton( + label: nextDestination.map { "\($0.title) →" } ?? "Next →", + enabled: nextDestination != nil, + action: { nextDestination.map { onNavigateToComponent?($0) } } + ) + .accessibilityIdentifier("gallery-nav-next") + } + .padding(.horizontal, cds.space.x3) + .padding(.vertical, cds.space.x1_5) + .background(cds.colors.bgSecondary) + } +} + +struct GalleryDestinationRow: View { + @Environment(\.cdsTheme) private var cds + let title: String + let subtitle: String + let testTag: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .center, spacing: cds.space.x1) { + VStack(alignment: .leading, spacing: cds.space.x0_5) { + Text(title).cdsText(.headline) + Text(subtitle).cdsText(.body, color: cds.colors.fgMuted) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(cds.colors.fgMuted) + } + .padding(cds.space.x2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(cds.colors.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: cds.borderRadius.radius300)) + } + .buttonStyle(.plain) + .accessibilityIdentifier(testTag) + } +} + +private struct GalleryPagerButton: View { + @Environment(\.cdsTheme) private var cds + let label: String + let enabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .cdsText(.label1, color: enabled ? cds.colors.fg : cds.colors.fgMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, cds.space.x1_5) + .padding(.horizontal, cds.space.x1) + .background(enabled ? cds.colors.bgTertiary : cds.colors.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: cds.borderRadius.radius300)) + } + .buttonStyle(.plain) + .disabled(!enabled) + } +} diff --git a/apps/ios-gallery/Sources/HomeGalleryView.swift b/apps/ios-gallery/Sources/HomeGalleryView.swift new file mode 100644 index 0000000000..86a05a01c3 --- /dev/null +++ b/apps/ios-gallery/Sources/HomeGalleryView.swift @@ -0,0 +1,66 @@ +@testable import CDSDesignSystem +import SwiftUI + +struct HomeGalleryView: View { + @Environment(\.cdsTheme) private var cds + @Binding var scheme: SchemeChoice + @Binding var theme: ThemeChoice + let onOpenThemeTokens: () -> Void + let onOpenComponent: (GalleryDestination) -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: cds.space.x2) { + Text("Coinbase Design System").cdsText(.title1) + Text("iOS gallery for theme tokens and CDS components.") + .cdsText(.body, color: cds.colors.fgMuted) + + themeControls + + Text("Galleries").cdsText(.headline) + + GalleryDestinationRow( + title: "Theme tokens", + subtitle: "Colors, spacing, typography, and the full token scale", + testTag: "gallery-destination-theme-tokens", + action: onOpenThemeTokens + ) + + Text("Components").cdsText(.headline) + + ForEach(GalleryDestination.allCases) { destination in + GalleryDestinationRow( + title: destination.title, + subtitle: destination.subtitle, + testTag: destination.testTag, + action: { onOpenComponent(destination) } + ) + } + } + .padding(cds.space.x3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(cds.colors.bg) + .accessibilityIdentifier("gallery-home") + } + + private var themeControls: some View { + VStack(alignment: .leading, spacing: cds.space.x1_5) { + Text("Theme").cdsText(.headline) + + Picker("Theme", selection: $theme) { + ForEach(ThemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + + Picker("Color scheme", selection: $scheme) { + ForEach(SchemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + } + .padding(cds.space.x2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(cds.colors.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: cds.borderRadius.radius400)) + } +} diff --git a/apps/ios-gallery/Sources/OtherComponentsGalleryView.swift b/apps/ios-gallery/Sources/OtherComponentsGalleryView.swift new file mode 100644 index 0000000000..131f1562d2 --- /dev/null +++ b/apps/ios-gallery/Sources/OtherComponentsGalleryView.swift @@ -0,0 +1,128 @@ +@testable import CDSDesignSystem +import SwiftUI + +struct OtherComponentsGalleryView: View { + @Environment(\.cdsTheme) private var cds + @State private var slideChecked = false + @State private var notificationsOn = true + @State private var biometricsOn = false + @State private var sellOn = true + @State private var showDeleteAlert = false + let onBack: () -> Void + let onNavigateToComponent: (GalleryDestination) -> Void + + private let destination = GalleryDestination.otherComponents + + var body: some View { + GalleryScreenScaffold( + title: destination.title, + subtitle: destination.subtitle, + onBack: onBack, + testTag: destination.testTag, + previousDestination: destination.previous(), + nextDestination: destination.next(), + onNavigateToComponent: onNavigateToComponent + ) { + text + slideButton + progressView + toggle + alert + invertedDemo + } + } + + private var text: some View { + VStack(alignment: .leading, spacing: cds.space.x1) { + Text("Text").cdsText(.label1, color: cds.colors.fgMuted) + Text("Default foreground").cdsText(.body) + Text("Muted foreground").cdsText(.body, color: cds.colors.fgMuted) + Text("Underlined").cdsText(.body, underline: true) + Text("Monospace 1234567890").cdsText(.body, mono: true) + Text("Disabled").cdsText(.body).disabled(true) + } + } + + private var slideButton: some View { + VStack(alignment: .leading, spacing: cds.space.x1) { + Text("SlideButton").cdsText(.label1, color: cds.colors.fgMuted) + SlideButton( + checked: $slideChecked, + uncheckedLabel: "Slide to confirm", + checkedLabel: "Confirming…" + ) + Button("Reset slider") { slideChecked = false } + .buttonStyle(.cds(.secondary, size: .s)) + } + } + + private var progressView: some View { + VStack(alignment: .leading, spacing: cds.space.x1) { + Text("ProgressView").cdsText(.label1, color: cds.colors.fgMuted) + HStack(spacing: cds.space.x3) { + ProgressView() + .progressViewStyle(.cds(.s)) + ProgressView() + .progressViewStyle(.cds(.m)) + ProgressView() + .progressViewStyle(.cds(.l)) + ProgressView(value: 0.65) + .progressViewStyle(.cds(.l, color: cds.colors.fgPrimary)) + } + } + } + + private var toggle: some View { + VStack(alignment: .leading, spacing: cds.space.x1) { + Text("Toggle").cdsText(.label1, color: cds.colors.fgMuted) + Toggle("Notifications (primary)", isOn: $notificationsOn) + .toggleStyle(.cds(.primary)) + Toggle("Biometrics (positive)", isOn: $biometricsOn) + .toggleStyle(.cds(.positive)) + Toggle("Sell (negative)", isOn: $sellOn) + .toggleStyle(.cds(.negative)) + Toggle("Disabled", isOn: .constant(true)) + .toggleStyle(.cds(.primary)) + .disabled(true) + } + } + + private var alert: some View { + VStack(alignment: .leading, spacing: cds.space.x1) { + Text("Alert (system .alert)").cdsText(.label1, color: cds.colors.fgMuted) + Text("OS dialog, not the CDS Alert component.") + .cdsText(.body, color: cds.colors.fgMuted) + Button("Show system alert") { showDeleteAlert = true } + .buttonStyle(.cds(.negative)) + .alert("Delete wallet?", isPresented: $showDeleteAlert) { + Button("Delete", role: .destructive) {} + Button("Cancel", role: .cancel) {} + } message: { + SwiftUI.Text("This cannot be undone.") + } + } + } + + private var invertedDemo: some View { + VStack(alignment: .leading, spacing: cds.space.x1) { + Text("InvertedThemeProvider").cdsText(.label1, color: cds.colors.fgMuted) + InvertedThemeProvider { + InvertedCard() + } + } + } +} + +/// Reads the (inverted) theme from the environment so its background/foreground come from the +/// flipped scheme. +private struct InvertedCard: View { + @Environment(\.cdsTheme) private var cds + + var body: some View { + Text("Content on the opposite scheme").cdsText(.body) + .padding(cds.space.x2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(cds.colors.bg) + .cdsBorderedCard(radius: cds.borderRadius.radius300) + } +} diff --git a/apps/ios-gallery/Sources/RootGalleryView.swift b/apps/ios-gallery/Sources/RootGalleryView.swift index 6381ba202e..298dd9fbdc 100644 --- a/apps/ios-gallery/Sources/RootGalleryView.swift +++ b/apps/ios-gallery/Sources/RootGalleryView.swift @@ -38,65 +38,45 @@ enum ThemeChoice: String, CaseIterable, Identifiable { struct RootGalleryView: View { @State private var scheme: SchemeChoice = .system @State private var theme: ThemeChoice = .cds + @State private var route: GalleryRoute = .home var body: some View { CDSThemeProvider(theme: theme.set, colorScheme: scheme.colorScheme) { - GalleryScreen(scheme: $scheme, theme: $theme) + GalleryApp( + route: route, + onRouteChange: { route = $0 }, + scheme: $scheme, + theme: $theme + ) } } } -/// The scrolling gallery itself, plus the theme/scheme controls. Lives under the provider so -/// `@Environment(\.cdsTheme)` resolves to the current selection. -struct GalleryScreen: View { +private struct GalleryApp: View { + let route: GalleryRoute + let onRouteChange: (GalleryRoute) -> Void @Binding var scheme: SchemeChoice @Binding var theme: ThemeChoice - @Environment(\.cdsTheme) private var cds var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: cds.space.x3) { - controls - - ColorGallery() - IllustrationGallery() - SpectrumGallery() - TypographyGallery() - SpacingGallery() - RadiusGallery() - BorderWidthGallery() - SizesGallery() - ShadowGallery() - ComponentsGallery() - TextGallery() - ToggleGallery() - AlertGallery() - ProgressGallery() - } - .padding(cds.space.x2) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background(cds.colors.bg) - } - - private var controls: some View { - VStack(alignment: .leading, spacing: cds.space.x1) { - Text("CDS iOS — Theme Gallery").cdsText(.title2) - Text("Live view of every token scale in the active theme.").cdsText(.label2, color: cds.colors.fgMuted) - - Picker("Theme", selection: $theme) { - ForEach(ThemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } - } - .pickerStyle(.segmented) - - Picker("Color scheme", selection: $scheme) { - ForEach(SchemeChoice.allCases) { SwiftUI.Text($0.label).tag($0) } + Group { + switch route { + case .home: + HomeGalleryView( + scheme: $scheme, + theme: $theme, + onOpenThemeTokens: { onRouteChange(.themeTokens) }, + onOpenComponent: { onRouteChange(.component($0)) } + ) + case .themeTokens: + ThemeTokensGalleryView(onBack: { onRouteChange(.home) }) + case .component(let destination): + ComponentGalleryView( + destination: destination, + onBack: { onRouteChange(.home) }, + onNavigateToComponent: { onRouteChange(.component($0)) } + ) } - .pickerStyle(.segmented) } - .padding(cds.space.x2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(cds.colors.bgSecondary) - .clipShape(RoundedRectangle(cornerRadius: cds.borderRadius.radius300)) } } diff --git a/apps/ios-gallery/Sources/ThemeTokensGalleryView.swift b/apps/ios-gallery/Sources/ThemeTokensGalleryView.swift new file mode 100644 index 0000000000..a712ea4e9f --- /dev/null +++ b/apps/ios-gallery/Sources/ThemeTokensGalleryView.swift @@ -0,0 +1,26 @@ +@testable import CDSDesignSystem +import SwiftUI + +struct ThemeTokensGalleryView: View { + @Environment(\.cdsTheme) private var cds + let onBack: () -> Void + + var body: some View { + GalleryScreenScaffold( + title: "Theme tokens", + subtitle: "Live view of every token scale in the active theme.", + onBack: onBack, + testTag: "gallery-destination-theme-tokens" + ) { + ColorGallery() + IllustrationGallery() + SpectrumGallery() + TypographyGallery() + SpacingGallery() + RadiusGallery() + BorderWidthGallery() + SizesGallery() + ShadowGallery() + } + } +} diff --git a/packages/cds-android/AGENTS.md b/packages/cds-android/AGENTS.md index 5698a035fa..172d92312a 100644 --- a/packages/cds-android/AGENTS.md +++ b/packages/cds-android/AGENTS.md @@ -13,16 +13,18 @@ so the compiler rejects any declaration whose visibility was inherited rather th - Default to `internal` or `private`. Reach for `public` only when the symbol is meant for customers. +- **Hyrum's Law applies:** consumers will depend on any `public` symbol even if undocumented. + Style resolvers (`*Colors`, `*Metrics`), assembly composables, and helpers stay `internal`. - When the compiler tells you to add a visibility modifier, that is the moment to decide whether the symbol belongs on the customer API - not a formality to satisfy with `public`. - **Never widen visibility to make `apps/android-app` compile.** The demo app is a consumer. If it cannot express something with the public API, either the API is genuinely missing something or the app is doing something it should not. - Changing the signature of an existing `public` declaration is a breaking change. -- The public surface lives in `com.coinbase.cds.theme`. Components under - `com.coinbase.cds.components.*` are temporarily `internal` for the first release — they were - experiments and are not customer API yet. Anything under `components/internal/` stays off-limits - to consumers by construction. +- The public surface lives in `com.coinbase.cds.theme` and `com.coinbase.cds.components.button`, + `com.coinbase.cds.interaction`. Components such as `Text` and `SlideButton` are temporarily + `internal` for the first release — they were experiments and are not customer API yet. Anything + under `components/internal/` stays off-limits to consumers by construction. ## Theming @@ -44,6 +46,10 @@ Follow the `jetpack-best-practices` skill (the official AOSP Compose API guideli that get violated most often here: every element accepts and respects a `Modifier` parameter, `Modifier` is the first optional parameter, and composables that emit UI return `Unit`. +When porting a component from `packages/mobile` or auditing an existing Android port for mobile +parity, load the `cds-rn-to-compose` skill. It covers discovery, RN→Compose mapping, interaction +hoisting, token usage, testing, and the audit checklist. + ## Boundaries with the rest of the monorepo - Do not add Yarn/npm dependencies to this package. Its `package.json` is a stub that exists only diff --git a/packages/cds-android/CHANGELOG.md b/packages/cds-android/CHANGELOG.md index 549e4d4a22..2068919152 100644 --- a/packages/cds-android/CHANGELOG.md +++ b/packages/cds-android/CHANGELOG.md @@ -24,8 +24,11 @@ Its versions are independent of the `@coinbase/cds-*` npm packages. - Initial public theme API: `CdsTheme`, the `cdsTheme` builder, `CdsThemeProvider`, `LocalCdsTheme`, `CdsDefaultTheme`, and the token types under `com.coinbase.cds.theme`. -- Components (`Button`, `Text`, `SlideButton`, and friends) ship in the AAR but are `internal` - and not customer API yet. +- Public `Button` component with variants, sizes, transparent mode, loading/disabled states, and + start/end icon slots under `com.coinbase.cds.components.button`. +- Public `CdsInteractionDefaults` indication primitive under `com.coinbase.cds.interaction` for + shared press, hover, drag, and keyboard-focus affordances. +- Other components (`Text`, `SlideButton`) ship in the AAR but remain `internal`. #### Requirements diff --git a/packages/cds-android/build.gradle.kts b/packages/cds-android/build.gradle.kts index a4e762958d..853493a516 100644 --- a/packages/cds-android/build.gradle.kts +++ b/packages/cds-android/build.gradle.kts @@ -36,6 +36,7 @@ android { // no-opping. Tracing is the only Android surface the token layer reaches, so letting // the stubs return defaults is enough to run it on the JVM -- no Robolectric needed. isReturnDefaultValues = true + isIncludeAndroidResources = true } } publishing { @@ -92,9 +93,11 @@ dependencies { api(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.animation.core) - // JUnit alone. Theme behavior -- inheritance through nested providers, scheme inversion -- - // exists only inside a composition, but nothing about it needs Android or a UI tree, so the - // tests host a composition on `androidx.compose.runtime` directly rather than pulling in - // Robolectric and the UI-test artifacts. See `HeadlessComposition.kt`. + // Theme tests still use the headless runtime harness (`HeadlessComposition.kt`). Component + // behavior uses the standard Compose UI Test + Robolectric JVM stack. testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/packages/cds-android/docs/README.md b/packages/cds-android/docs/README.md index 454db71046..333c59ef71 100644 --- a/packages/cds-android/docs/README.md +++ b/packages/cds-android/docs/README.md @@ -5,11 +5,13 @@ Guides for teams building Android apps on the Coinbase Design System. | Guide | Read it when | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | [Using theme tokens](using-tokens.md) | You're building UI on CDS: reading tokens in a composable, and carrying them through your own state and logic. **Start here.** | +| [Interaction affordances](interaction.md) | You're building a custom interactive composable that should match CDS press/hover/focus behavior. | | [Creating a custom theme](custom-themes.md) | You want CDS components to render in your brand's colors, spacing, type, or shape. | | [Theme token reference](token-reference.md) | You need the full list of token names, or the default values behind them. | | [Publishing a version](releasing.md) | You're cutting a GitHub Release of the AAR: version bump, changelog, build, and `gh release create`. Maintainers only. | -For API-level detail on a specific type, see its KDoc. For the internal rationale behind the token +Component APIs (`Button`, future composables) are documented in **KDoc** on the public types — this +folder covers cross-cutting package guides only. For the internal rationale behind the token layer's design (why the builder DSL, why `equals` is hand-written, how ABI compatibility is enforced), see [`../src/main/java/com/coinbase/cds/theme/README.md`](../src/main/java/com/coinbase/cds/theme/README.md). diff --git a/packages/cds-android/docs/interaction.md b/packages/cds-android/docs/interaction.md new file mode 100644 index 0000000000..49def9f2e6 --- /dev/null +++ b/packages/cds-android/docs/interaction.md @@ -0,0 +1,75 @@ +# Interaction affordances + +CDS ships reusable press, hover, drag, and keyboard-focus feedback through Compose's standard +[Indication](https://developer.android.com/reference/kotlin/androidx/compose/foundation/Indication) +API. This is **not** the alpha Compose Styles API — it works on the current stable Compose BOM. + +## Quick start + +Paint your component with normal CDS tokens, then pass `CdsInteractionDefaults.indication()` to +`clickable`, `focusable`, or `Modifier.indication`: + +```kotlin +@Composable +fun CustomAction( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val shape = RoundedCornerShape(CdsTheme.borderRadius.radius700) + + Box( + modifier = modifier + .alpha(if (enabled) 1f else CdsInteractionDefaults.DisabledAlpha) + .clip(shape) + .background(CdsTheme.colors.bgPrimary) + .clickable( + interactionSource = interactionSource, + indication = CdsInteractionDefaults.indication(shape), + enabled = enabled, + role = Role.Button, + onClick = onClick, + ) + .padding(CdsTheme.space.x2), + ) { + BasicText( + text = "Custom action", + style = CdsTheme.typography.headline.copy(color = CdsTheme.colors.fgInverse), + ) + } +} +``` + +## API + +| Symbol | Purpose | +| ------------------------------------------ | ------------------------------------------------------------------------------- | +| `CdsInteractionDefaults.indication()` | Rectangular focus outline; no shape required | +| `CdsInteractionDefaults.indication(shape)` | Press scrim and focus ring follow [shape] | +| `CdsInteractionDefaults.DisabledAlpha` | Shared disabled opacity (`0.5`, matches web/mobile `accessibleOpacityDisabled`) | + +## Behavior + +The indication observes a standard `InteractionSource` and applies CDS tokens: + +| State | Visual treatment | +| --------------------- | -------------------------------------------------------------- | +| **Pressed / dragged** | `0.98` scale, `0.82` content alpha, scheme-aware scrim overlay | +| **Hovered** | `0.88` content alpha | +| **Focused** | `bgPrimary` outline for keyboard/D-pad navigation | +| **Disabled** | Not handled here — apply [DisabledAlpha] on the component | + +Pressed and dragged states take priority over hover; focus is hidden while pressed. + +## Used by CDS components + +[Button](../src/main/java/com/coinbase/cds/components/button/Button.kt) applies +`CdsInteractionDefaults.indication(shape)` internally. Future interactive components will share the +same primitive so customer-built composables and CDS components stay visually aligned. + +## Compose Styles migration seam + +Interaction rendering lives behind `CdsInteractionDefaults` and component style resolution stays in +files like `ButtonStyle.kt`. A future Compose Styles integration can replace the internal +application layer without changing public component parameters or icon slot contracts. diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt index da4b5b8501..bdb57f7002 100644 --- a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt +++ b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/Button.kt @@ -1,63 +1,77 @@ package com.coinbase.cds.components.button -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import com.coinbase.cds.components.internal.Spinner -import com.coinbase.cds.theme.CdsColorScheme +import com.coinbase.cds.interaction.CdsInteractionDefaults import com.coinbase.cds.theme.CdsTheme -import com.coinbase.cds.theme.CdsThemeProvider -/** Visual/semantic variant -- the five in the current Figma Button spec. */ -internal enum class ButtonVariant { Primary, Secondary, Tertiary, Positive, Negative } - -/** Size tier. The four sizes (`xs`/`s`/`m`/`l`) in the Figma Button spec. */ -internal enum class ButtonSize { Xs, S, M, L } +/** Visual/semantic variant for [Button]. */ +public enum class ButtonVariant { + Primary, + Secondary, + Tertiary, + Positive, + Negative, + Inverse, +} -private const val PressedScale = 0.98f -private const val DisabledAlpha = 0.4f -private const val PressedBlendFraction = 0.15f +/** Size tier for [Button]. */ +public enum class ButtonSize { + Xs, + S, + M, + L, +} /** * CDS's primary call-to-action control. Covers [variant], [size], [enabled]/[loading] state, - * [transparent], [fullWidth], and leading/trailing icon slots. Raw color/background/border - * overrides are deliberately absent -- re-theme via [CdsThemeProvider] instead, so the override - * applies consistently rather than one call site at a time. - * - * Temporarily `internal` for the first AAR release — this was an experiment and is not customer - * API yet. + * [transparent], icon slots, and accessibility semantics. Raw color/background/border overrides + * are deliberately absent — re-theme via [com.coinbase.cds.theme.CdsThemeProvider] instead. * - * Reads colors and metrics from the ambient [CdsTheme], so wrapping a subtree in a - * [CdsThemeProvider] override -- e.g. a customer brand theme -- is picked up automatically with no - * extra wiring. + * For full-width layout, pass `Modifier.fillMaxWidth()`. For test hooks (RN `testID` equivalent), + * pass `modifier = Modifier.testTag("confirm")` — the tag is applied on this root `Row` alongside + * button semantics and gestures. Maestro can select it with `id:` when the host app enables + * `testTagsAsResourceId` at the activity root; prefer matching visible label text when unique. + * For shared interaction patterns see `packages/cds-android/docs/interaction.md`. For Maestro and + * `testTag` conventions see the cds-rn-to-compose `ui-testing` reference. * * @param transparent Renders on the plain page background with variant-colored text instead of a - * filled, variant-colored container -- CDS's lower-emphasis "ghost" treatment. - * @param leadingIcon Called with the button's resolved content color so an icon's tint - * automatically matches the label and stays correct across variants and themes. + * filled, variant-colored container — CDS's lower-emphasis "ghost" treatment. + * @param interactionSource Hoisted source for press, hover, and focus interactions. Pass the same + * instance you observe via `collectIsPressedAsState()` or `interactions.collect`. + * @param startIcon Called with the button's resolved content color and icon size so an icon's tint + * and dimensions automatically match the label across variants and themes. + * @param endIcon Same contract as [startIcon], rendered after the label. */ @Composable -internal fun Button( +public fun Button( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -66,54 +80,62 @@ internal fun Button( enabled: Boolean = true, loading: Boolean = false, transparent: Boolean = false, - fullWidth: Boolean = false, - leadingIcon: (@Composable (tint: Color) -> Unit)? = null, - trailingIcon: (@Composable (tint: Color) -> Unit)? = null, + maxLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + startIcon: (@Composable (tint: Color, size: Dp) -> Unit)? = null, + endIcon: (@Composable (tint: Color, size: Dp) -> Unit)? = null, ) { val colors = buttonColors(variant, transparent) val metrics = buttonMetrics(size) - - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val active = pressed && enabled && !loading - - val scale by animateFloatAsState(if (active) PressedScale else 1f, label = "cdsButtonScale") - val containerColor = if (active) { - val scrim = if (CdsTheme.colorScheme == CdsColorScheme.Dark) Color.White else Color.Black - lerp(colors.container, scrim, PressedBlendFraction) - } else { - colors.container - } + val shape = RoundedCornerShape(metrics.radius) + val interactive = enabled && !loading Row( modifier = modifier - .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier) - .scale(scale) - .alpha(if (enabled) 1f else DisabledAlpha) - .clip(RoundedCornerShape(metrics.radius)) - .background(containerColor) + .alpha(if (enabled) 1f else CdsInteractionDefaults.DisabledAlpha) + .semantics(mergeDescendants = true) { + role = Role.Button + contentDescription = text + if (loading) { + stateDescription = "Loading" + progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + } + if (!enabled) { + disabled() + } + } + .clip(shape) + .background(colors.container) + .hoverable(interactionSource = interactionSource, enabled = interactive) + .focusable(enabled = interactive, interactionSource = interactionSource) .clickable( interactionSource = interactionSource, - indication = null, - enabled = enabled && !loading, + indication = CdsInteractionDefaults.indication(shape), + enabled = interactive, role = Role.Button, onClick = onClick, ) .padding(horizontal = metrics.paddingX, vertical = metrics.paddingY), - horizontalArrangement = Arrangement.spacedBy(CdsTheme.space.x1, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy( + CdsTheme.space.x1, + Alignment.CenterHorizontally, + ), verticalAlignment = Alignment.CenterVertically, ) { if (loading) { Spinner(color = colors.content, diameter = metrics.iconSize) } else { - leadingIcon?.invoke(colors.content) + startIcon?.invoke(colors.content, metrics.iconSize) BasicText( text = text, - style = metrics.font.copy(color = colors.content), - maxLines = 1, + style = metrics.font.copy( + color = colors.content, + textAlign = TextAlign.Center, + ), + maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) - trailingIcon?.invoke(colors.content) + endIcon?.invoke(colors.content, metrics.iconSize) } } } diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt index 424cca8b2c..b56373b9e7 100644 --- a/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt +++ b/packages/cds-android/src/main/java/com/coinbase/cds/components/button/ButtonStyle.kt @@ -8,19 +8,25 @@ import androidx.compose.ui.unit.Dp import com.coinbase.cds.theme.CdsTheme /** - * Resolved container/content colors for a [ButtonVariant] -- a flat lookup by variant, nothing more. + * Resolved container/content colors for a [ButtonVariant] — a flat lookup by variant, nothing more. * - * The transparent variants use a true [Color.Transparent] container rather than the base `bg` token. - * Painting `bg` only reads as "transparent" when the button happens to sit directly on the screen's - * base background; this way it looks right on any surface a caller places it on, such as a - * `bgSecondary` card. + * The transparent variants use a true [Color.Transparent] container rather than the base `bg` + * token. Painting `bg` only reads as "transparent" when the button happens to sit directly on the + * screen's base background; this way it looks right on any surface a caller places it on, such as + * a `bgSecondary` card. */ @Immutable internal data class ButtonColors(val container: Color, val content: Color) @Composable -internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonColors { - val colors = CdsTheme.colors +internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonColors = + resolveButtonColors(variant, transparent, CdsTheme.colors) + +internal fun resolveButtonColors( + variant: ButtonVariant, + transparent: Boolean, + colors: com.coinbase.cds.theme.CdsColors, +): ButtonColors { return if (transparent) { when (variant) { ButtonVariant.Primary -> ButtonColors(Color.Transparent, colors.fgPrimary) @@ -28,6 +34,7 @@ internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonC ButtonVariant.Tertiary -> ButtonColors(Color.Transparent, colors.fg) ButtonVariant.Positive -> ButtonColors(Color.Transparent, colors.fgPositive) ButtonVariant.Negative -> ButtonColors(Color.Transparent, colors.fgNegative) + ButtonVariant.Inverse -> ButtonColors(Color.Transparent, colors.fg) } } else { when (variant) { @@ -36,6 +43,7 @@ internal fun buttonColors(variant: ButtonVariant, transparent: Boolean): ButtonC ButtonVariant.Tertiary -> ButtonColors(colors.bgTertiary, colors.fg) ButtonVariant.Positive -> ButtonColors(colors.bgPositive, colors.fgInverse) ButtonVariant.Negative -> ButtonColors(colors.bgNegative, colors.fgInverse) + ButtonVariant.Inverse -> ButtonColors(colors.bgInverse, colors.fgInverse) } } } @@ -51,15 +59,24 @@ internal data class ButtonMetrics( ) @Composable -internal fun buttonMetrics(size: ButtonSize): ButtonMetrics { - val space = CdsTheme.space - val radius = CdsTheme.borderRadius - val iconSize = CdsTheme.iconSize - val typography = CdsTheme.typography - return when (size) { - ButtonSize.Xs -> ButtonMetrics(space.x2, space.x0_75, radius.radius700, iconSize.s, typography.label1) - ButtonSize.S -> ButtonMetrics(space.x2, space.x1, radius.radius700, iconSize.s, typography.headline) - ButtonSize.M -> ButtonMetrics(space.x3, space.x1_5, radius.radius900, iconSize.m, typography.headline) - ButtonSize.L -> ButtonMetrics(space.x4, space.x2, radius.radius900, iconSize.m, typography.headline) - } +internal fun buttonMetrics(size: ButtonSize): ButtonMetrics = + resolveButtonMetrics( + size = size, + space = CdsTheme.space, + borderRadius = CdsTheme.borderRadius, + iconSize = CdsTheme.iconSize, + typography = CdsTheme.typography, + ) + +internal fun resolveButtonMetrics( + size: ButtonSize, + space: com.coinbase.cds.theme.CdsSpace, + borderRadius: com.coinbase.cds.theme.CdsBorderRadius, + iconSize: com.coinbase.cds.theme.CdsIconSize, + typography: com.coinbase.cds.theme.CdsTypography, +): ButtonMetrics = when (size) { + ButtonSize.Xs -> ButtonMetrics(space.x2, space.x0_75, borderRadius.radius700, iconSize.s, typography.label1) + ButtonSize.S -> ButtonMetrics(space.x2, space.x1, borderRadius.radius700, iconSize.s, typography.headline) + ButtonSize.M -> ButtonMetrics(space.x3, space.x1_5, borderRadius.radius900, iconSize.m, typography.headline) + ButtonSize.L -> ButtonMetrics(space.x4, space.x2, borderRadius.radius900, iconSize.m, typography.headline) } diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsIndication.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsIndication.kt new file mode 100644 index 0000000000..d172006b8c --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsIndication.kt @@ -0,0 +1,171 @@ +package com.coinbase.cds.interaction + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.HoverInteraction +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.unit.dp +import com.coinbase.cds.theme.CdsColorScheme +import com.coinbase.cds.theme.LocalCdsTheme +import kotlinx.coroutines.launch + +private const val ScaleAnimationDurationMillis = 120 + +internal class CdsIndicationNodeFactory( + private val shape: Shape?, +) : IndicationNodeFactory { + override fun create(interactionSource: InteractionSource): DelegatableNode = + CdsIndicationNode(interactionSource, shape) + + override fun equals(other: Any?): Boolean = + other is CdsIndicationNodeFactory && other.shape == shape + + override fun hashCode(): Int = shape.hashCode() +} + +private class CdsIndicationNode( + private val interactionSource: InteractionSource, + private val shape: Shape?, +) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode { + private val interactions = mutableStateListOf() + private val animatedScale = Animatable(1f) + + override fun onAttach() { + coroutineScope.launch { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> interactions.add(interaction) + is PressInteraction.Release -> interactions.remove(interaction.press) + is PressInteraction.Cancel -> interactions.remove(interaction.press) + is HoverInteraction.Enter -> interactions.add(interaction) + is HoverInteraction.Exit -> interactions.remove(interaction.enter) + is FocusInteraction.Focus -> interactions.add(interaction) + is FocusInteraction.Unfocus -> interactions.remove(interaction.focus) + is DragInteraction.Start -> interactions.add(interaction) + is DragInteraction.Stop -> interactions.remove(interaction.start) + is DragInteraction.Cancel -> interactions.remove(interaction.start) + else -> Unit + } + val visualState = currentVisualState() + launch { + animatedScale.animateTo( + targetValue = visualState.scale, + animationSpec = tween(durationMillis = ScaleAnimationDurationMillis), + ) + } + invalidateDraw() + } + } + } + + override fun ContentDrawScope.draw() { + val visualState = currentVisualState() + val theme = currentValueOf(LocalCdsTheme) + val focusColor = theme?.colors?.bgPrimary ?: Color.Unspecified + val scrimBase = if (theme?.colorScheme == CdsColorScheme.Dark) Color.White else Color.Black + val layerBounds = Rect(0f, 0f, size.width, size.height) + + scale(animatedScale.value, pivot = Offset(size.width / 2f, size.height / 2f)) { + val alpha = visualState.contentAlpha + if (alpha < 1f) { + val layerPaint = Paint().apply { this.alpha = alpha } + drawContext.canvas.saveLayer(layerBounds, layerPaint) + this@draw.drawContent() + drawContext.canvas.restore() + } else { + this@draw.drawContent() + } + } + + if (visualState.showPressedScrim) { + val scrimColor = scrimBase.copy(alpha = CdsInteractionTokens.PressedScrimBlendFraction) + drawShapeOverlay(color = scrimColor, filled = true) + } + + if (visualState.showFocusRing && focusColor != Color.Unspecified) { + val outlineWidth = CdsInteractionTokens.FocusOutlineWidthDp.dp.toPx() + drawShapeOverlay( + color = focusColor, + filled = false, + strokeWidth = outlineWidth, + ) + } + } + + private fun ContentDrawScope.drawShapeOverlay( + color: Color, + filled: Boolean, + strokeWidth: Float = 0f, + ) { + val drawStyle = if (filled) androidx.compose.ui.graphics.drawscope.Fill else Stroke(width = strokeWidth) + val outline = shape?.createOutline(size, layoutDirection, this) + if (outline != null) { + when (outline) { + is Outline.Rectangle -> drawRect( + color = color, + topLeft = outline.rect.topLeft, + size = outline.rect.size, + style = drawStyle, + ) + is Outline.Rounded -> { + val roundRect = outline.roundRect + drawRoundRect( + color = color, + topLeft = Offset(roundRect.left, roundRect.top), + size = Size(roundRect.width, roundRect.height), + cornerRadius = roundRect.topLeftCornerRadius, + style = drawStyle, + ) + } + is Outline.Generic -> drawPath( + path = outline.path, + color = color, + style = drawStyle, + ) + } + } else if (!filled && strokeWidth > 0f) { + val inset = CdsInteractionTokens.FocusOutlineOffsetDp.dp.toPx() + drawRect( + color = color, + topLeft = Offset(-inset, -inset), + size = size.copy( + width = size.width + inset * 2, + height = size.height + inset * 2, + ), + style = Stroke(width = strokeWidth), + ) + } else if (filled) { + drawRect(color = color) + } + } + + private fun currentVisualState(): CdsInteractionVisualState { + val isPressed = interactions.any { it is PressInteraction.Press } + val isDragged = interactions.any { it is DragInteraction.Start } + val isHovered = interactions.any { it is HoverInteraction.Enter } + val isFocused = interactions.any { it is FocusInteraction.Focus } + return resolveCdsInteractionVisualState(isPressed, isDragged, isHovered, isFocused) + } +} diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt new file mode 100644 index 0000000000..11b01180f0 --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionDefaults.kt @@ -0,0 +1,35 @@ +package com.coinbase.cds.interaction + +import androidx.compose.foundation.Indication +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.ui.graphics.Shape + +/** + * Shared CDS interaction affordances for clickable, focusable, hoverable, and draggable + * composables. + * + * Paint the component's normal colors from [com.coinbase.cds.theme.CdsTheme], then pass + * [indication] to `clickable`, `focusable`, or `indication` so press, hover, drag, and keyboard + * focus render with CDS tokens. Disabled styling is separate: apply [DisabledAlpha] when the + * component is not interactive. + * + * This uses Compose's standard [Indication] / [IndicationNodeFactory] APIs — not the alpha + * Compose Styles API — so it can be adopted today and replaced internally later without changing + * component parameters. + */ +public object CdsInteractionDefaults { + /** Matches `accessibleOpacityDisabled` from `@coinbase/cds-common/tokens/interactable`. */ + public const val DisabledAlpha: Float = CdsInteractionTokens.DisabledAlpha + + /** + * CDS interaction feedback with a rectangular focus outline. Use this when corners are square + * or when an exact outline match is unnecessary. + */ + public fun indication(): IndicationNodeFactory = CdsIndicationNodeFactory(shape = null) + + /** + * CDS interaction feedback clipped to [shape]. Pass the same shape used by `clip` or + * `background` when the focus ring and pressed scrim should follow rounded corners. + */ + public fun indication(shape: Shape): IndicationNodeFactory = CdsIndicationNodeFactory(shape) +} diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionState.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionState.kt new file mode 100644 index 0000000000..40bffd3983 --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionState.kt @@ -0,0 +1,50 @@ +package com.coinbase.cds.interaction + +import androidx.compose.runtime.Immutable + +/** + * Resolved CDS interaction affordances for a single frame. Pure data so priority rules are + * unit-testable without a composition. + */ +@Immutable +internal data class CdsInteractionVisualState( + val isPressed: Boolean = false, + val isDragged: Boolean = false, + val isHovered: Boolean = false, + val isFocused: Boolean = false, +) { + val isActive: Boolean + get() = isPressed || isDragged + + val contentAlpha: Float + get() = when { + isActive -> CdsInteractionTokens.PressedContentAlpha + isHovered -> CdsInteractionTokens.HoveredContentAlpha + else -> 1f + } + + val scale: Float + get() = if (isActive) CdsInteractionTokens.PressedScale else 1f + + val showPressedScrim: Boolean + get() = isActive + + val showFocusRing: Boolean + get() = isFocused && !isActive +} + +/** + * Collapses raw interaction flags into the CDS visual priority: pressed/dragged beat hover, and + * focus is shown only when not actively pressed. + */ +internal fun resolveCdsInteractionVisualState( + isPressed: Boolean, + isDragged: Boolean, + isHovered: Boolean, + isFocused: Boolean, +): CdsInteractionVisualState = CdsInteractionVisualState( + isPressed = isPressed, + isDragged = isDragged, + isHovered = isHovered, + isFocused = isFocused, +) diff --git a/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionTokens.kt b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionTokens.kt new file mode 100644 index 0000000000..d7a52510db --- /dev/null +++ b/packages/cds-android/src/main/java/com/coinbase/cds/interaction/CdsInteractionTokens.kt @@ -0,0 +1,12 @@ +package com.coinbase.cds.interaction + +/** CDS interactable opacity and scale tokens, hand-ported from `@coinbase/cds-common/tokens/interactable`. */ +internal object CdsInteractionTokens { + const val HoveredContentAlpha = 0.88f + const val PressedContentAlpha = 0.82f + const val DisabledAlpha = 0.5f + const val PressedScale = 0.98f + const val PressedScrimBlendFraction = 0.15f + const val FocusOutlineWidthDp = 2f + const val FocusOutlineOffsetDp = 2f +} diff --git a/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonStyleTest.kt b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonStyleTest.kt new file mode 100644 index 0000000000..c7dbd991fd --- /dev/null +++ b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonStyleTest.kt @@ -0,0 +1,74 @@ +package com.coinbase.cds.components.button + +import androidx.compose.ui.graphics.Color +import com.coinbase.cds.theme.CdsColors +import com.coinbase.cds.theme.CdsDefaultTheme +import org.junit.Assert.assertEquals +import org.junit.Test + +class ButtonStyleTest { + private val light = CdsDefaultTheme.lightColors + private val dark = CdsDefaultTheme.darkColors + + @Test + fun filledPrimaryUsesPrimaryTokens() { + val colors = resolveButtonColors(ButtonVariant.Primary, transparent = false, light) + assertEquals(light.bgPrimary, colors.container) + assertEquals(light.fgInverse, colors.content) + } + + @Test + fun filledInverseUsesInverseTokens() { + val colors = resolveButtonColors(ButtonVariant.Inverse, transparent = false, dark) + assertEquals(dark.bgInverse, colors.container) + assertEquals(dark.fgInverse, colors.content) + } + + @Test + fun transparentPrimaryUsesClearContainerAndPrimaryForeground() { + val colors = resolveButtonColors(ButtonVariant.Primary, transparent = true, light) + assertEquals(Color.Transparent, colors.container) + assertEquals(light.fgPrimary, colors.content) + } + + @Test + fun transparentInverseUsesForegroundToken() { + val colors = resolveButtonColors(ButtonVariant.Inverse, transparent = true, dark) + assertEquals(Color.Transparent, colors.container) + assertEquals(dark.fg, colors.content) + } + + @Test + fun sizeLMetricsMatchMobileTable() { + val theme = CdsDefaultTheme + val metrics = resolveButtonMetrics( + ButtonSize.L, + theme.space, + theme.borderRadius, + theme.iconSize, + theme.typography, + ) + assertEquals(theme.space.x4, metrics.paddingX) + assertEquals(theme.space.x2, metrics.paddingY) + assertEquals(theme.borderRadius.radius900, metrics.radius) + assertEquals(theme.iconSize.m, metrics.iconSize) + assertEquals(theme.typography.headline, metrics.font) + } + + @Test + fun sizeXsMetricsMatchMobileTable() { + val theme = CdsDefaultTheme + val metrics = resolveButtonMetrics( + ButtonSize.Xs, + theme.space, + theme.borderRadius, + theme.iconSize, + theme.typography, + ) + assertEquals(theme.space.x2, metrics.paddingX) + assertEquals(theme.space.x0_75, metrics.paddingY) + assertEquals(theme.borderRadius.radius700, metrics.radius) + assertEquals(theme.iconSize.s, metrics.iconSize) + assertEquals(theme.typography.label1, metrics.font) + } +} diff --git a/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt new file mode 100644 index 0000000000..38463625e8 --- /dev/null +++ b/packages/cds-android/src/test/java/com/coinbase/cds/components/button/ButtonTest.kt @@ -0,0 +1,189 @@ +package com.coinbase.cds.components.button + +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.coinbase.cds.theme.CdsColorScheme +import com.coinbase.cds.theme.CdsDefaultTheme +import com.coinbase.cds.theme.CdsThemeProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(instrumentedPackages = ["androidx.loader.content"]) +class ButtonTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun rendersLabelAndFiresOnClick() { + var clicked = false + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Confirm", onClick = { clicked = true }) + } + } + + composeRule.onNodeWithText("Confirm").assertIsDisplayed().performClick() + assertTrue(clicked) + } + + @Test + fun disabledButtonDoesNotInvokeOnClick() { + var clicked = false + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Disabled", onClick = { clicked = true }, enabled = false) + } + } + + composeRule.onNodeWithText("Disabled").assertIsNotEnabled() + composeRule.onNodeWithText("Disabled").performClick() + assertEquals(false, clicked) + } + + @Test + fun loadingButtonIsNotEnabledAndExposesProgressSemantics() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Submit", onClick = {}, loading = true) + } + } + + val node = composeRule.onNodeWithContentDescription("Submit") + node.assertIsNotEnabled() + node.fetchSemanticsNode().config.apply { + assertEquals("Loading", get(SemanticsProperties.StateDescription)) + assertEquals( + ProgressBarRangeInfo.Indeterminate, + get(SemanticsProperties.ProgressBarRangeInfo), + ) + } + } + + @Test + fun loadingButtonDoesNotInvokeOnClick() { + var clicked = false + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Submit", onClick = { clicked = true }, loading = true) + } + } + + composeRule.onNodeWithContentDescription("Submit").performClick() + assertEquals(false, clicked) + } + + @Test + fun hoistedInteractionSourceReceivesPressInteraction() { + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + composeRule.setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + interactions.add(interaction) + } + } + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "Confirm", + onClick = {}, + interactionSource = interactionSource, + ) + } + } + + composeRule.onNodeWithText("Confirm").performClick() + composeRule.waitForIdle() + assertTrue(interactions.any { it is PressInteraction.Press }) + } + + @Test + fun iconSlotsReceiveTintAndSize() { + var capturedTint: Color? = null + var capturedSize: Dp? = null + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "With icon", + onClick = {}, + startIcon = { tint, size -> + capturedTint = tint + capturedSize = size + }, + ) + } + } + + composeRule.waitForIdle() + assertEquals(CdsDefaultTheme.lightColors.fgInverse, capturedTint) + assertEquals(CdsDefaultTheme.iconSize.m, capturedSize) + } + + @Test + fun callerTestTagIsQueryable() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "Confirm", + onClick = {}, + modifier = Modifier.testTag("confirm-button"), + ) + } + } + + composeRule.onNodeWithTag("confirm-button").assertIsDisplayed() + composeRule.onNodeWithText("Confirm").assertIsDisplayed() + } + + @Test + fun callerModifierFillMaxWidthIsRespected() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button( + text = "Full width", + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .testTag("button"), + ) + } + } + + composeRule.onNodeWithTag("button").assertIsDisplayed() + } + + @Test + fun enabledButtonIsClickable() { + composeRule.setContent { + CdsThemeProvider(theme = CdsDefaultTheme, colorScheme = CdsColorScheme.Light) { + Button(text = "Enabled", onClick = {}) + } + } + + composeRule.onNodeWithText("Enabled").assertIsEnabled() + } +} diff --git a/packages/cds-android/src/test/java/com/coinbase/cds/interaction/CdsInteractionStateTest.kt b/packages/cds-android/src/test/java/com/coinbase/cds/interaction/CdsInteractionStateTest.kt new file mode 100644 index 0000000000..b6dd0f9347 --- /dev/null +++ b/packages/cds-android/src/test/java/com/coinbase/cds/interaction/CdsInteractionStateTest.kt @@ -0,0 +1,60 @@ +package com.coinbase.cds.interaction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CdsInteractionStateTest { + @Test + fun pressedTakesPriorityOverHoverAndFocus() { + val state = resolveCdsInteractionVisualState( + isPressed = true, + isDragged = false, + isHovered = true, + isFocused = true, + ) + assertEquals(CdsInteractionTokens.PressedContentAlpha, state.contentAlpha) + assertEquals(CdsInteractionTokens.PressedScale, state.scale) + assertTrue(state.showPressedScrim) + assertFalse(state.showFocusRing) + } + + @Test + fun draggedMatchesPressedPriority() { + val state = resolveCdsInteractionVisualState( + isPressed = false, + isDragged = true, + isHovered = true, + isFocused = true, + ) + assertEquals(CdsInteractionTokens.PressedContentAlpha, state.contentAlpha) + assertTrue(state.showPressedScrim) + assertFalse(state.showFocusRing) + } + + @Test + fun hoverAppliesWhenNotActive() { + val state = resolveCdsInteractionVisualState( + isPressed = false, + isDragged = false, + isHovered = true, + isFocused = false, + ) + assertEquals(CdsInteractionTokens.HoveredContentAlpha, state.contentAlpha) + assertEquals(1f, state.scale) + assertFalse(state.showPressedScrim) + } + + @Test + fun focusRingShownOnlyWhenNotActive() { + val state = resolveCdsInteractionVisualState( + isPressed = false, + isDragged = false, + isHovered = false, + isFocused = true, + ) + assertTrue(state.showFocusRing) + assertEquals(1f, state.contentAlpha) + } +}