diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd1b790daa..96b502b939 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,7 @@ jobs: fetch-depth: 100 # TODO: This needs to include the merge-base - uses: ./.github/actions/setup - name: Build - run: yarn nx affected --exclude=mobile-app --target=build --base=$NX_BASE --head=$NX_HEAD + run: yarn nx affected --exclude=mobile-app,test-expo --target=build --base=$NX_BASE --head=$NX_HEAD depcheck: name: Depcheck diff --git a/.prettierignore b/.prettierignore index 2500464492..4a3f850f68 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,9 @@ **/persisted_queries.json **/__generated__ +# Test fixtures (must match exact transform output) +**/__testfixtures__/ + # Builds dist/ lib/ diff --git a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch b/.yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch similarity index 66% rename from .yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch rename to .yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch index e8f544c190..0a4b594dd4 100644 --- a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch +++ b/.yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch @@ -1,26 +1,26 @@ diff --git a/build/src/start/platforms/android/AndroidAppIdResolver.js b/build/src/start/platforms/android/AndroidAppIdResolver.js -index f4b217c5d71fb62179160cdbf8e02276abd06a6d..74d58fee13c7dbb5144b6c77d6e12917dc62958d 100644 +index eedb068830f3d5869bfc594671c254f48cd9ab8a..38b728b747bc8974ad8ab0fd38f271c771b0519a 100644 --- a/build/src/start/platforms/android/AndroidAppIdResolver.js +++ b/build/src/start/platforms/android/AndroidAppIdResolver.js -@@ -31,7 +31,7 @@ class AndroidAppIdResolver extends _appIdResolver.AppIdResolver { +@@ -33,7 +33,7 @@ class AndroidAppIdResolver extends _AppIdResolver.AppIdResolver { async resolveAppIdFromNativeAsync() { - const applicationIdFromGradle = await _configPlugins().AndroidConfig.Package.getApplicationIdAsync(this.projectRoot).catch(()=>null); + const applicationIdFromGradle = await _configplugins().AndroidConfig.Package.getApplicationIdAsync(this.projectRoot).catch(()=>null); if (applicationIdFromGradle) { - return applicationIdFromGradle; + return `${applicationIdFromGradle}.development`; } try { - var ref, ref1; + var _androidManifest_manifest_$, _androidManifest_manifest; diff --git a/build/src/start/platforms/ios/AppleAppIdResolver.js b/build/src/start/platforms/ios/AppleAppIdResolver.js -index 06d6d1e11802ed88388444b10acd83834e079f50..c4409c566377897eacdb78aea4a8fd78d5aeca03 100644 +index 96cc53df6109e3b62ede2e79bf4093598a24fa5b..9ec60b4477480128f28c5eb1394caf60e65d8072 100644 --- a/build/src/start/platforms/ios/AppleAppIdResolver.js +++ b/build/src/start/platforms/ios/AppleAppIdResolver.js -@@ -50,7 +50,7 @@ class AppleAppIdResolver extends _appIdResolver.AppIdResolver { +@@ -52,7 +52,7 @@ class AppleAppIdResolver extends _AppIdResolver.AppIdResolver { async resolveAppIdFromNativeAsync() { // Check xcode project try { -- const bundleId = _configPlugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot); -+ const bundleId = _configPlugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot, {'buildConfiguration': 'Debug'}); +- const bundleId = _configplugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot); ++ const bundleId = _configplugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot, {'buildConfiguration': 'Debug'}); if (bundleId) { return bundleId; } diff --git a/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch b/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch new file mode 100644 index 0000000000..3fca734af2 --- /dev/null +++ b/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch @@ -0,0 +1,19 @@ +diff --git a/build/serializer/environmentVariableSerializerPlugin.js b/build/serializer/environmentVariableSerializerPlugin.js +index 3b13e076369a5a94ec3dc7fddb905b01e52d91e8..c42cdebf000b8bd53c5eb45bfbbb2f6492e20b1c 100644 +--- a/build/serializer/environmentVariableSerializerPlugin.js ++++ b/build/serializer/environmentVariableSerializerPlugin.js +@@ -17,6 +17,14 @@ function getTransformEnvironment(url) { + function getAllExpoPublicEnvVars(inputEnv = process.env) { + // Create an object containing all environment variables that start with EXPO_PUBLIC_ + const env = {}; ++ ++ if (inputEnv._ENV_VARS_FOR_APP) { ++ const keys = JSON.parse(inputEnv._ENV_VARS_FOR_APP); ++ for (const key of keys) { ++ env[key] = inputEnv[key]; ++ } ++ } ++ + for (const key in inputEnv) { + if (key.startsWith('EXPO_PUBLIC_')) { + // @ts-expect-error: TS doesn't know that the key starts with EXPO_PUBLIC_ diff --git a/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch b/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch deleted file mode 100644 index d318a3e1b0..0000000000 --- a/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt b/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -index b7a856d72f271e5d655d256a2ea2774c6d4356bd..49d90a461f0c7a26c72a71b77009ec92c0e94105 100644 ---- a/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -+++ b/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -@@ -32,7 +32,7 @@ abstract class DevLauncherPlugin : Plugin { - } - - val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) -- androidComponents.onVariants(androidComponents.selector().withBuildType("debug")) { variant -> -+ androidComponents.onVariants(androidComponents.selector().withBuildType("development")) { variant -> - variant.instrumentation.transformClassesWith(DevLauncherClassVisitorFactory::class.java, InstrumentationScope.ALL) { - it.enabled.set(true) - } diff --git a/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch b/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch new file mode 100644 index 0000000000..0e1951e2f9 --- /dev/null +++ b/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch @@ -0,0 +1,17 @@ +diff --git a/ios/Core/ExpoBridgeModule.mm b/ios/Core/ExpoBridgeModule.mm +index 2ed1c00f47406e109750cc27ace7e0d88e42c00e..d14269aae847143318888ad3c848d707de95e691 100644 +--- a/ios/Core/ExpoBridgeModule.mm ++++ b/ios/Core/ExpoBridgeModule.mm +@@ -45,9 +45,9 @@ - (void)setBridge:(RCTBridge *)bridge + _bridge = bridge; + _appContext.reactBridge = bridge; + +-#if !__has_include() +- _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge]; +-#endif // React Native <0.74 ++// #if !__has_include() ++// _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge]; ++// #endif // React Native <0.74 + } + + #if __has_include() diff --git a/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch b/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch deleted file mode 100644 index 22a0caa149..0000000000 --- a/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch +++ /dev/null @@ -1,126 +0,0 @@ -diff --git a/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt b/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -index f5ac5483aa3f34ae59830a9da16afe52ccc8ba0e..e2bdef4296b1aeecae41681adf43fe6e7fcc3b89 100644 ---- a/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -+++ b/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -@@ -8,6 +8,11 @@ import android.view.ViewGroup - import android.widget.ImageView - import android.widget.RelativeLayout - -+import androidx.core.content.ContextCompat -+import android.view.Gravity -+import android.widget.TextView -+import android.graphics.Color -+ - // this needs to stay for versioning to work - - @SuppressLint("ViewConstructor") -@@ -15,16 +20,44 @@ class SplashScreenView( - context: Context - ) : RelativeLayout(context) { - val imageView: ImageView = ImageView(context).also { view -> -- view.layoutParams = LayoutParams( -+ val params = LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.MATCH_PARENT - ) -+ params.addRule(CENTER_IN_PARENT) // Center align -+ view.layoutParams = params - } - - init { - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - addView(imageView) -+ -+ // context comes from the application level. -+ val packageName = context.packageName -+ -+ val resId = context.resources.getIdentifier("splashscreen_bottom_image", "drawable", packageName) -+ -+ // If bottom image is provided, add it to the view -+ // Otherwise we keep only the main, centered, image -+ if (resId != 0) { -+ val bottomImageView = ImageView(context).apply { -+ val params = LayoutParams( -+ LayoutParams.WRAP_CONTENT, -+ LayoutParams.WRAP_CONTENT -+ ) -+ params.addRule(ALIGN_PARENT_BOTTOM) -+ params.addRule(CENTER_HORIZONTAL) -+ layoutParams = params -+ setPadding(0, 0, 0, 40) -+ val resId = context.resources.getIdentifier("splashscreen_bottom_image", "drawable", packageName) -+ if (resId != 0) { -+ setImageResource(resId) -+ } -+ scaleType = ImageView.ScaleType.CENTER -+ } -+ addView(bottomImageView) -+ } - } - - fun configureImageViewResizeMode(resizeMode: SplashScreenImageResizeMode) { -diff --git a/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt b/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -index 23e8d4b416bb12192a3fe517f02e0945ccd8c347..16fd58a80216f49d8b9eeaa3a7a27ba8567760b3 100644 ---- a/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -+++ b/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -@@ -7,6 +7,7 @@ import android.view.View - import android.view.ViewGroup - import expo.modules.splashscreen.exceptions.NoContentViewException - import java.lang.ref.WeakReference -+import android.view.animation.AlphaAnimation - - const val SEARCH_FOR_ROOT_VIEW_INTERVAL = 20L - -@@ -63,12 +64,19 @@ open class SplashScreenViewController( - return failureCallback("Cannot hide native splash screen on activity that is already destroyed (application is already closed).") - } - -- Handler(activity.mainLooper).post { -- contentView.removeView(splashScreenView) -- autoHideEnabled = true -- splashScreenShown = false -- successCallback(true) -+ val fadeOutDuration = 300L -+ val fadeOutAnimation = AlphaAnimation(1f, 0f).apply { -+ duration = fadeOutDuration -+ fillAfter = true - } -+ -+ Handler(activity.mainLooper).postDelayed({ -+ contentView.removeView(splashScreenView) -+ }, fadeOutDuration) -+ splashScreenView.startAnimation(fadeOutAnimation) -+ autoHideEnabled = true -+ splashScreenShown = false -+ successCallback(true) - } - - // endregion -diff --git a/ios/EXSplashScreen/EXSplashScreenViewController.m b/ios/EXSplashScreen/EXSplashScreenViewController.m -index 3f1226e3867c7b3ef663a3b56787975006d60ddf..3361283632abc49143e59f93c8e57b57324f1708 100644 ---- a/ios/EXSplashScreen/EXSplashScreenViewController.m -+++ b/ios/EXSplashScreen/EXSplashScreenViewController.m -@@ -72,12 +72,16 @@ - (void)hideWithCallback:(nullable void(^)(BOOL))successCallback - EX_WEAKIFY(self); - dispatch_async(dispatch_get_main_queue(), ^{ - EX_ENSURE_STRONGIFY(self); -- [self.splashScreenView removeFromSuperview]; -- self.splashScreenShown = NO; -- self.autoHideEnabled = YES; -- if (successCallback) { -- successCallback(YES); -- } -+ [UIView animateWithDuration:0.2 // 200ms fade-out animation -+ animations:^{self.splashScreenView.alpha = 0.0;} -+ completion:^(BOOL finished){ -+ [self.splashScreenView removeFromSuperview]; -+ self.splashScreenShown = NO; -+ self.autoHideEnabled = YES; -+ if (successCallback) { -+ successCallback(YES); -+ } -+ }]; - }); - } - diff --git a/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch b/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch deleted file mode 100644 index d45460a62a..0000000000 --- a/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch +++ /dev/null @@ -1,41 +0,0 @@ -diff --git a/sync.js b/sync.js ---- a/sync.js -+++ b/sync.js -@@ -18,6 +18,10 @@ var ownProp = common.ownProp - var childrenIgnored = common.childrenIgnored - var isIgnored = common.isIgnored - -+function safeJoin (arr) { -+ return arr.map(function (p) { return typeof p === 'symbol' ? '**' : p }).join('/') -+} -+ - function globSync (pattern, options) { - if (typeof options === 'function' || arguments.length === 3) - throw new TypeError('callback provided to sync glob\n'+ -@@ -89,7 +93,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - switch (n) { - // if not, then this is rather simple - case pattern.length: -- this._processSimple(pattern.join('/'), index) -+ this._processSimple(safeJoin(pattern), index) - return - - case 0: -@@ -102,7 +106,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - // pattern has some string bits in the front. - // whatever it starts with, whether that's 'absolute' like /foo/bar, - // or 'relative' like '../baz' -- prefix = pattern.slice(0, n).join('/') -+ prefix = safeJoin(pattern.slice(0, n)) - break - } - -@@ -112,7 +116,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - var read - if (prefix === null) - read = '.' -- else if (isAbsolute(prefix) || isAbsolute(pattern.join('/'))) { -+ else if (isAbsolute(prefix) || isAbsolute(safeJoin(pattern))) { - if (!prefix || !isAbsolute(prefix)) - prefix = '/' + prefix - read = prefix diff --git a/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch b/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch deleted file mode 100644 index 4b7ce66e94..0000000000 --- a/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch +++ /dev/null @@ -1,76 +0,0 @@ -diff --git a/src/handlers/gestures/GestureDetector.tsx b/src/handlers/gestures/GestureDetector.tsx -index 45d927c230a86a7713d097f19e97da9c32563e2d..06c8d1d441957f29f899af5efd533cab25dd690d 100644 ---- a/src/handlers/gestures/GestureDetector.tsx -+++ b/src/handlers/gestures/GestureDetector.tsx -@@ -256,8 +256,29 @@ function updateHandlers( - ) { - gestureConfig.prepare(); - -+ /* Patch added to fix performance regression due to SharedValue reads. As -+ * per this discussion https://github.com/software-mansion/react-native-gesture-handler/commit/1217039146ddcae6796820b5ecf19d1ff51af837#r143406410 -+ * -+ * Remove patch if this change -+ * https://github.com/software-mansion/react-native-gesture-handler/pull/2957 -+ * has landed on the version you upgrade to. -+ */ -+ // if amount of gesture configs changes, we need to update the callbacks in shared value -+ let shouldUpdateSharedValueIfUsed = -+ preparedGesture.config.length !== gesture.length; -+ - for (let i = 0; i < gesture.length; i++) { - const handler = preparedGesture.config[i]; -+ -+ // if the gestureId is different (gesture isn't wrapped with useMemo or its dependencies changed), -+ // we need to update the shared value, assuming the gesture runs on UI thread or the thread changed -+ if ( -+ handler.handlers.gestureId !== gesture[i].handlers.gestureId && -+ (gesture[i].shouldUseReanimated || handler.shouldUseReanimated) -+ ) { -+ shouldUpdateSharedValueIfUsed = true; -+ } -+ - checkGestureCallbacksForWorklets(handler); - - // only update handlerTag when it's actually different, it may be the same -@@ -301,34 +322,13 @@ function updateHandlers( - } - - if (preparedGesture.animatedHandlers) { -- const previousHandlersValue = -- preparedGesture.animatedHandlers.value ?? []; -- const newHandlersValue = preparedGesture.config -- .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI -- .map((g) => g.handlers) as unknown as HandlerCallbacks< -- Record -- >[]; -- -- // if amount of gesture configs changes, we need to update the callbacks in shared value -- let shouldUpdateSharedValue = -- previousHandlersValue.length !== newHandlersValue.length; -- -- if (!shouldUpdateSharedValue) { -- // if the amount is the same, we need to check if any of the configs inside has changed -- for (let i = 0; i < newHandlersValue.length; i++) { -- if ( -- // we can use the `gestureId` prop as it's unique for every config instance -- newHandlersValue[i].gestureId !== previousHandlersValue[i].gestureId -- ) { -- shouldUpdateSharedValue = true; -- break; -- } -- } -- } -- -- if (shouldUpdateSharedValue) { -- preparedGesture.animatedHandlers.value = newHandlersValue; -- } -+ if (shouldUpdateSharedValueIfUsed) { -+ preparedGesture.animatedHandlers.value = preparedGesture.config -+ .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI -+ .map((g) => g.handlers) as unknown as HandlerCallbacks< -+ Record -+ >[]; -+ } - } - - scheduleFlushOperations(); diff --git a/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch b/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch deleted file mode 100644 index a09c674667..0000000000 --- a/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch +++ /dev/null @@ -1,36 +0,0 @@ -diff --git a/React/Views/RCTModalHostViewManager.m b/React/Views/RCTModalHostViewManager.m -index b0295e05ae4d54091bd80f77809ca2aeaaa8562b..81f8f4fa738cfe80ec89f32ebe5bab7ed21f5958 100644 ---- a/React/Views/RCTModalHostViewManager.m -+++ b/React/Views/RCTModalHostViewManager.m -@@ -75,7 +75,6 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView - modalHostView.onShow(nil); - } - }; -- dispatch_async(dispatch_get_main_queue(), ^{ - if (self->_presentationBlock) { - self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock); - } else { -@@ -83,7 +82,6 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView - animated:animated - completion:completionBlock]; - } -- }); - } - - - (void)dismissModalHostView:(RCTModalHostView *)modalHostView -@@ -95,7 +93,6 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView - [[self.bridge moduleForClass:[RCTModalManager class]] modalDismissed:modalHostView.identifier]; - } - }; -- dispatch_async(dispatch_get_main_queue(), ^{ - if (self->_dismissalBlock) { - self->_dismissalBlock([modalHostView reactViewController], viewController, animated, completionBlock); - } else if (viewController.presentingViewController) { -@@ -106,7 +103,6 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView - // This, somehow, invalidate the presenting view controller and the modal remains always visible. - completionBlock(); - } -- }); - } - - - (RCTShadowView *)shadowView diff --git a/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch b/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch new file mode 100644 index 0000000000..c5e401ba99 --- /dev/null +++ b/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch @@ -0,0 +1,92 @@ +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js b/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js +index 9d663610a0546d4f801196217966ad9d184818af..1586d116b9fc4e86a39976de543489c6a23a1154 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js +@@ -16868,7 +16868,7 @@ __DEV__ && + shouldSuspendImpl = newShouldSuspendImpl; + }; + var isomorphicReactPackageVersion = React.version; +- if ("19.1.0" !== isomorphicReactPackageVersion) ++ if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js b/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js +index b3d1cfa09d76b50617b9032b15c82351a699638e..c17f99912028e52b9acfb01b9b9560bba8c03c16 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js +@@ -10603,7 +10603,7 @@ function updateContainer(element, container, parentComponent, callback) { + return lane; + } + var isomorphicReactPackageVersion = React.version; +-if ("19.1.0" !== isomorphicReactPackageVersion) ++if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js b/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js +index b317ca102b0b7d25c15819c61352019fef05561b..e5c3854d0d6de8c9181d6a50124fdc7e8ddc72ab 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js +@@ -11245,7 +11245,7 @@ function updateContainer(element, container, parentComponent, callback) { + return lane; + } + var isomorphicReactPackageVersion = React.version; +-if ("19.1.0" !== isomorphicReactPackageVersion) ++if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/React/Views/RCTModalHostViewManager.m b/React/Views/RCTModalHostViewManager.m +index 203d0b441342487bfd8765b93044b291029614b2..1f2abc9651d3a4c809be6a03e8d9f7d6f7bd12bc 100644 +--- a/React/Views/RCTModalHostViewManager.m ++++ b/React/Views/RCTModalHostViewManager.m +@@ -60,7 +60,7 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView + modalHostView.onShow(nil); + } + }; +- dispatch_async(dispatch_get_main_queue(), ^{ ++ + if (self->_presentationBlock) { + self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock); + } else { +@@ -68,7 +68,7 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView + animated:animated + completion:completionBlock]; + } +- }); ++ + } + + - (void)dismissModalHostView:(RCTModalHostView *)modalHostView +@@ -80,7 +80,7 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView + [[self.bridge moduleForClass:[RCTModalManager class]] modalDismissed:modalHostView.identifier]; + } + }; +- dispatch_async(dispatch_get_main_queue(), ^{ ++ + if (self->_dismissalBlock) { + self->_dismissalBlock([modalHostView reactViewController], viewController, animated, completionBlock); + } else if (viewController.presentingViewController) { +@@ -91,7 +91,7 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView + // This, somehow, invalidate the presenting view controller and the modal remains always visible. + completionBlock(); + } +- }); ++ + } + + - (RCTShadowView *)shadowView +diff --git a/sdks/hermes-engine/hermes-engine.podspec b/sdks/hermes-engine/hermes-engine.podspec +index 326c6fa9089cf794c2dcf37084085bf3bef3f6a5..4aa7b70780af967ff607aada3419959a8be49670 100644 +--- a/sdks/hermes-engine/hermes-engine.podspec ++++ b/sdks/hermes-engine/hermes-engine.podspec +@@ -77,7 +77,7 @@ Pod::Spec.new do |spec| + . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh" + + CONFIG="Release" +- if echo $GCC_PREPROCESSOR_DEFINITIONS | grep -q "DEBUG=1"; then ++ if echo $GCC_PREPROCESSOR_DEFINITIONS | grep -q "HERMES_ENABLE_DEBUGGER=1"; then + CONFIG="Debug" + fi + diff --git a/apps/docs/docs/components/animation/Lottie/mobileMetadata.json b/apps/docs/docs/components/animation/Lottie/mobileMetadata.json index c172e168a0..3abaf8ff25 100644 --- a/apps/docs/docs/components/animation/Lottie/mobileMetadata.json +++ b/apps/docs/docs/components/animation/Lottie/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "lottie-react-native", - "version": "^6.7.0" + "version": "7.3.1" } ] } diff --git a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json index 1ba88778e5..ae92a6b54d 100644 --- a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json +++ b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "lottie-react-native", - "version": "^6.7.0" + "version": "7.3.1" } ] } diff --git a/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx b/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx index 2a1a0da348..90330f6863 100644 --- a/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx +++ b/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx @@ -81,7 +81,7 @@ function Example() { @@ -94,7 +94,12 @@ function Example() { subtitle: 'Sept earnings', onPress: NoopFn, header: ( - + ), diff --git a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx index 7d5d1c194d..62bcdceea5 100644 --- a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx @@ -126,7 +126,7 @@ function Example() { ## With Background -Apply a background color to the card using the `background` prop. When using a background, consider using `variant="tertiary"` on buttons. +Apply a background color to the card using the `background` prop. When using a background, consider using `variant="inverse"` on buttons. ```jsx function Example() { @@ -153,7 +153,7 @@ function Example() { - @@ -275,7 +275,7 @@ function AccessibleCard() { @@ -259,9 +259,9 @@ function Example() { - + Reward - + +$15 ACS @@ -317,7 +317,7 @@ function AccessibleCard() { + @@ -46,6 +50,12 @@ Use transparent buttons for supplementary actions with lower prominence. The con + + diff --git a/apps/docs/docs/components/inputs/Button/_webExamples.mdx b/apps/docs/docs/components/inputs/Button/_webExamples.mdx index 29e60288d2..a3f485d613 100644 --- a/apps/docs/docs/components/inputs/Button/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Button/_webExamples.mdx @@ -14,7 +14,8 @@ Use variants to communicate the importance and intent of an action. - **Primary** — High emphasis for main actions like "Save" or "Confirm". Limit to one per screen. - **Secondary** — Medium emphasis for multiple actions of equal weight. -- **Tertiary** — High contrast with inverted background. +- **Tertiary** — Low emphasis with a muted background. +- **Inverse** — High contrast with inverted background. - **Negative** — Destructive actions that can't be undone. Use sparingly. ```jsx live @@ -28,6 +29,9 @@ Use variants to communicate the importance and intent of an action. + @@ -49,6 +53,9 @@ Use transparent buttons for supplementary actions with lower prominence. The con + diff --git a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json index 77ea5e689d..84ba30dce8 100644 --- a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json @@ -13,7 +13,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/Combobox/webMetadata.json b/apps/docs/docs/components/inputs/Combobox/webMetadata.json index 4db33d49b9..807152ff4b 100644 --- a/apps/docs/docs/components/inputs/Combobox/webMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx index de2eb7bdee..35c8fbf927 100644 --- a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx @@ -38,12 +38,6 @@ Use variants to denote intent and importance. The `active` prop fills the icon w variant="tertiary" onPress={console.log} /> - ``` @@ -76,13 +70,6 @@ Use the `transparent` prop to remove the background until the user interacts wit transparent onPress={console.log} /> - ``` @@ -135,13 +122,6 @@ Use the `disabled` prop to prevent interaction and show a disabled visual state. disabled onPress={console.log} /> - ``` @@ -191,7 +171,7 @@ A toggleable icon button with an adjacent label. Uses `accessibilityLabelledBy` ```jsx function ClaimDropExample() { const [active, setActive] = useState(false); - const variant = useMemo(() => (active ? 'primary' : 'foregroundMuted'), [active]); + const variant = useMemo(() => (active ? 'primary' : 'secondary'), [active]); const label = useMemo(() => (active ? 'Reject drop' : 'Claim drop'), [active]); return ( diff --git a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx index e9e1a685f6..a4cdfecbc3 100644 --- a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx @@ -38,12 +38,6 @@ Use variants to denote intent and importance. The `active` prop fills the icon w variant="tertiary" onClick={console.log} /> - ``` @@ -76,13 +70,6 @@ Use the `transparent` prop to remove the background until the user interacts wit transparent onClick={console.log} /> - ``` @@ -144,13 +131,6 @@ Use the `disabled` prop to prevent interaction and show a disabled visual state. disabled onClick={console.log} /> - ``` @@ -200,7 +180,7 @@ A toggleable icon button with an adjacent label. Uses `accessibilityLabelledBy` ```jsx live function ClaimDropExample() { const [active, setActive] = useState(false); - const variant = useMemo(() => (active ? 'primary' : 'foregroundMuted'), [active]); + const variant = useMemo(() => (active ? 'primary' : 'secondary'), [active]); const label = useMemo(() => (active ? 'Reject drop' : 'Claim drop'), [active]); return ( diff --git a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json index 6ef25ea309..a1d0bcb69c 100644 --- a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json @@ -28,7 +28,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json index 685965c943..1835a350d4 100644 --- a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json index c594a0a22a..d4fccd3eac 100644 --- a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json @@ -18,7 +18,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/Select/webMetadata.json b/apps/docs/docs/components/inputs/Select/webMetadata.json index 135c57e756..6a0a6c4799 100644 --- a/apps/docs/docs/components/inputs/Select/webMetadata.json +++ b/apps/docs/docs/components/inputs/Select/webMetadata.json @@ -30,7 +30,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json index db9d2c6ec2..9bc249be52 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json @@ -8,7 +8,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json index 59cda3ea44..bb8c0514a6 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json @@ -12,7 +12,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json index 036d3ca9a0..aff8fd59a2 100644 --- a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json index fc3dc6f6c9..54a937a146 100644 --- a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json @@ -30,7 +30,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json index b8be5679bb..c61c0c0385 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json @@ -21,7 +21,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json index f7088fafeb..f629c63e12 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json @@ -26,7 +26,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json index 2c8b9fc38d..4748bad616 100644 --- a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-gesture-handler", - "version": "^2.16.2" + "version": "2.28.0" } ] } diff --git a/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx b/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx new file mode 100644 index 0000000000..bf115dda45 --- /dev/null +++ b/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/controls/Switch/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Switch/_webStyles.mdx b/apps/docs/docs/components/inputs/Switch/_webStyles.mdx new file mode 100644 index 0000000000..05ea45585d --- /dev/null +++ b/apps/docs/docs/components/inputs/Switch/_webStyles.mdx @@ -0,0 +1,30 @@ +import { useState } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Switch } from '@coinbase/cds-web/controls'; + +import webStylesData from ':docgen/web/controls/Switch/styles-data'; + +export const StatefulSwitchPreview = ({ classNames }) => { + const [isChecked, setIsChecked] = useState(false); + +return ( + + setIsChecked(event.target.checked)} +> + Dark mode + +); }; + +## Explorer + + + {(classNames) => } + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Switch/index.mdx b/apps/docs/docs/components/inputs/Switch/index.mdx index cbf97aa2b6..98ac45f3c7 100644 --- a/apps/docs/docs/components/inputs/Switch/index.mdx +++ b/apps/docs/docs/components/inputs/Switch/index.mdx @@ -16,6 +16,8 @@ import webPropsToc from ':docgen/web/controls/Switch/toc-props'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; @@ -34,12 +36,16 @@ import mobileMetadata from './mobileMetadata.json'; } + webStyles={} webExamples={} mobilePropsTable={} + mobileStyles={} mobileExamples={} webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} mobilePropsToc={mobilePropsToc} + mobileStylesToc={mobileStylesToc} /> diff --git a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json index 8cbed53d2b..13f259ea12 100644 --- a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json +++ b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx b/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx index 09678b32e1..4d7fd8c347 100644 --- a/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx +++ b/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx @@ -4,6 +4,8 @@ Carousels are a great way to showcase a list of items in a compact and engaging By default, Carousels have navigation and pagination enabled. You can also add a title to the Carousel by setting `title` prop. +`paginationVariant` is deprecated. Carousel now defaults to dot pagination. Existing uses of `paginationVariant="pill"` still work during the deprecation window, but new usage should prefer the default pagination or a custom `PaginationComponent`. + You simply wrap each child in a `CarouselItem` component, and can optionally set the `width` prop to control the width of the item. You can also set the `styles` prop to control the styles of the carousel, such as the gap between items. @@ -31,7 +33,6 @@ function MyCarousel() { return ( Earn staking rewards on ETH by holding it on Coinbase @@ -171,7 +171,6 @@ function ResponsiveSizingCarousel() { return ( + @@ -611,14 +603,14 @@ function CustomComponentsCarousel() { disabled={!canGoPrevious} name="caretLeft" onPress={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -673,7 +665,7 @@ function CustomComponentsCarousel() { Earn staking rewards on ETH by holding it on Coinbase @@ -697,7 +689,7 @@ function CustomComponentsCarousel() { Chat with other devs in our Discord community @@ -721,7 +713,7 @@ function CustomComponentsCarousel() { Use code NOV60 when you sign up for Coinbase One @@ -745,7 +737,7 @@ function CustomComponentsCarousel() { Spend USDC to get rewards with our Visa® debit card @@ -779,7 +771,6 @@ You can use the `styles` props to customize different parts of the carousel. function CustomStylesCarousel() { return ( ( Start earning} - dangerouslySetBackground="rgb(var(--purple70))" + style={{ backgroundColor: 'rgb(var(--purple70))' }} description={ Earn staking rewards on ETH by holding it on Coinbase @@ -184,7 +185,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Start chatting} - dangerouslySetBackground="rgb(var(--teal70))" + style={{ backgroundColor: 'rgb(var(--teal70))' }} description={ Chat with other devs in our Discord community @@ -213,7 +214,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Get 60 days free} - dangerouslySetBackground="rgb(var(--blue80))" + style={{ backgroundColor: 'rgb(var(--blue80))' }} description={ Use code NOV60 when you sign up for Coinbase One @@ -242,7 +243,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Get started} - dangerouslySetBackground="rgb(var(--gray100))" + style={{ backgroundColor: 'rgb(var(--gray100))' }} description={ Spend USDC to get rewards with our Visa® debit card @@ -558,7 +559,6 @@ function SnapModeCarousel() { + @@ -859,14 +856,14 @@ function CustomComponentsCarousel() { disabled={!canGoPrevious} name="caretLeft" onClick={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -942,7 +939,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Start earning} - dangerouslySetBackground="rgb(var(--purple70))" + style={{ backgroundColor: 'rgb(var(--purple70))' }} description={ Earn staking rewards on ETH by holding it on Coinbase @@ -967,7 +964,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Start chatting} - dangerouslySetBackground="rgb(var(--teal70))" + style={{ backgroundColor: 'rgb(var(--teal70))' }} description={ Chat with other devs in our Discord community @@ -992,7 +989,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Get 60 days free} - dangerouslySetBackground="rgb(var(--blue80))" + style={{ backgroundColor: 'rgb(var(--blue80))' }} description={ Use code NOV60 when you sign up for Coinbase One @@ -1017,7 +1014,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Get started} - dangerouslySetBackground="rgb(var(--gray100))" + style={{ backgroundColor: 'rgb(var(--gray100))' }} description={ Spend USDC to get rewards with our Visa® debit card @@ -1053,7 +1050,6 @@ function CustomStylesCarousel() { return ( console.log('Page changed', activePageIndex)} onDragStart={() => console.log('Drag started')} onDragEnd={() => console.log('Drag ended')} diff --git a/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json b/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json index 539db97fbf..1f97ba4c47 100644 --- a/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json +++ b/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/layout/Dropdown/webMetadata.json b/apps/docs/docs/components/layout/Dropdown/webMetadata.json index d0df1b6cc6..3b4e99f96b 100644 --- a/apps/docs/docs/components/layout/Dropdown/webMetadata.json +++ b/apps/docs/docs/components/layout/Dropdown/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/media/Avatar/mobileMetadata.json b/apps/docs/docs/components/media/Avatar/mobileMetadata.json index 1e9ac28249..9fe43be2da 100644 --- a/apps/docs/docs/components/media/Avatar/mobileMetadata.json +++ b/apps/docs/docs/components/media/Avatar/mobileMetadata.json @@ -15,7 +15,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/Avatar/webMetadata.json b/apps/docs/docs/components/media/Avatar/webMetadata.json index 8570ed2307..05b76303df 100644 --- a/apps/docs/docs/components/media/Avatar/webMetadata.json +++ b/apps/docs/docs/components/media/Avatar/webMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json index 3ad2f79f6f..f0ae9e9932 100644 --- a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json +++ b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json @@ -15,7 +15,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json index f9eb2b2ea7..5717fcb92c 100644 --- a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json index a2eca6c388..1042016dd0 100644 --- a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json index a06f647034..ba0a1e2dc6 100644 --- a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json index 30aedc1c64..3b826ae4ab 100644 --- a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json +++ b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json index ff0b14bfe2..fd2274207f 100644 --- a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json index 109cd18957..5e7183cda7 100644 --- a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json index c5697db759..b646cdaa6c 100644 --- a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json index f8817f27ba..8b0e9e96bf 100644 --- a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json index 03c6bc3376..e7dc64ca93 100644 --- a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json index 16d4c6ef1a..77fe49f782 100644 --- a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json index d0c8956cf6..e12bc1ed1c 100644 --- a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json index ecc956fceb..a8ed09aedd 100644 --- a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json index f51f4917f8..df48967410 100644 --- a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json +++ b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json @@ -24,7 +24,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json index f0bbda8767..bbb8cfc8fe 100644 --- a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx b/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx index 91b6d29bbd..0fb4e7e314 100644 --- a/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx @@ -385,9 +385,9 @@ function RenderEndExample() { {!isCollapsed && ( - + Help & Support - + )} @@ -448,7 +448,9 @@ function CustomStyles() { > - Help + + Help + )} @@ -568,7 +570,11 @@ function ApplicationShell() { > - {!isCollapsed && Settings} + {!isCollapsed && ( + + Settings + + )} - {!isCollapsed && Profile} + {!isCollapsed && ( + + Profile + + )} diff --git a/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx b/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx new file mode 100644 index 0000000000..76b8dd7c95 --- /dev/null +++ b/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx @@ -0,0 +1,30 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { LogoMark } from '@coinbase/cds-web/icons'; +import { HStack } from '@coinbase/cds-web/layout'; +import { Sidebar, SidebarItem } from '@coinbase/cds-web/navigation'; + +import webStylesData from ':docgen/web/navigation/SidebarItem/styles-data'; + +## Explorer + + + {(classNames) => ( + + }> + undefined} + title="Home" + tooltipContent="Home" + /> + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/navigation/SidebarItem/index.mdx b/apps/docs/docs/components/navigation/SidebarItem/index.mdx index f8b94cc0ea..65388061e9 100644 --- a/apps/docs/docs/components/navigation/SidebarItem/index.mdx +++ b/apps/docs/docs/components/navigation/SidebarItem/index.mdx @@ -14,14 +14,17 @@ import webPropsToc from ':docgen/web/navigation/SidebarItem/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import webMetadata from './webMetadata.json'; } + webStyles={} webExamples={} webExamplesToc={webExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json index 648e617b7a..ff3117cd23 100644 --- a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json @@ -17,7 +17,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json index 5fcaf9184d..bba58c545c 100644 --- a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json index 322496548a..749f4f45f4 100644 --- a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json @@ -15,7 +15,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json index 4b2b7ef553..1bb03cbb63 100644 --- a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json @@ -21,7 +21,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json index 745a786e7b..dbf20bf601 100644 --- a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json @@ -13,7 +13,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json index 98412d5d70..49a6d1fa82 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json @@ -29,7 +29,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json index 851ba23c75..07c03025ec 100644 --- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json index 3fd09a8015..13af54de36 100644 --- a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json index dcb490e8f9..636878e6ab 100644 --- a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tour/webMetadata.json b/apps/docs/docs/components/navigation/Tour/webMetadata.json index 53c33ef98b..3cdd3102a9 100644 --- a/apps/docs/docs/components/navigation/Tour/webMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/webMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json index 989c9eeae0..acf6065693 100644 --- a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json +++ b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json @@ -7,7 +7,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/other/Calendar/webMetadata.json b/apps/docs/docs/components/other/Calendar/webMetadata.json index 1ac432167b..5d9f8f465a 100644 --- a/apps/docs/docs/components/other/Calendar/webMetadata.json +++ b/apps/docs/docs/components/other/Calendar/webMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json index 6a1739571d..c67219b293 100644 --- a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/other/DatePicker/webMetadata.json b/apps/docs/docs/components/other/DatePicker/webMetadata.json index c785ad8c75..bcf504b892 100644 --- a/apps/docs/docs/components/other/DatePicker/webMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/webMetadata.json @@ -29,7 +29,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/other/DotCount/mobileMetadata.json b/apps/docs/docs/components/other/DotCount/mobileMetadata.json index 78d58b2e5c..bbef0f0284 100644 --- a/apps/docs/docs/components/other/DotCount/mobileMetadata.json +++ b/apps/docs/docs/components/other/DotCount/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json index aed0ba53c3..f5077fee12 100644 --- a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json +++ b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx b/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx index 744a052539..72ae20c962 100644 --- a/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx +++ b/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx @@ -52,6 +52,8 @@ ThemeProviders can be nested to create theme overrides for specific sections. ``` +### Overriding theme values + When nesting, you may want to override specific color values from the current theme. Overrides must be conditionally applied because we don't enforce that a theme has both light and dark colors defined. ```jsx @@ -80,7 +82,7 @@ const customTheme = { } as const satisfies Theme; ``` -## Theme inheritence +### Theme inheritance Nested ThemeProviders do not automatically inherit the theme from their parent provider. You can manually inherit the theme with the `useTheme` hook. diff --git a/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx b/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx index a2033aae8a..e1ada240a6 100644 --- a/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx +++ b/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx @@ -46,11 +46,11 @@ theme.fontSize.display3; // "2.5rem" For best performance, prefer to use CSS Variables instead of the `useTheme` hook whenever possible. ::: -## ThemeProvider CSS Variables +## CSS Variables CSS Variables are created for every value in the theme. -For best performance, prefer to use CSS Variables instead of the `useTheme` hook whenever possible. +For best performance, prefer using CSS Variables instead of the `useTheme` hook whenever possible. ```jsx const theme = useTheme(); @@ -70,6 +70,23 @@ You can see all the CSS Variables for the `defaultTheme` below. +### CSS Variable inheritance + +When ThemeProviders are nested, the nested provider only sets CSS variables that differ from its parent. +Unchanged values are inherited through the DOM via normal CSS custom property inheritance. + +This optimization breaks when a ThemeProvider renders **outside** its parent's DOM tree — for example, inside a portal — because CSS inheritance requires DOM ancestry. In these cases, use the `isolated` prop to ensure the ThemeProvider writes all CSS variables: + +```tsx + + {/* All CSS variables are written, regardless of the parent theme */} + +``` + +:::tip +CDS overlay components (Modal, Toast, Alert, etc.) handle this automatically via [PortalProvider](/components/overlay/PortalProvider). You only need the `isolated` prop when rendering a ThemeProvider inside a custom portal that is not managed by CDS. +::: + ## ThemeProvider classnames The ThemeProvider renders with CSS classnames based on the `activeColorScheme` and the theme's `id`. @@ -95,6 +112,8 @@ ThemeProviders can be nested to create theme overrides for specific sections. ``` +### Overriding theme values + When nesting, you may want to override specific color values from the current theme. Overrides must be conditionally applied because we don't enforce that a theme has both light and dark colors defined. ```jsx @@ -123,7 +142,7 @@ const customTheme = { } as const satisfies Theme; ``` -## Theme inheritence +### Theme inheritance Nested ThemeProviders do not automatically inherit the theme from their parent provider. You can manually inherit the theme with the `useTheme` hook. diff --git a/apps/docs/docs/components/overlay/Alert/webMetadata.json b/apps/docs/docs/components/overlay/Alert/webMetadata.json index c89a519b50..0d04f735ea 100644 --- a/apps/docs/docs/components/overlay/Alert/webMetadata.json +++ b/apps/docs/docs/components/overlay/Alert/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json index 48a8300764..436ca5e1ec 100644 --- a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json index 9fb8bfb472..b1341fbd57 100644 --- a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json index 94605e2da2..32dfbe0dc5 100644 --- a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Modal/webMetadata.json b/apps/docs/docs/components/overlay/Modal/webMetadata.json index 6be86f4db3..0f641bc4f1 100644 --- a/apps/docs/docs/components/overlay/Modal/webMetadata.json +++ b/apps/docs/docs/components/overlay/Modal/webMetadata.json @@ -37,7 +37,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx b/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx index 5c64fa8cf2..53dbac6446 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx @@ -1,6 +1,6 @@ ### Basic usage -The PortalProvider component is typically used at the root of your mobile application to manage overlay components: +Render PortalProvider once near the root of your application, to manage overlay components: ```tsx function App() { diff --git a/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx b/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx index 48b39e28eb..673d79f8f5 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx @@ -1,6 +1,6 @@ ### Basic usage -The PortalProvider component is typically used at the root of your application to manage overlay components: +Render PortalProvider once near the root of your application: ```tsx live function Example() { diff --git a/apps/docs/docs/components/overlay/PortalProvider/index.mdx b/apps/docs/docs/components/overlay/PortalProvider/index.mdx index 32aa69a7e4..43ba352540 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/index.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/index.mdx @@ -28,7 +28,7 @@ import mobileMetadata from './mobileMetadata.json'; title="PortalProvider" webMetadata={webMetadata} mobileMetadata={mobileMetadata} - description="The PortalProvider component manages the rendering of portals for modals, toasts, alerts, and tooltips. It provides a centralized way to handle overlay components in your application." + description="A required root-level provider that enables CDS overlay components (Modal, Toast, Alert, Tooltip, Tray). Must be rendered once near the root of your application, alongside ThemeProvider." /> {item} handleDelete(index)} accessibilityLabel={`Delete ${item}`} /> diff --git a/apps/docs/docs/components/overlay/Toast/webMetadata.json b/apps/docs/docs/components/overlay/Toast/webMetadata.json index 690f61dd3f..40cc051bb9 100644 --- a/apps/docs/docs/components/overlay/Toast/webMetadata.json +++ b/apps/docs/docs/components/overlay/Toast/webMetadata.json @@ -25,7 +25,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json index 1355f725a8..d1d828744a 100644 --- a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json @@ -25,7 +25,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json index 2e1051b2e4..034502ee0c 100644 --- a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/overlay/Tray/webMetadata.json b/apps/docs/docs/components/overlay/Tray/webMetadata.json index 0d3166435d..7315ea3ee1 100644 --- a/apps/docs/docs/components/overlay/Tray/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/webMetadata.json @@ -29,7 +29,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx index bb063fbb78..cf1a325cbc 100644 --- a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx @@ -188,7 +188,8 @@ React Native flattens nested Text into a string and cannot focus internal links ```jsx import { AccessibilityInfo, Linking } from 'react-native'; - Consider a case where you have a block of text with an inline link.{' '} Like so. You may want to write your code like this. -; +; ``` ### Multiple nested links diff --git a/apps/docs/docs/components/typography/Text/_mobileExamples.mdx b/apps/docs/docs/components/typography/Text/_mobileExamples.mdx index 0e5a7fd240..484251c9e4 100644 --- a/apps/docs/docs/components/typography/Text/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Text/_mobileExamples.mdx @@ -57,34 +57,25 @@ All text components support a few numeric typography styles, overflow, text tran ### A11y -On the web, there are different HTML elements to wrap texts with to communicated semantic meanings of the strings. Therefore, CDS does not make any assumptions about the semantic of the text but ask developers to choose the approriate semantic HTML element via the `as` prop. +On mobile, `Text` automatically sets `accessibilityRole="header"` for display and title font variants (`display1`, `display2`, `display3`, `title1`, `title2`). This ensures screen readers correctly identify these elements as headings without requiring any additional props. ```jsx -Display +// accessibilityRole="header" is applied automatically +Page Title -// If we want large text but not as the page title -Display +// Other font variants do not receive a default accessibilityRole +Regular body text ``` -### Headings +You can override the default `accessibilityRole` if needed: -Headings help users understand the hierarchical page organization. All pages on the web should at least have a `

` level heading providing the title or summary of the page. Screen readers users prefer that only the document title be `

` on a page. Headings should NOT be used inside tables header elements (``). - -When using headings, it is confusing to screen reader users to skip heading levels to be more specific (ex. do not go from `

` to `

`). However, it is permissible to use a higher heading level after a lower heading level, i.e. from `

`to`

`, if the outline of the page calls for it. - -One common misconception is that headings for a web app have consistent typography across different pages. That is not an accessibility requirement or a design guideline that our product designers follow. Therefore, based on the content layouts, product engineers should determine the approriate semantic tags to use for each string and choose the proper heading element when the texts convey hierarchical content information. - -Yale has a detailed [web accessibility article](https://usability.yale.edu/web-accessibility/articles/headings#:~:text=One%20of%20the%20most%20common,Do%20not%20overuse%20headings) about how to use headings if you want to learn more. - -In a nutshell, you can reference the following for the most common text semantics. +```jsx +// Override to remove the default header role +Decorative Title -- `h1` for page title (exactly one per page) -- `h2`-`h4` for hierarchical section headings (CDS does not foresee the need for heading level 5 or 6 in Coinbase products). -- `p` for paragraphs of text with default block display. It can be wrapped inside `blockquote`, `li`, or `label` elements for additional semantics. -- `li` for bullet points in a list. -- `time`, `abbr`, `sup`, `kbd`, etc, for granular semantics. -- `pre` and `code` for preformatted code blocks. -- `span` when no semantics are required (within buttons for example) and it also has default inline display. +// Explicitly set a different role +Summary Section +``` ### With Links diff --git a/apps/docs/docs/getting-started/installation/_mobileContent.mdx b/apps/docs/docs/getting-started/installation/_mobileContent.mdx index 60ab85c711..05d0067062 100644 --- a/apps/docs/docs/getting-started/installation/_mobileContent.mdx +++ b/apps/docs/docs/getting-started/installation/_mobileContent.mdx @@ -33,18 +33,24 @@ For React Native projects, ensure you have set up your environment for React Nat ## Getting started -### 1. Render a ThemeProvider +### 1. Render providers -Render a ThemeProvider at the root of your application, and pass the `theme` and `activeColorScheme`. +Render the following providers at the root of your application: + +- **ThemeProvider** — applies the CDS theme and color scheme +- **PortalProvider** — manages the registry of active overlay components (Modal, Toast, Alert, Tooltip, Tray). ([read more →](/components/overlay/PortalProvider)) ```tsx import { ThemeProvider } from '@coinbase/cds-mobile/system'; +import { PortalProvider } from '@coinbase/cds-mobile/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; import App from './App'; const Index = () => ( - + + + ); diff --git a/apps/docs/docs/getting-started/installation/_webContent.mdx b/apps/docs/docs/getting-started/installation/_webContent.mdx index b6acc2e1b8..bf4873b6e4 100644 --- a/apps/docs/docs/getting-started/installation/_webContent.mdx +++ b/apps/docs/docs/getting-started/installation/_webContent.mdx @@ -49,21 +49,26 @@ import '@coinbase/cds-web/defaultFontStyles'; -### 2. Render a ThemeProvider and MediaQueryProvider +### 2. Render providers -Render a ThemeProvider at the root of your application, and pass the `theme` and `activeColorScheme`. +Render the following providers at the root of your application: -Render a MediaQueryProvider for components that use the `useMediaQuery` hook. +- **ThemeProvider** — applies the CDS theme and color scheme +- **MediaQueryProvider** — prevents issues with `window.matchMedia()` in SSR environments ([read more →](/components/other/MediaQueryProvider#server-side-rendering)) +- **PortalProvider** — creates the DOM containers required by overlay components (Modal, Toast, Alert, Tooltip, Tray). ([read more →](/components/overlay/PortalProvider)) ```tsx import { ThemeProvider, MediaQueryProvider } from '@coinbase/cds-web/system'; +import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import App from './App'; const Index = () => ( - + + + ); @@ -71,11 +76,6 @@ const Index = () => ( export default Index; ``` -:::tip -The MediaQueryProvider prevents issues with `window.matchMedia()` in SSR environments. -[Read more here →](/components/other/MediaQueryProvider#server-side-rendering) -::: - ### 3. Verify the installation @@ -104,6 +104,7 @@ import '@coinbase/cds-web/defaultFontStyles'; import '@coinbase/cds-web/globalStyles'; import { createRoot } from 'react-dom/client'; import { ThemeProvider, MediaQueryProvider } from '@coinbase/cds-web/system'; +import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import App from './App'; @@ -112,7 +113,9 @@ const root = createRoot(document.getElementById('root')); root.render( - + + + , ); diff --git a/apps/docs/docs/hooks/useMergeRefs/_api.mdx b/apps/docs/docs/hooks/useMergeRefs/_api.mdx index 0266f16ead..ef38176406 100644 --- a/apps/docs/docs/hooks/useMergeRefs/_api.mdx +++ b/apps/docs/docs/hooks/useMergeRefs/_api.mdx @@ -5,10 +5,10 @@ import { MDXArticle } from '@site/src/components/page/MDXArticle'; The `useMergeRefs` hook accepts a spread of refs as its parameter: -- `...refs: (React.MutableRefObject | React.LegacyRef | undefined | null)[]` - An array of refs to merge. Can include: - - `MutableRefObject` - Object-based refs created with `useRef` - - `LegacyRef` - Function-based refs or string refs (legacy) - - `undefined` or `null` - Optional refs that might not be provided +- `...refs: (React.Ref | undefined)[]` - An array of refs to merge. Can include: + - `RefObject` - Object-based refs created with `useRef` / `createRef` + - `RefCallback` - Function refs + - `undefined` - Optional refs that might not be provided diff --git a/apps/docs/docs/hooks/useMergeRefs/metadata.json b/apps/docs/docs/hooks/useMergeRefs/metadata.json index 22d542ff63..f958e8363a 100644 --- a/apps/docs/docs/hooks/useMergeRefs/metadata.json +++ b/apps/docs/docs/hooks/useMergeRefs/metadata.json @@ -1,5 +1,5 @@ { - "import": "import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'", + "import": "import { useMergeRefs } from '@coinbase/cds-common/utils/mergeRefs'", "source": "https://github.com/coinbase/cds/blob/master/packages/common/src/hooks/useMergeRefs.ts", "description": "Combines multiple refs into a single ref callback, allowing a component to work with multiple ref instances simultaneously. Useful when you need to combine refs from different sources, such as forwarded refs and internal component state." } diff --git a/apps/docs/package.json b/apps/docs/package.json index 76d8e4f2a3..c86523e25b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -55,16 +55,16 @@ "lz-string": "^1.5.0", "prettier": "^3.6.2", "prism-react-renderer": "^2.4.1", - "react": "^18.3.1", + "react": "19.1.2", "react-colorful": "^5.6.1", - "react-dom": "^18.3.1", + "react-dom": "19.1.2", "react-live": "^4.1.8", "three": "0.177.0" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/module-type-aliases": "~3.7.0", "@docusaurus/tsconfig": "~3.7.0", @@ -73,8 +73,8 @@ "@linaria/core": "^3.0.0-beta.22", "@linaria/webpack-loader": "^3.0.0-beta.22", "@types/lz-string": "^1.5.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", "@types/three": "0.177.0", "babel-loader": "^10.0.0", "css-loader": "^7.1.2", diff --git a/apps/docs/src/components/ButtonLink/index.tsx b/apps/docs/src/components/ButtonLink/index.tsx index c1f11fd01b..960b28b89d 100644 --- a/apps/docs/src/components/ButtonLink/index.tsx +++ b/apps/docs/src/components/ButtonLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { Button, type ButtonProps } from '@coinbase/cds-web/buttons'; import isInternalUrl from '@docusaurus/isInternalUrl'; diff --git a/apps/docs/src/components/FooterLink/index.tsx b/apps/docs/src/components/FooterLink/index.tsx index adaacdfac4..d35c99daf2 100644 --- a/apps/docs/src/components/FooterLink/index.tsx +++ b/apps/docs/src/components/FooterLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Text, type TextDefaultElement, type TextProps } from '@coinbase/cds-web/typography/Text'; import isInternalUrl from '@docusaurus/isInternalUrl'; import Link, { type Props } from '@docusaurus/Link'; diff --git a/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx b/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx index 2cbe2708f3..8978b282d9 100644 --- a/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx +++ b/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx @@ -66,10 +66,10 @@ export const HeroCell = ({ diff --git a/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx b/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx index 0e2a272823..76b638e100 100644 --- a/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx +++ b/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx @@ -9,8 +9,8 @@ export type QuickStartLinkProps = { title: string; description: string; link: { label: string; to: string } | { label: string; href: string }; - BannerComponentLight: React.ComponentType>; - BannerComponentDark: React.ComponentType>; + BannerComponentLight: React.ComponentType<{ width?: string | number; height?: string | number }>; + BannerComponentDark: React.ComponentType<{ width?: string | number; height?: string | number }>; }; const cardTitleFontConfig = { base: 'title4', desktop: 'title2' } as const; diff --git a/apps/docs/src/components/kbar/KBarAnimator.tsx b/apps/docs/src/components/kbar/KBarAnimator.tsx index 1d0d78a6f5..aa2a8f67b5 100644 --- a/apps/docs/src/components/kbar/KBarAnimator.tsx +++ b/apps/docs/src/components/kbar/KBarAnimator.tsx @@ -30,7 +30,7 @@ const KBarAnimator = memo(function KBarAnimator({ children }: { children: React. const exitMs = options?.animations?.exitMs ?? 0; // Height animation - const previousHeight = useRef(); + const previousHeight = useRef(undefined); useEffect(() => { // Only animate if we're actually showing if (visualState === VisualState.showing) { diff --git a/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx b/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx index c4f7f37aee..657386e7f9 100644 --- a/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx +++ b/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx @@ -9,7 +9,7 @@ import { Link, type LinkBaseProps } from '@coinbase/cds-web/typography/Link'; export type ModalLinkProps = { children: string; content: React.ReactElement; - modalBodyRef?: React.RefObject; + modalBodyRef?: React.RefObject; modalBodyProps?: Omit; title?: string; } & Omit; diff --git a/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx b/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx index da4815ee12..791ebe1ca0 100644 --- a/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx +++ b/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx @@ -22,7 +22,7 @@ function ParentTypesTable({ sharedParentTypes, props, scrollContainerRef, -}: ParentTypesItem & { scrollContainerRef: React.RefObject }) { +}: ParentTypesItem & { scrollContainerRef: React.RefObject }) { const [searchValue, setSearchValue] = useState(''); const filteredProps = useMemo( () => diff --git a/apps/docs/src/components/page/ComponentTabsContainer/index.tsx b/apps/docs/src/components/page/ComponentTabsContainer/index.tsx index c7945480b2..a3bdde2817 100644 --- a/apps/docs/src/components/page/ComponentTabsContainer/index.tsx +++ b/apps/docs/src/components/page/ComponentTabsContainer/index.tsx @@ -49,7 +49,7 @@ const CustomTab = ({ id, label }: TabValue) => { }; const CustomTabsActiveIndicator = (props: TabsActiveIndicatorProps) => { - return ; + return ; }; export const ComponentTabsContainer: React.FC = ({ diff --git a/apps/docs/src/components/page/HookTabsContainer/index.tsx b/apps/docs/src/components/page/HookTabsContainer/index.tsx index ac0273052b..df2a2cbc95 100644 --- a/apps/docs/src/components/page/HookTabsContainer/index.tsx +++ b/apps/docs/src/components/page/HookTabsContainer/index.tsx @@ -43,7 +43,7 @@ const CustomTab = ({ id, label }: TabValue) => { }; const CustomTabsActiveIndicator = (props: TabsActiveIndicatorProps) => { - return ; + return ; }; export const HookTabsContainer: React.FC = ({ diff --git a/apps/docs/src/components/page/JSONCodeBlock/index.tsx b/apps/docs/src/components/page/JSONCodeBlock/index.tsx index f477eb4ea6..80eae8e223 100644 --- a/apps/docs/src/components/page/JSONCodeBlock/index.tsx +++ b/apps/docs/src/components/page/JSONCodeBlock/index.tsx @@ -5,8 +5,11 @@ import styles from './styles.module.css'; export const JSONCodeBlock = ({ json }: { json: Serializable }) => { return ( - - {JSON.stringify(json, null, 2)} - + <> + + {JSON.stringify(json, null, 2)} + +
+ ); }; diff --git a/apps/docs/src/components/page/ShareablePlayground/index.tsx b/apps/docs/src/components/page/ShareablePlayground/index.tsx index b459d94057..a7bb73b72d 100644 --- a/apps/docs/src/components/page/ShareablePlayground/index.tsx +++ b/apps/docs/src/components/page/ShareablePlayground/index.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LiveEditor, LiveError, LivePreview, LiveProvider } from 'react-live'; import { Collapsible } from '@coinbase/cds-web/collapsible/Collapsible'; import { Icon } from '@coinbase/cds-web/icons/Icon'; diff --git a/apps/docs/src/components/page/SheetTabs/index.tsx b/apps/docs/src/components/page/SheetTabs/index.tsx index 1d778b0cc0..c6cfd46b80 100644 --- a/apps/docs/src/components/page/SheetTabs/index.tsx +++ b/apps/docs/src/components/page/SheetTabs/index.tsx @@ -43,8 +43,8 @@ export const SheetTabs = ( props: Omit, ) => ( ); diff --git a/apps/docs/src/theme/DocItem/Layout/index.tsx b/apps/docs/src/theme/DocItem/Layout/index.tsx index 2d673b5dc5..3c4740d6d9 100644 --- a/apps/docs/src/theme/DocItem/Layout/index.tsx +++ b/apps/docs/src/theme/DocItem/Layout/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { type JSX, useMemo } from 'react'; import { VStack } from '@coinbase/cds-web/layout'; import type { DocFrontMatter } from '@docusaurus/plugin-content-docs'; import { useDoc } from '@docusaurus/plugin-content-docs/client'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx index 030ceee955..08882be91d 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { HStack } from '@coinbase/cds-web/layout'; import type { Props } from '@theme/DocRoot/Layout/Main'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx index c0afc87f1d..2fc394fef1 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { translate } from '@docusaurus/Translate'; import type { Props } from '@theme/DocRoot/Layout/Sidebar/ExpandButton'; import IconArrow from '@theme/Icon/Arrow'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx index 9f02321b23..762a91b61b 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { type ReactNode, useCallback, useState } from 'react'; +import React, { type JSX, type ReactNode, useCallback, useState } from 'react'; import { cx } from '@coinbase/cds-web'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; import { useLocation } from '@docusaurus/router'; diff --git a/apps/docs/src/theme/DocRoot/Layout/index.tsx b/apps/docs/src/theme/DocRoot/Layout/index.tsx index 298c4a2c14..d8f86881e6 100644 --- a/apps/docs/src/theme/DocRoot/Layout/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { type JSX, useState } from 'react'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; import BackToTopButton from '@theme/BackToTopButton'; diff --git a/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx b/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx index 2d4404764b..e33abd8ec9 100644 --- a/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx +++ b/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { translate } from '@docusaurus/Translate'; import type { Props } from '@theme/DocSidebar/Desktop/CollapseButton'; diff --git a/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx b/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx index aeea018217..80e9120374 100644 --- a/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx +++ b/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { type JSX, useState } from 'react'; import { cx } from '@coinbase/cds-web'; import { VStack } from '@coinbase/cds-web/layout'; import { ThemeClassNames } from '@docusaurus/theme-common'; diff --git a/apps/docs/src/theme/DocSidebar/index.tsx b/apps/docs/src/theme/DocSidebar/index.tsx index 6cc90ff6c3..1d00f852ba 100644 --- a/apps/docs/src/theme/DocSidebar/index.tsx +++ b/apps/docs/src/theme/DocSidebar/index.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import type { PropSidebarItem } from '@docusaurus/plugin-content-docs'; import { useWindowSizeWithBreakpointOverride } from '@site/src/utils/useWindowSizeWithBreakpointOverride'; import type { Props } from '@theme/DocSidebar'; import DocSidebarDesktop from '@theme/DocSidebar/Desktop'; import DocSidebarMobile from '@theme/DocSidebar/Mobile'; -export default function DocSidebar(props: Props): JSX.Element { +export default function DocSidebar({ sidebar, ...props }: Props): JSX.Element { const windowSize = useWindowSizeWithBreakpointOverride(); const filterItems = (items: PropSidebarItem[] = []): PropSidebarItem[] => { @@ -13,7 +13,7 @@ export default function DocSidebar(props: Props): JSX.Element { }; // Filter the sidebar items - const filteredSidebar = filterItems([...props.sidebar]); + const filteredSidebar = filterItems([...sidebar]); // Desktop sidebar visible on hydration: need SSR rendering const shouldRenderSidebarDesktop = windowSize === 'desktop' || windowSize === 'ssr'; @@ -23,8 +23,8 @@ export default function DocSidebar(props: Props): JSX.Element { return ( <> - {shouldRenderSidebarDesktop && } - {shouldRenderSidebarMobile && } + {shouldRenderSidebarDesktop && } + {shouldRenderSidebarMobile && } ); } diff --git a/apps/docs/src/theme/DocSidebarItem/Category/index.tsx b/apps/docs/src/theme/DocSidebarItem/Category/index.tsx index 8a2ae4f429..6a1423b0ed 100644 --- a/apps/docs/src/theme/DocSidebarItem/Category/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Category/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { type JSX, useCallback, useEffect, useMemo } from 'react'; import type { IconName } from '@coinbase/cds-common/types'; import { cx } from '@coinbase/cds-web'; import { Collapsible } from '@coinbase/cds-web/collapsible'; diff --git a/apps/docs/src/theme/DocSidebarItem/Html/index.tsx b/apps/docs/src/theme/DocSidebarItem/Html/index.tsx index 8418d584b0..3eb57c8a0c 100644 --- a/apps/docs/src/theme/DocSidebarItem/Html/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Html/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { ThemeClassNames } from '@docusaurus/theme-common'; import type { Props } from '@theme/DocSidebarItem/Html'; diff --git a/apps/docs/src/theme/DocSidebarItem/Link/index.tsx b/apps/docs/src/theme/DocSidebarItem/Link/index.tsx index 7dbd328c60..dcc123b256 100644 --- a/apps/docs/src/theme/DocSidebarItem/Link/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Link/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system'; import isInternalUrl from '@docusaurus/isInternalUrl'; diff --git a/apps/docs/src/theme/DocSidebarItem/index.tsx b/apps/docs/src/theme/DocSidebarItem/index.tsx index 13602a3add..564a375f02 100644 --- a/apps/docs/src/theme/DocSidebarItem/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import type { Props } from '@theme/DocSidebarItem'; import DocSidebarItemCategory from '@theme/DocSidebarItem/Category'; import DocSidebarItemHtml from '@theme/DocSidebarItem/Html'; diff --git a/apps/docs/src/theme/Footer/index.tsx b/apps/docs/src/theme/Footer/index.tsx index b9f2383dd2..d870727d7a 100644 --- a/apps/docs/src/theme/Footer/index.tsx +++ b/apps/docs/src/theme/Footer/index.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; import type { FooterLinkItem } from '@docusaurus/theme-common'; diff --git a/apps/docs/src/theme/Heading/index.tsx b/apps/docs/src/theme/Heading/index.tsx index 27624ceed4..6f11e8370d 100644 --- a/apps/docs/src/theme/Heading/index.tsx +++ b/apps/docs/src/theme/Heading/index.tsx @@ -15,7 +15,7 @@ export default function Heading({ as: As, id, ...props }: Props): ReactNode { } = useThemeConfig(); // H1 headings do not need an id because they don't appear in the TOC. if (As === 'h1' || !id) { - return ; + return ; } brokenLinks.collectAnchor(id); @@ -33,13 +33,13 @@ export default function Heading({ as: As, id, ...props }: Props): ReactNode { return ( {props.children} diff --git a/apps/docs/src/theme/Layout/Provider/index.tsx b/apps/docs/src/theme/Layout/Provider/index.tsx index 4e5b7edb03..4a69c5a256 100644 --- a/apps/docs/src/theme/Layout/Provider/index.tsx +++ b/apps/docs/src/theme/Layout/Provider/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultFontStyles } from '@coinbase/cds-web/styles/defaultFont'; diff --git a/apps/docs/src/theme/Layout/index.tsx b/apps/docs/src/theme/Layout/index.tsx index 2359cd1615..ca156a67da 100644 --- a/apps/docs/src/theme/Layout/index.tsx +++ b/apps/docs/src/theme/Layout/index.tsx @@ -1,6 +1,6 @@ import '@coinbase/cds-icons/fonts/web/icon-font.css'; -import { useCallback } from 'react'; +import { type JSX, useCallback } from 'react'; import { cx } from '@coinbase/cds-web'; import type { FallbackParams } from '@docusaurus/ErrorBoundary'; import ErrorBoundary from '@docusaurus/ErrorBoundary'; diff --git a/apps/docs/src/theme/Navbar/Content/index.tsx b/apps/docs/src/theme/Navbar/Content/index.tsx index cd955bb229..f7cb124bb4 100644 --- a/apps/docs/src/theme/Navbar/Content/index.tsx +++ b/apps/docs/src/theme/Navbar/Content/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { type JSX, useMemo, useRef } from 'react'; import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions'; import { HStack } from '@coinbase/cds-web/layout/HStack'; import { Tooltip } from '@coinbase/cds-web/overlays/tooltip/Tooltip'; diff --git a/apps/docs/src/theme/Navbar/Layout/index.tsx b/apps/docs/src/theme/Navbar/Layout/index.tsx index 8270f353e3..56a1cffabd 100644 --- a/apps/docs/src/theme/Navbar/Layout/index.tsx +++ b/apps/docs/src/theme/Navbar/Layout/index.tsx @@ -10,7 +10,7 @@ import { useWindowSizeWithBreakpointOverride } from '../../../utils/useWindowSiz import styles from './styles.module.css'; -function NavbarBackdrop(props: ComponentProps<'div'>) { +function NavbarBackdrop({ className, ...props }: ComponentProps<'div'>) { const mobileSidebar = useNavbarMobileSidebar(); const windowSize = useWindowSizeWithBreakpointOverride(); if (mobileSidebar.disabled || windowSize !== 'mobile') { @@ -18,10 +18,10 @@ function NavbarBackdrop(props: ComponentProps<'div'>) { } return (
); } diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx index 3dcbeb2188..7daea4fbba 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { IconButton } from '@coinbase/cds-web/buttons'; import { HStack } from '@coinbase/cds-web/layout'; import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx index 0b137e1295..79428c1219 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { VStack } from '@coinbase/cds-web/layout'; import type { Props } from '@theme/Navbar/MobileSidebar/Layout'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx index 49990f1468..b08dbecabd 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Icon } from '@coinbase/cds-web/icons/Icon'; import { HStack, VStack } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system/Pressable'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx index 952a69bcf6..a742b006d1 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { IconButton } from '@coinbase/cds-web/buttons'; import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; import { translate } from '@docusaurus/Translate'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx index ac0a3d8956..8aba5f2669 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { type JSX, useCallback, useEffect, useRef } from 'react'; import { FocusTrap } from '@coinbase/cds-web/overlays/FocusTrap'; import { useLockBodyScroll, useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; import { useWindowSizeWithBreakpointOverride } from '@site/src/utils/useWindowSizeWithBreakpointOverride'; diff --git a/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx b/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx index 1db8fbf5b9..25c0bff7d7 100644 --- a/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx +++ b/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { Box } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system/Pressable'; diff --git a/apps/docs/src/theme/Playground/index.tsx b/apps/docs/src/theme/Playground/index.tsx index a14d4d04f1..0a0028900f 100644 --- a/apps/docs/src/theme/Playground/index.tsx +++ b/apps/docs/src/theme/Playground/index.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from 'react'; import { LiveEditor, LiveError, LivePreview, LiveProvider, withLive } from 'react-live'; import { Collapsible } from '@coinbase/cds-web/collapsible/Collapsible'; import { Icon } from '@coinbase/cds-web/icons/Icon'; diff --git a/apps/docs/src/utils/useIsSticky.ts b/apps/docs/src/utils/useIsSticky.ts index 4581b19a1a..94a4a3757c 100644 --- a/apps/docs/src/utils/useIsSticky.ts +++ b/apps/docs/src/utils/useIsSticky.ts @@ -13,12 +13,12 @@ type UseStickyOptions = { * Optional ref to a container element. If provided, the sticky behavior will be relative * to this container instead of the viewport. */ - containerRef?: RefObject; + containerRef?: RefObject; }; type UseStickyResult = { /** Ref to attach to the element that should become sticky */ - elementRef: RefObject; + elementRef: RefObject; /** Whether the element is currently in "sticky" state */ isSticky: boolean; }; @@ -38,7 +38,7 @@ type UseStickyResult = { export function useIsSticky(options: UseStickyOptions = {}): UseStickyResult { const { top = 0, containerRef } = options; - const elementRef = useRef(null); + const elementRef = useRef(null); const [isSticky, setIsSticky] = useState(false); useEffect(() => { diff --git a/apps/docs/src/utils/useThrottledValue.ts b/apps/docs/src/utils/useThrottledValue.ts index 6697e746a2..dcd32bed76 100644 --- a/apps/docs/src/utils/useThrottledValue.ts +++ b/apps/docs/src/utils/useThrottledValue.ts @@ -18,7 +18,7 @@ export const useThrottledValue = (value: T, delay: number) => { const lastExecutedAt = useRef(0); // Ref to store the timeout ID that ensures the final synchronization of the throttled value after the value has not changed for the delay period - const throttleTimeoutIdRef = useRef>(); + const throttleTimeoutIdRef = useRef>(undefined); // updates the throttled value and schedules a final update after the delay period if needed const updateThrottledValue = useCallback( diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index 83b8d76522..0ac3299478 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -26,42 +26,39 @@ "@formatjs/intl-locale": "^4.2.11", "@formatjs/intl-numberformat": "^8.15.4", "@formatjs/intl-pluralrules": "^5.4.4", - "@react-native/metro-config": "^0.72.9", "@react-navigation/core": "^6.4.16", - "@react-navigation/native": "^6.1.6", - "@react-navigation/native-stack": "^6.9.26", + "@react-navigation/native": "6.1.17", + "@react-navigation/native-stack": "6.9.26", "@react-navigation/stack": "^6.3.16", - "@shopify/react-native-skia": "1.12.4", - "expo": "~51.0.31", - "expo-application": "~5.9.1", - "expo-asset": "~10.0.10", - "expo-build-properties": "~0.12.5", - "expo-clipboard": "~6.0.3", - "expo-dev-client": "4.0.27", - "expo-font": "~12.0.9", - "expo-gradle-ext-vars": "^0.1.1", - "expo-linking": "~6.3.1", - "expo-quick-actions": "2.0.0", - "expo-splash-screen": "~0.27.6", - "expo-status-bar": "~1.12.1", - "expo-system-ui": "~3.0.7", + "@shopify/react-native-skia": "2.2.12", + "expo": "54.0.32", + "expo-application": "~7.0.8", + "expo-asset": "12.0.12", + "expo-build-properties": "1.0.10", + "expo-clipboard": "8.0.8", + "expo-dev-client": "6.0.20", + "expo-font": "14.0.11", + "expo-linking": "~8.0.11", + "expo-quick-actions": "6.0.1", + "expo-splash-screen": "31.0.13", + "expo-status-bar": "3.0.9", + "expo-system-ui": "~6.0.9", "intl": "^1.2.5", - "lottie-react-native": "6.7.0", - "react": "^18.3.1", - "react-native": "0.74.5", - "react-native-gesture-handler": "2.16.2", + "lottie-react-native": "7.3.1", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-gesture-handler": "2.28.0", "react-native-inappbrowser-reborn": "3.7.0", "react-native-navigation-bar-color": "2.0.2", - "react-native-reanimated": "3.14.0", - "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.32.0", - "react-native-svg": "14.1.0" + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-screens": "4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" }, "devDependencies": { - "@babel/core": "^7.28.0", - "@expo/config": "~9.0.0", - "@expo/config-types": "~51.0.2", - "@types/react": "^18.3.12", + "@expo/config-types": "54.0.10", + "@types/react": "19.1.2", "babel-plugin-transform-inline-environment-variables": "^0.4.4", "detox": "^20.14.8", "jest": "^29.7.0", diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index a071d79cfa..c73c77c50d 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -54,6 +54,11 @@ export const routes = [ require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') .default, }, + { + key: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, { key: 'AnimatedCaret', getComponent: () => diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index a071d79cfa..c73c77c50d 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -54,6 +54,11 @@ export const routes = [ require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') .default, }, + { + key: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, { key: 'AnimatedCaret', getComponent: () => diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 9549d3063d..75a37463dc 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -24,8 +24,8 @@ const bundleStatsFilename = path.resolve( ); const addons = [ // '@chromatic-com/storybook', - '@storybook/addon-storysource', '@storybook-community/storybook-dark-mode', + '@storybook/addon-docs', ...(!isPercyBuild ? ['@storybook/addon-a11y', '@storybook/addon-vitest'] : []), ]; diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index 7090edef8b..618f80314f 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -54,7 +54,7 @@ const preview: Preview = { decorators: [StoryContainer], parameters: { layout: 'fullscreen', - backgrounds: { disable: true }, + backgrounds: { disabled: true }, globalStyles: `${globalStyles} ${defaultFontStyles}`, controls: { matchers: { diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 561f5f3ab3..b7604ebe0c 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -12,35 +12,35 @@ "@coinbase/cds-illustrations": "workspace:^", "@coinbase/cds-web": "workspace:^", "@coinbase/cds-web-visualization": "workspace:^", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "19.1.2", + "react-dom": "19.1.2" }, "devDependencies": { "@linaria/babel-preset": "^3.0.0-beta.22", "@linaria/core": "^3.0.0-beta.22", "@linaria/rollup": "^3.0.0-beta.22", "@percy/cli": "^1.31.1", - "@percy/storybook": "^9.0.0", + "@percy/storybook": "^9.1.0", "@shopify/storybook-a11y-test": "^1.2.1", "@storybook-community/storybook-dark-mode": "^6.0.0", "@storybook/addon-a11y": "^9.1.19", - "@storybook/addon-storysource": "^8.6.14", + "@storybook/addon-docs": "9.1.17", "@storybook/addon-vitest": "^9.1.2", "@storybook/jest": "^0.2.3", - "@storybook/react-vite": "^9.1.2", + "@storybook/react-vite": "9.1.17", "@storybook/testing-library": "^0.2.2", "@types/diff": "^5.0.9", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.0.0", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "^5.1.2", "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "diff": "^5.1.0", "playwright": "^1.58.2", "rollup-plugin-visualizer": "^6.0.3", - "storybook": "^9.1.2", + "storybook": "9.1.17", "typescript": "~5.9.2", - "vite": "^7.1.2", + "vite": "^7.3.1", "vitest": "^4.0.18" } } diff --git a/apps/test-expo/.gitignore b/apps/test-expo/.gitignore new file mode 100644 index 0000000000..350adace4c --- /dev/null +++ b/apps/test-expo/.gitignore @@ -0,0 +1,47 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +dts/ +*.d.ts +*.d.ts.map + +# generated native folders +/ios +/android + +# custom build artifacts (generated, too large to commit) +builds/ diff --git a/apps/test-expo/App.tsx b/apps/test-expo/App.tsx new file mode 100644 index 0000000000..c6da3cae5a --- /dev/null +++ b/apps/test-expo/App.tsx @@ -0,0 +1,69 @@ +import React, { memo, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import type { ColorScheme } from '@coinbase/cds-common/core/theme'; +import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { PortalProvider } from '@coinbase/cds-mobile/overlays/PortalProvider'; +import { StatusBar } from '@coinbase/cds-mobile/system/StatusBar'; +import { ThemeProvider } from '@coinbase/cds-mobile/system/ThemeProvider'; +import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; +import { ChartBridgeProvider } from '@coinbase/cds-mobile-visualization'; +import { NavigationContainer } from '@react-navigation/native'; +import * as Linking from 'expo-linking'; +import * as SplashScreen from 'expo-splash-screen'; + +import { useFonts } from './src/hooks/useFonts'; +import { Playground } from './src/playground'; +import { routes as codegenRoutes } from './src/routes'; + +const linking = { + prefixes: [Linking.createURL('/')], +}; + +if (Platform.OS === 'android') { + require('intl'); + require('intl/locale-data/jsonp/en-US'); +} + +const gestureHandlerStyle = { flex: 1 }; + +const CdsSafeAreaProvider: React.FC> = memo(({ children }) => { + const theme = useTheme(); + const style = useMemo(() => ({ backgroundColor: theme.color.bg }), [theme.color.bg]); + return {children}; +}); + +const App = memo(() => { + const [fontsLoaded] = useFonts(); + const [colorScheme, setColorScheme] = useState('light'); + + React.useEffect(() => { + if (fontsLoaded) { + SplashScreen.hideAsync(); + } + }, [fontsLoaded]); + + if (!fontsLoaded) { + return null; + } + + return ( + + + + + + + + + + + ); +}); + +export default App; diff --git a/apps/test-expo/README.md b/apps/test-expo/README.md new file mode 100644 index 0000000000..4ab9c2990a --- /dev/null +++ b/apps/test-expo/README.md @@ -0,0 +1,103 @@ +# test-expo + +Expo-based demo app for testing CDS mobile components. + +## Building and Running + +### Commands + +| Command | Description | Artifacts | +| ------------------------------------------------------- | ------------------------------------------------------------ | ------------------------ | +| `yarn nx run test-expo:build --configuration=` | Builds standalone app artifacts | See configurations below | +| `yarn nx run test-expo:launch --configuration=` | Installs build artifact on simulator/emulator | None | +| `yarn nx run test-expo:ios` | Builds (if needed), installs, launches app, and starts Metro | Runs app + Metro | +| `yarn nx run test-expo:android` | Builds (if needed), installs, launches app, and starts Metro | Runs app + Metro | +| `yarn nx run test-expo:start` | Starts Metro bundler | None | +| `yarn nx run test-expo:validate` | Checks Expo dependency versions for compatibility | None | + +### Build Configurations + +| Configuration | Platform | Profile | Target | Output | +| -------------------- | -------- | ------- | --------- | ---------------------------------------- | +| `ios-debug` | iOS | Debug | Simulator | `builds/ios-debug/testexpo.tar.gz` | +| `ios-release` | iOS | Release | Simulator | `builds/ios-release/testexpo.tar.gz` | +| `ios-debug-device` | iOS | Debug | Device | `builds/ios-debug-device/testexpo.ipa` | +| `ios-release-device` | iOS | Release | Device | `builds/ios-release-device/testexpo.ipa` | +| `android-debug` | Android | Debug | Emulator | `builds/android-debug/testexpo.apk` | +| `android-release` | Android | Release | Emulator | `builds/android-release/testexpo.apk` | + +### Local Development Setup + +For a general setup guide covering all platform and device combinations (iOS/Android, simulator/real device), see [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/). + +#### iOS Simulator + +iOS works seamlessly with build artifacts. + +For first-time setup, see the [Expo iOS Simulator guide](https://docs.expo.dev/workflow/ios-simulator/). + +1. **Run the app**: + + ```bash + yarn nx run test-expo:ios + ``` + + This will: + - Build the app if no artifact exists at `builds/ios-debug/testexpo.tar.gz` + - Boot the iOS Simulator if not already running + - Extract, install, and launch the app + - Start Metro bundler + +2. **Rebuild when native dependencies change**: + ```bash + rm -rf builds/ios-debug + yarn nx run test-expo:ios + ``` + +#### Android Emulator + +Android requires more manual steps due to expo-dev-client limitations. + +For first-time setup, see the [Expo Android Studio Emulator guide](https://docs.expo.dev/workflow/android-studio-emulator/). + +1. **Prerequisites**: + - Android Studio installed with an emulator configured + - `ANDROID_HOME` environment variable set + +2. **Run the app**: + + ```bash + yarn nx run test-expo:android + ``` + + This will: + - Build the APK if no artifact exists at `builds/android-debug/testexpo.apk` + - Start the Android emulator if not already running + - Install and launch the app via adb + - Start Metro bundler + +3. **Troubleshooting**: + + If the app doesn't connect to Metro automatically: + - Press `r` in the Metro terminal to reload the app + - Or shake the device / press Cmd+M to open the dev menu and select "Reload" + + If Metro connection fails entirely: + + ```bash + adb reverse tcp:8081 tcp:8081 + ``` + + Then reload the app. + +4. **Rebuild when native dependencies change**: + ```bash + rm -rf builds/android-debug + yarn nx run test-expo:android + ``` + +### Expo Go Compatibility + +This app cannot run in Expo Go due to dependencies on native modules. Specifically, `@react-native-community/datetimepicker` (used by cds-mobile) contains native code not included in Expo Go. + +You must use the development build workflow described above. diff --git a/apps/test-expo/app.json b/apps/test-expo/app.json new file mode 100644 index 0000000000..06696df75a --- /dev/null +++ b/apps/test-expo/app.json @@ -0,0 +1,31 @@ +{ + "expo": { + "name": "test-expo", + "slug": "test-expo", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.test-expo" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "package": "com.anonymous.testexpo" + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/apps/test-expo/assets/adaptive-icon.png b/apps/test-expo/assets/adaptive-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/apps/test-expo/assets/adaptive-icon.png differ diff --git a/apps/test-expo/assets/favicon.png b/apps/test-expo/assets/favicon.png new file mode 100644 index 0000000000..e75f697b18 Binary files /dev/null and b/apps/test-expo/assets/favicon.png differ diff --git a/apps/test-expo/assets/icon.png b/apps/test-expo/assets/icon.png new file mode 100644 index 0000000000..a0b1526fc7 Binary files /dev/null and b/apps/test-expo/assets/icon.png differ diff --git a/apps/test-expo/assets/splash-icon.png b/apps/test-expo/assets/splash-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/apps/test-expo/assets/splash-icon.png differ diff --git a/apps/test-expo/babel.config.js b/apps/test-expo/babel.config.js new file mode 100644 index 0000000000..8c5c851083 --- /dev/null +++ b/apps/test-expo/babel.config.js @@ -0,0 +1,10 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + // IMPORTANT: react-native-worklets/plugin must be listed LAST + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/apps/test-expo/index.js b/apps/test-expo/index.js new file mode 100644 index 0000000000..131fa8562d --- /dev/null +++ b/apps/test-expo/index.js @@ -0,0 +1,10 @@ +import './polyfills/intl'; + +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/apps/test-expo/package.json b/apps/test-expo/package.json new file mode 100644 index 0000000000..74dd641d37 --- /dev/null +++ b/apps/test-expo/package.json @@ -0,0 +1,43 @@ +{ + "name": "test-expo", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "@coinbase/cds-icons": "workspace:^", + "@coinbase/cds-mobile": "workspace:^", + "@coinbase/cds-mobile-visualization": "workspace:^", + "@expo-google-fonts/inter": "^0.3.0", + "@expo-google-fonts/source-code-pro": "^0.3.0", + "@formatjs/intl-getcanonicallocales": "^2.5.5", + "@formatjs/intl-locale": "^4.2.11", + "@formatjs/intl-numberformat": "^8.15.4", + "@formatjs/intl-pluralrules": "^5.4.4", + "@react-navigation/native": "6.1.17", + "@react-navigation/native-stack": "6.9.26", + "@shopify/react-native-skia": "2.2.12", + "expo": "54.0.32", + "expo-dev-client": "6.0.20", + "expo-font": "14.0.11", + "expo-linking": "~8.0.11", + "expo-splash-screen": "31.0.13", + "expo-status-bar": "3.0.9", + "intl": "^1.2.5", + "lottie-react-native": "7.3.1", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-date-picker": "5.0.12", + "react-native-gesture-handler": "2.28.0", + "react-native-inappbrowser-reborn": "3.7.0", + "react-native-navigation-bar-color": "2.0.2", + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-screens": "4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" + }, + "private": true, + "scripts": { + "android": "expo run:android", + "ios": "expo run:ios" + } +} diff --git a/apps/test-expo/polyfills/intl.ts b/apps/test-expo/polyfills/intl.ts new file mode 100644 index 0000000000..1e5e037308 --- /dev/null +++ b/apps/test-expo/polyfills/intl.ts @@ -0,0 +1,6 @@ +import '@formatjs/intl-getcanonicallocales/polyfill'; +import '@formatjs/intl-locale/polyfill'; +import '@formatjs/intl-pluralrules/polyfill'; +import '@formatjs/intl-numberformat/polyfill'; +import '@formatjs/intl-pluralrules/locale-data/en'; +import '@formatjs/intl-numberformat/locale-data/en'; diff --git a/apps/test-expo/project.json b/apps/test-expo/project.json new file mode 100644 index 0000000000..e1a8d776dc --- /dev/null +++ b/apps/test-expo/project.json @@ -0,0 +1,89 @@ +{ + "name": "test-expo", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/test-expo", + "tags": [], + "targets": { + "start": { + "command": "npx expo start", + "options": { + "cwd": "apps/test-expo" + } + }, + "ios": { + "command": "node ./scripts/run.mjs --platform ios", + "options": { + "cwd": "apps/test-expo" + } + }, + "android": { + "command": "node ./scripts/run.mjs --platform android", + "options": { + "cwd": "apps/test-expo" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "typecheck": { + "command": "tsc --build --pretty --verbose" + }, + "validate": { + "command": "npx expo install --check", + "options": { + "cwd": "apps/test-expo" + } + }, + "launch": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/test-expo", + "command": "node ./scripts/launch.mjs --platform {args.platform} --profile {args.profile}" + }, + "defaultConfiguration": "ios-debug", + "configurations": { + "ios-debug": { + "args": "--platform ios --profile debug" + }, + "ios-release": { + "args": "--platform ios --profile release" + }, + "android-debug": { + "args": "--platform android --profile debug" + }, + "android-release": { + "args": "--platform android --profile release" + } + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/test-expo", + "command": "node ./scripts/build.mjs --platform {args.platform} --profile {args.profile} --target {args.target}" + }, + "defaultConfiguration": "ios-debug", + "configurations": { + "ios-debug": { + "args": "--platform ios --profile debug --target simulator" + }, + "ios-release": { + "args": "--platform ios --profile release --target simulator" + }, + "ios-debug-device": { + "args": "--platform ios --profile debug --target device" + }, + "ios-release-device": { + "args": "--platform ios --profile release --target device" + }, + "android-debug": { + "args": "--platform android --profile debug --target simulator" + }, + "android-release": { + "args": "--platform android --profile release --target simulator" + } + } + } + } +} diff --git a/apps/test-expo/scripts/build.mjs b/apps/test-expo/scripts/build.mjs new file mode 100644 index 0000000000..f01219aeae --- /dev/null +++ b/apps/test-expo/scripts/build.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + target: { type: 'string', default: 'simulator' }, + }, +}); + +const { platform, profile, target } = values; + +if (!platform) { + console.error( + 'Usage: node build.mjs --platform [--profile ] [--target ]', + ); + process.exit(1); +} + +if (target !== 'simulator' && target !== 'device') { + console.error('Error: --target must be "simulator" or "device"'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target }); +const builder = createBuilder(buildInfo); + +await builder.build(); + +console.log(`\nBuild artifacts are in: ${buildInfo.outputPath}/`); +process.exit(0); diff --git a/apps/test-expo/scripts/launch.mjs b/apps/test-expo/scripts/launch.mjs new file mode 100644 index 0000000000..717a872168 --- /dev/null +++ b/apps/test-expo/scripts/launch.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + }, +}); + +const { platform, profile } = values; + +if (!platform) { + console.error('Usage: node launch.mjs --platform [--profile ]'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +// Check that build artifact exists +if (!(await builder.hasBuildArtifact())) { + const config = `${platform}-${profile}`; + console.error(`Error: Build artifact not found.`); + console.error(`Run: yarn nx run test-expo:build --configuration=${config}`); + process.exit(1); +} + +// Install and launch +await builder.install(); +await builder.launch(); + +console.log('\nApp launched! Run "yarn nx run test-expo:start" to connect Metro.'); diff --git a/apps/test-expo/scripts/run.mjs b/apps/test-expo/scripts/run.mjs new file mode 100644 index 0000000000..290e11c9b3 --- /dev/null +++ b/apps/test-expo/scripts/run.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Smart run script that uses pre-built artifacts if available, + * otherwise falls back to building from source. + */ +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + }, +}); + +const { platform, profile } = values; + +if (!platform) { + console.error('Usage: node run.mjs --platform [--profile ]'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +// Build if needed, launch, and start Metro +await builder.buildIfNeeded(); + +console.log(`Launching ${platform}...`); +await builder.ensureSimulatorRunning(); +await builder.install(); +await builder.launch(); +await builder.startMetro(); diff --git a/apps/test-expo/scripts/utils/AndroidBuilder.mjs b/apps/test-expo/scripts/utils/AndroidBuilder.mjs new file mode 100644 index 0000000000..cdfa101e82 --- /dev/null +++ b/apps/test-expo/scripts/utils/AndroidBuilder.mjs @@ -0,0 +1,125 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { PlatformBuilder } from './PlatformBuilder.mjs'; +import { run, runCapture } from './shell.mjs'; + +export class AndroidBuilder extends PlatformBuilder { + get android() { + return this.buildInfo.android; + } + + // ───────────────────────────────────────────────────────────────── + // Build artifact management + // ───────────────────────────────────────────────────────────────── + + async hasBuildArtifact() { + try { + await fs.access(this.android.apk); + return true; + } catch { + return false; + } + } + + async compile() { + const { outputPath } = this.buildInfo; + const isDebug = this.buildInfo.profile === 'debug'; + const buildType = isDebug ? 'Debug' : 'Release'; + const buildTypeLC = buildType.toLowerCase(); + + console.log(`Building Android app (${buildType})...`); + + await fs.mkdir(outputPath, { recursive: true }); + + const gradleTask = isDebug ? 'assembleDebug' : 'assembleRelease'; + await run('./gradlew', [`:app:${gradleTask}`, '--no-daemon'], { + cwd: this.android.projectPath, + }); + + // Copy the built APK to output directory + const builtApkDir = path.join( + this.android.projectPath, + 'app', + 'build', + 'outputs', + 'apk', + buildTypeLC, + ); + const builtApkPath = path.join(builtApkDir, `app-${buildTypeLC}.apk`); + + try { + await fs.access(builtApkPath); + await fs.copyFile(builtApkPath, this.android.apk); + console.log(`Android APK created: ${this.android.apk}`); + } catch { + throw new Error(`APK not found at ${builtApkPath}`); + } + } + + // ───────────────────────────────────────────────────────────────── + // Emulator management + // ───────────────────────────────────────────────────────────────── + + async isSimulatorRunning() { + const output = await runCapture('adb', ['devices']); + const lines = output.split('\n').slice(1); // Skip header + return lines.some((line) => line.trim() && line.includes('\tdevice')); + } + + async bootSimulator() { + console.log('No Android emulator running, starting one...'); + + const avdList = await runCapture('emulator', ['-list-avds']); + const avds = avdList.trim().split('\n').filter(Boolean); + + if (avds.length === 0) { + throw new Error('No Android Virtual Devices found. Create one in Android Studio first.'); + } + + const avd = avds[0]; + console.log(`Starting emulator: ${avd}`); + + // Start emulator in background (detached) + spawn('emulator', ['-avd', avd], { + detached: true, + stdio: 'ignore', + }).unref(); + + console.log('Waiting for emulator to boot...'); + await run('adb', ['wait-for-device']); + } + + async waitForSimulator() { + const maxAttempts = 60; + for (let i = 0; i < maxAttempts; i++) { + try { + const result = await runCapture('adb', ['shell', 'getprop', 'sys.boot_completed']); + if (result.trim() === '1') return; + } catch { + // Device not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error('Emulator failed to boot within timeout'); + } + + // ───────────────────────────────────────────────────────────────── + // App installation and launch + // ───────────────────────────────────────────────────────────────── + + async extractArtifact() { + // Android APKs don't need extraction + } + + async install() { + console.log('Installing on Android Emulator...'); + await run('adb', ['install', '-r', this.android.apk]); + } + + async launch() { + console.log(`Launching ${this.android.packageId}...`); + await run('adb', ['shell', 'am', 'start', '-n', `${this.android.packageId}/.MainActivity`]); + } +} diff --git a/apps/test-expo/scripts/utils/IOSBuilder.mjs b/apps/test-expo/scripts/utils/IOSBuilder.mjs new file mode 100644 index 0000000000..9add638f25 --- /dev/null +++ b/apps/test-expo/scripts/utils/IOSBuilder.mjs @@ -0,0 +1,188 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { PlatformBuilder } from './PlatformBuilder.mjs'; +import { run, runCapture } from './shell.mjs'; + +export class IOSBuilder extends PlatformBuilder { + get ios() { + return this.buildInfo.ios; + } + + // ───────────────────────────────────────────────────────────────── + // Build artifact management + // ───────────────────────────────────────────────────────────────── + + async hasBuildArtifact() { + try { + await fs.access(this.ios.appTarball); + return true; + } catch { + return false; + } + } + + async compile() { + const { outputPath } = this.buildInfo; + const configuration = this.buildInfo.profile === 'debug' ? 'Debug' : 'Release'; + + if (this.ios.isDevice) { + await this.#compileForDevice(configuration); + } else { + await this.#compileForSimulator(configuration, outputPath); + } + } + + async #compileForSimulator(configuration, outputPath) { + const buildDir = path.resolve('build'); + + console.log(`Building iOS app (${configuration}) for simulator...`); + await run('xcodebuild', [ + '-workspace', + this.ios.workspace, + '-scheme', + this.ios.scheme, + '-configuration', + configuration, + '-destination', + this.ios.destination, + '-derivedDataPath', + buildDir, + 'build', + ]); + + // Find the built .app and create tarball + const configFolder = `${configuration}-iphonesimulator`; + const appPath = path.join( + buildDir, + 'Build', + 'Products', + configFolder, + `${this.ios.scheme}.app`, + ); + const appDir = path.dirname(appPath); + const appName = path.basename(appPath); + + console.log(`Creating tarball: ${this.ios.appTarball}`); + await run('tar', ['-czf', path.resolve(this.ios.appTarball), '-C', appDir, appName]); + + // Clean up + await fs.rm(buildDir, { recursive: true, force: true }); + console.log(`iOS simulator build created: ${this.ios.appTarball}`); + } + + async #compileForDevice(configuration) { + const { outputPath } = this.buildInfo; + + console.log(`Archiving iOS app (${configuration}) for device...`); + await run('xcodebuild', [ + '-workspace', + this.ios.workspace, + '-scheme', + this.ios.scheme, + '-configuration', + configuration, + '-destination', + this.ios.destination, + '-archivePath', + this.ios.archivePath, + 'archive', + 'CODE_SIGN_IDENTITY=-', + 'AD_HOC_CODE_SIGNING_ALLOWED=YES', + ]); + + console.log('Exporting IPA...'); + await run('xcodebuild', [ + '-exportArchive', + '-archivePath', + this.ios.archivePath, + '-exportPath', + outputPath, + '-exportOptionsPlist', + this.ios.exportOptionsPlist, + '-allowProvisioningUpdates', + ]); + + // Rename if needed + const exportedIpa = path.join(outputPath, `${this.ios.scheme}.ipa`); + try { + await fs.access(exportedIpa); + await fs.rename(exportedIpa, this.ios.ipa); + } catch { + // Already named correctly + } + + // Clean up archive + await fs.rm(this.ios.archivePath, { recursive: true, force: true }); + console.log(`iOS device build created: ${this.ios.ipa}`); + } + + // ───────────────────────────────────────────────────────────────── + // Simulator management + // ───────────────────────────────────────────────────────────────── + + async isSimulatorRunning() { + const output = await runCapture('xcrun', ['simctl', 'list', 'devices', 'booted', '-j']); + const json = JSON.parse(output); + const bootedDevices = Object.values(json.devices).flat(); + return bootedDevices.length > 0; + } + + async bootSimulator() { + console.log('No iOS Simulator running, booting one...'); + + // Find an available iPhone + const output = await runCapture('xcrun', ['simctl', 'list', 'devices', 'available', '-j']); + const json = JSON.parse(output); + + let deviceUDID = null; + let deviceName = null; + + for (const [runtime, devices] of Object.entries(json.devices)) { + if (runtime.includes('iOS')) { + const iphone = devices.find((d) => d.name.includes('iPhone') && d.isAvailable); + if (iphone) { + deviceUDID = iphone.udid; + deviceName = iphone.name; + break; + } + } + } + + if (!deviceUDID) { + throw new Error('No available iPhone simulator found.'); + } + + console.log(`Booting ${deviceName}...`); + await run('xcrun', ['simctl', 'boot', deviceUDID]); + await run('open', ['-a', 'Simulator']); + } + + async waitForSimulator() { + await run('xcrun', ['simctl', 'bootstatus', 'booted', '-b']); + } + + // ───────────────────────────────────────────────────────────────── + // App installation and launch + // ───────────────────────────────────────────────────────────────── + + async extractArtifact() { + try { + await fs.access(this.ios.app); + } catch { + console.log(`Extracting ${this.ios.appTarball}...`); + await run('tar', ['-xzf', this.ios.appTarball, '-C', this.buildInfo.outputPath]); + } + } + + async install() { + await this.extractArtifact(); + console.log('Installing on iOS Simulator...'); + await run('xcrun', ['simctl', 'install', 'booted', this.ios.app]); + } + + async launch() { + console.log(`Launching ${this.ios.bundleId}...`); + await run('xcrun', ['simctl', 'launch', 'booted', this.ios.bundleId]); + } +} diff --git a/apps/test-expo/scripts/utils/PlatformBuilder.mjs b/apps/test-expo/scripts/utils/PlatformBuilder.mjs new file mode 100644 index 0000000000..1df887fcea --- /dev/null +++ b/apps/test-expo/scripts/utils/PlatformBuilder.mjs @@ -0,0 +1,100 @@ +import fs from 'node:fs/promises'; + +import { run } from './shell.mjs'; + +/** + * Abstract base class for platform-specific build operations. + * iOS and Android implement the abstract methods differently. + */ +export class PlatformBuilder { + constructor(buildInfo) { + this.buildInfo = buildInfo; + } + + // ───────────────────────────────────────────────────────────────── + // Abstract methods - must be implemented by subclasses + // ───────────────────────────────────────────────────────────────── + + /** Check if the build artifact exists */ + async hasBuildArtifact() { + throw new Error('Not implemented'); + } + + /** Compile the native app (xcodebuild / gradle) */ + async compile() { + throw new Error('Not implemented'); + } + + /** Check if a simulator/emulator is currently running */ + async isSimulatorRunning() { + throw new Error('Not implemented'); + } + + /** Boot a simulator/emulator */ + async bootSimulator() { + throw new Error('Not implemented'); + } + + /** Wait for the simulator/emulator to be fully ready */ + async waitForSimulator() { + throw new Error('Not implemented'); + } + + /** Extract build artifact if needed (e.g., untar .tar.gz) */ + async extractArtifact() { + throw new Error('Not implemented'); + } + + /** Install the app on the simulator/emulator */ + async install() { + throw new Error('Not implemented'); + } + + /** Launch the app */ + async launch() { + throw new Error('Not implemented'); + } + + // ───────────────────────────────────────────────────────────────── + // Shared methods - common to both platforms + // ───────────────────────────────────────────────────────────────── + + /** Run expo prebuild to generate native project files */ + async prebuild() { + const { platform } = this.buildInfo; + console.log(`Running prebuild for ${platform}...`); + await run('npx', ['expo', 'prebuild', '--platform', platform, '--clean']); + } + + /** Full build: prebuild + compile */ + async build() { + const { platform, profile, outputPath } = this.buildInfo; + console.log(`Building ${platform} (${profile})...`); + + await fs.mkdir(outputPath, { recursive: true }); + await this.prebuild(); + await this.compile(); + } + + /** Build only if artifact doesn't exist */ + async buildIfNeeded() { + if (!(await this.hasBuildArtifact())) { + console.log('No build artifact found, building...'); + await this.build(); + } + } + + /** Ensure simulator is running, boot if needed */ + async ensureSimulatorRunning() { + if (!(await this.isSimulatorRunning())) { + await this.bootSimulator(); + } + await this.waitForSimulator(); + } + + /** Start Metro bundler */ + async startMetro() { + console.log('\nStarting Metro bundler...'); + await run('npx', ['expo', 'start'], { interactive: true }); + } +} diff --git a/apps/test-expo/scripts/utils/createBuilder.mjs b/apps/test-expo/scripts/utils/createBuilder.mjs new file mode 100644 index 0000000000..e090a8f9f9 --- /dev/null +++ b/apps/test-expo/scripts/utils/createBuilder.mjs @@ -0,0 +1,12 @@ +import { AndroidBuilder } from './AndroidBuilder.mjs'; +import { IOSBuilder } from './IOSBuilder.mjs'; + +/** + * Factory function to create the appropriate platform builder. + */ +export function createBuilder(buildInfo) { + if (buildInfo.platform === 'ios') { + return new IOSBuilder(buildInfo); + } + return new AndroidBuilder(buildInfo); +} diff --git a/apps/test-expo/scripts/utils/exportOptions.plist b/apps/test-expo/scripts/utils/exportOptions.plist new file mode 100644 index 0000000000..d5b9e3ee1d --- /dev/null +++ b/apps/test-expo/scripts/utils/exportOptions.plist @@ -0,0 +1,14 @@ + + + + + method + development + compileBitcode + + thinning + <none> + signingStyle + automatic + + diff --git a/apps/test-expo/scripts/utils/getBuildInfo.mjs b/apps/test-expo/scripts/utils/getBuildInfo.mjs new file mode 100644 index 0000000000..fb5cea3900 --- /dev/null +++ b/apps/test-expo/scripts/utils/getBuildInfo.mjs @@ -0,0 +1,45 @@ +import path from 'node:path'; + +const OUTPUT_DIRECTORY = 'builds'; +const APP_NAME = 'testexpo'; +const IOS_SCHEME = 'testexpo'; +const IOS_BUNDLE_ID = 'com.anonymous.test-expo'; +const ANDROID_PACKAGE_ID = 'com.anonymous.testexpo'; + +export function getBuildInfo({ platform, profile, target = 'simulator' }) { + const isDevice = target === 'device'; + // Default builds are for simulator/emulator, device builds get -device suffix + const buildId = isDevice ? `${platform}-${profile}-device` : `${platform}-${profile}`; + const outputPath = `${OUTPUT_DIRECTORY}/${buildId}`; + + const ios = { + scheme: IOS_SCHEME, + bundleId: IOS_BUNDLE_ID, + workspace: path.resolve('ios', 'testexpo.xcworkspace'), + isDevice, + destination: isDevice ? 'generic/platform=iOS' : 'generic/platform=iOS Simulator', + archivePath: `${outputPath}/${APP_NAME}.xcarchive`, + app: `${outputPath}/${APP_NAME}.app`, + appTarball: `${outputPath}/${APP_NAME}.tar.gz`, + ipa: `${outputPath}/${APP_NAME}.ipa`, + exportOptionsPlist: path.resolve('scripts/utils/exportOptions.plist'), + }; + + const android = { + packageId: ANDROID_PACKAGE_ID, + projectPath: path.resolve('android'), + apk: `${outputPath}/${APP_NAME}.apk`, + testApk: `${outputPath}/${APP_NAME}-androidTest.apk`, + }; + + return { + platform, + profile, + target, + buildId, + outputDirectory: OUTPUT_DIRECTORY, + outputPath, + ios, + android, + }; +} diff --git a/apps/test-expo/scripts/utils/shell.mjs b/apps/test-expo/scripts/utils/shell.mjs new file mode 100644 index 0000000000..ef577fce6f --- /dev/null +++ b/apps/test-expo/scripts/utils/shell.mjs @@ -0,0 +1,44 @@ +import { spawn } from 'node:child_process'; + +/** + * Runs a command with inherited stdio (output goes to terminal). + */ +export function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + if (!options.silent) { + console.log(`> ${command} ${args.join(' ')}`); + } + const child = spawn(command, args, { + stdio: 'inherit', + shell: false, + ...options, + }); + child.on('close', (code) => { + if (code === 0 || options.ignoreError) resolve(); + else reject(new Error(`Command failed with code ${code}`)); + }); + child.on('error', (err) => { + if (options.ignoreError) resolve(); + else reject(err); + }); + }); +} + +/** + * Runs a command and captures its stdout (instead of inheriting stdio). + * Used when we need to parse the output of a command. + */ +export function runCapture(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + let stdout = ''; + child.stdout.on('data', (data) => { + stdout += data; + }); + child.on('close', (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`Command failed with code ${code}`)); + }); + child.on('error', reject); + }); +} diff --git a/apps/test-expo/src/__generated__/iconSvgMap.ts b/apps/test-expo/src/__generated__/iconSvgMap.ts new file mode 100644 index 0000000000..14cedc3187 --- /dev/null +++ b/apps/test-expo/src/__generated__/iconSvgMap.ts @@ -0,0 +1,3214 @@ +/** + * DO NOT MODIFY + * This file is generated by ui-mobile-playground/scripts/generateIconSvgMap.ts + * + * Why this exists: + * - Provides a static map of icon names to their SVG content for rendering Icons directly with react-native-svg components + * + * What this provides: + * - A static map of iconName-12|16|24|32-active|inactive → { content: "svg-string" } + * + * Usage: + * - Access SVG string content via: svgMap['icon-name-12-active'].content + */ + +export const svgMap: Record = { + 'account-12-active': { content: "" }, + 'account-12-inactive': { content: "" }, + 'account-16-active': { content: "" }, + 'account-16-inactive': { content: "" }, + 'account-24-active': { content: "" }, + 'account-24-inactive': { content: "" }, + 'activity-12-active': { content: "" }, + 'activity-12-inactive': { content: "" }, + 'activity-16-active': { content: "" }, + 'activity-16-inactive': { content: "" }, + 'activity-24-active': { content: "" }, + 'activity-24-inactive': { content: "" }, + 'add-12-active': { content: "" }, + 'add-12-inactive': { content: "" }, + 'add-16-active': { content: "" }, + 'add-16-inactive': { content: "" }, + 'add-24-active': { content: "" }, + 'add-24-inactive': { content: "" }, + 'addPeople-12-active': { content: "" }, + 'addPeople-12-inactive': { content: "" }, + 'addPeople-16-active': { content: "" }, + 'addPeople-16-inactive': { content: "" }, + 'addPeople-24-active': { content: "" }, + 'addPeople-24-inactive': { content: "" }, + 'advancedMarketSelector-12-active': { content: "" }, + 'advancedMarketSelector-12-inactive': { content: "" }, + 'advancedMarketSelector-16-active': { content: "" }, + 'advancedMarketSelector-16-inactive': { content: "" }, + 'advancedMarketSelector-24-active': { content: "" }, + 'advancedMarketSelector-24-inactive': { content: "" }, + 'advancedTradeProduct-12-active': { content: "" }, + 'advancedTradeProduct-12-inactive': { content: "" }, + 'advancedTradeProduct-16-active': { content: "" }, + 'advancedTradeProduct-16-inactive': { content: "" }, + 'advancedTradeProduct-24-active': { content: "" }, + 'advancedTradeProduct-24-inactive': { content: "" }, + 'affiliates-12-active': { content: "" }, + 'affiliates-12-inactive': { content: "" }, + 'affiliates-16-active': { content: "" }, + 'affiliates-16-inactive': { content: "" }, + 'affiliates-24-active': { content: "" }, + 'affiliates-24-inactive': { content: "" }, + 'airdrop-12-active': { content: "" }, + 'airdrop-12-inactive': { content: "" }, + 'airdrop-16-active': { content: "" }, + 'airdrop-16-inactive': { content: "" }, + 'airdrop-24-active': { content: "" }, + 'airdrop-24-inactive': { content: "" }, + 'airdropAlt-12-active': { content: "" }, + 'airdropAlt-12-inactive': { content: "" }, + 'airdropAlt-16-active': { content: "" }, + 'airdropAlt-16-inactive': { content: "" }, + 'airdropAlt-24-active': { content: "" }, + 'airdropAlt-24-inactive': { content: "" }, + 'airdropCoins-12-active': { content: "" }, + 'airdropCoins-12-inactive': { content: "" }, + 'airdropCoins-16-active': { content: "" }, + 'airdropCoins-16-inactive': { content: "" }, + 'airdropCoins-24-active': { content: "" }, + 'airdropCoins-24-inactive': { content: "" }, + 'airdropParachute-12-active': { content: "" }, + 'airdropParachute-12-inactive': { content: "" }, + 'airdropParachute-16-active': { content: "" }, + 'airdropParachute-16-inactive': { content: "" }, + 'airdropParachute-24-active': { content: "" }, + 'airdropParachute-24-inactive': { content: "" }, + 'alien-12-active': { content: "" }, + 'alien-12-inactive': { content: "" }, + 'alien-16-active': { content: "" }, + 'alien-16-inactive': { content: "" }, + 'alien-24-active': { content: "" }, + 'alien-24-inactive': { content: "" }, + 'allocation-12-active': { content: "" }, + 'allocation-12-inactive': { content: "" }, + 'allocation-16-active': { content: "" }, + 'allocation-16-inactive': { content: "" }, + 'allocation-24-active': { content: "" }, + 'allocation-24-inactive': { content: "" }, + 'allTimeHigh-12-active': { content: "" }, + 'allTimeHigh-12-inactive': { content: "" }, + 'allTimeHigh-16-active': { content: "" }, + 'allTimeHigh-16-inactive': { content: "" }, + 'allTimeHigh-24-active': { content: "" }, + 'allTimeHigh-24-inactive': { content: "" }, + 'annotation-12-active': { content: "" }, + 'annotation-12-inactive': { content: "" }, + 'annotation-16-active': { content: "" }, + 'annotation-16-inactive': { content: "" }, + 'annotation-24-active': { content: "" }, + 'annotation-24-inactive': { content: "" }, + 'api-12-active': { content: "" }, + 'api-12-inactive': { content: "" }, + 'api-16-active': { content: "" }, + 'api-16-inactive': { content: "" }, + 'api-24-active': { content: "" }, + 'api-24-inactive': { content: "" }, + 'apiPlug-12-active': { content: "" }, + 'apiPlug-12-inactive': { content: "" }, + 'apiPlug-16-active': { content: "" }, + 'apiPlug-16-inactive': { content: "" }, + 'apiPlug-24-active': { content: "" }, + 'apiPlug-24-inactive': { content: "" }, + 'apothecary-12-active': { content: "" }, + 'apothecary-12-inactive': { content: "" }, + 'apothecary-16-active': { content: "" }, + 'apothecary-16-inactive': { content: "" }, + 'apothecary-24-active': { content: "" }, + 'apothecary-24-inactive': { content: "" }, + 'apple-12-active': { content: "" }, + 'apple-12-inactive': { content: "" }, + 'apple-16-active': { content: "" }, + 'apple-16-inactive': { content: "" }, + 'apple-24-active': { content: "" }, + 'apple-24-inactive': { content: "" }, + 'appleLogo-12-active': { content: "" }, + 'appleLogo-12-inactive': { content: "" }, + 'appleLogo-16-active': { content: "" }, + 'appleLogo-16-inactive': { content: "" }, + 'appleLogo-24-active': { content: "" }, + 'appleLogo-24-inactive': { content: "" }, + 'application-12-active': { content: "" }, + 'application-12-inactive': { content: "" }, + 'application-16-active': { content: "" }, + 'application-16-inactive': { content: "" }, + 'application-24-active': { content: "" }, + 'application-24-inactive': { content: "" }, + 'appSwitcher-12-active': { content: "" }, + 'appSwitcher-12-inactive': { content: "" }, + 'appSwitcher-16-active': { content: "" }, + 'appSwitcher-16-inactive': { content: "" }, + 'appSwitcher-24-active': { content: "" }, + 'appSwitcher-24-inactive': { content: "" }, + 'arrowDown-12-active': { content: "" }, + 'arrowDown-12-inactive': { content: "" }, + 'arrowDown-16-active': { content: "" }, + 'arrowDown-16-inactive': { content: "" }, + 'arrowDown-24-active': { content: "" }, + 'arrowDown-24-inactive': { content: "" }, + 'arrowLeft-12-active': { content: "" }, + 'arrowLeft-12-inactive': { content: "" }, + 'arrowLeft-16-active': { content: "" }, + 'arrowLeft-16-inactive': { content: "" }, + 'arrowLeft-24-active': { content: "" }, + 'arrowLeft-24-inactive': { content: "" }, + 'arrowRight-12-active': { content: "" }, + 'arrowRight-12-inactive': { content: "" }, + 'arrowRight-16-active': { content: "" }, + 'arrowRight-16-inactive': { content: "" }, + 'arrowRight-24-active': { content: "" }, + 'arrowRight-24-inactive': { content: "" }, + 'arrowsHorizontal-12-active': { content: "" }, + 'arrowsHorizontal-12-inactive': { content: "" }, + 'arrowsHorizontal-16-active': { content: "" }, + 'arrowsHorizontal-16-inactive': { content: "" }, + 'arrowsHorizontal-24-active': { content: "" }, + 'arrowsHorizontal-24-inactive': { content: "" }, + 'arrowsUpDown-12-active': { content: "" }, + 'arrowsUpDown-12-inactive': { content: "" }, + 'arrowsUpDown-16-active': { content: "" }, + 'arrowsUpDown-16-inactive': { content: "" }, + 'arrowsUpDown-24-active': { content: "" }, + 'arrowsUpDown-24-inactive': { content: "" }, + 'arrowsVertical-12-active': { content: "" }, + 'arrowsVertical-12-inactive': { content: "" }, + 'arrowsVertical-16-active': { content: "" }, + 'arrowsVertical-16-inactive': { content: "" }, + 'arrowsVertical-24-active': { content: "" }, + 'arrowsVertical-24-inactive': { content: "" }, + 'arrowUp-12-active': { content: "" }, + 'arrowUp-12-inactive': { content: "" }, + 'arrowUp-16-active': { content: "" }, + 'arrowUp-16-inactive': { content: "" }, + 'arrowUp-24-active': { content: "" }, + 'arrowUp-24-inactive': { content: "" }, + 'artwork-12-active': { content: "" }, + 'artwork-12-inactive': { content: "" }, + 'artwork-16-active': { content: "" }, + 'artwork-16-inactive': { content: "" }, + 'artwork-24-active': { content: "" }, + 'artwork-24-inactive': { content: "" }, + 'assetHubProduct-12-active': { content: "" }, + 'assetHubProduct-12-inactive': { content: "" }, + 'assetHubProduct-16-active': { content: "" }, + 'assetHubProduct-16-inactive': { content: "" }, + 'assetHubProduct-24-active': { content: "" }, + 'assetHubProduct-24-inactive': { content: "" }, + 'assetManagementProduct-12-active': { content: "" }, + 'assetManagementProduct-12-inactive': { content: "" }, + 'assetManagementProduct-16-active': { content: "" }, + 'assetManagementProduct-16-inactive': { content: "" }, + 'assetManagementProduct-24-active': { content: "" }, + 'assetManagementProduct-24-inactive': { content: "" }, + 'astronautHelmet-12-active': { content: "" }, + 'astronautHelmet-12-inactive': { content: "" }, + 'astronautHelmet-16-active': { content: "" }, + 'astronautHelmet-16-inactive': { content: "" }, + 'astronautHelmet-24-active': { content: "" }, + 'astronautHelmet-24-inactive': { content: "" }, + 'atomScience-12-active': { content: "" }, + 'atomScience-12-inactive': { content: "" }, + 'atomScience-16-active': { content: "" }, + 'atomScience-16-inactive': { content: "" }, + 'atomScience-24-active': { content: "" }, + 'atomScience-24-inactive': { content: "" }, + 'atSign-12-active': { content: "" }, + 'atSign-12-inactive': { content: "" }, + 'atSign-16-active': { content: "" }, + 'atSign-16-inactive': { content: "" }, + 'atSign-24-active': { content: "" }, + 'atSign-24-inactive': { content: "" }, + 'auto-12-active': { content: "" }, + 'auto-12-inactive': { content: "" }, + 'auto-16-active': { content: "" }, + 'auto-16-inactive': { content: "" }, + 'auto-24-active': { content: "" }, + 'auto-24-inactive': { content: "" }, + 'autoCar-12-active': { content: "" }, + 'autoCar-12-inactive': { content: "" }, + 'autoCar-16-active': { content: "" }, + 'autoCar-16-inactive': { content: "" }, + 'autoCar-24-active': { content: "" }, + 'autoCar-24-inactive': { content: "" }, + 'avatar-12-active': { content: "" }, + 'avatar-12-inactive': { content: "" }, + 'avatar-16-active': { content: "" }, + 'avatar-16-inactive': { content: "" }, + 'avatar-24-active': { content: "" }, + 'avatar-24-inactive': { content: "" }, + 'average-12-active': { content: "" }, + 'average-12-inactive': { content: "" }, + 'average-16-active': { content: "" }, + 'average-16-inactive': { content: "" }, + 'average-24-active': { content: "" }, + 'average-24-inactive': { content: "" }, + 'backArrow-12-active': { content: "" }, + 'backArrow-12-inactive': { content: "" }, + 'backArrow-16-active': { content: "" }, + 'backArrow-16-inactive': { content: "" }, + 'backArrow-24-active': { content: "" }, + 'backArrow-24-inactive': { content: "" }, + 'ballot-12-active': { content: "" }, + 'ballot-12-inactive': { content: "" }, + 'ballot-16-active': { content: "" }, + 'ballot-16-inactive': { content: "" }, + 'ballot-24-active': { content: "" }, + 'ballot-24-inactive': { content: "" }, + 'ballotbox-12-active': { content: "" }, + 'ballotbox-12-inactive': { content: "" }, + 'ballotbox-16-active': { content: "" }, + 'ballotbox-16-inactive': { content: "" }, + 'ballotbox-24-active': { content: "" }, + 'ballotbox-24-inactive': { content: "" }, + 'bandage-12-active': { content: "" }, + 'bandage-12-inactive': { content: "" }, + 'bandage-16-active': { content: "" }, + 'bandage-16-inactive': { content: "" }, + 'bandage-24-active': { content: "" }, + 'bandage-24-inactive': { content: "" }, + 'bank-12-active': { content: "" }, + 'bank-12-inactive': { content: "" }, + 'bank-16-active': { content: "" }, + 'bank-16-inactive': { content: "" }, + 'bank-24-active': { content: "" }, + 'bank-24-inactive': { content: "" }, + 'barChartSimple-12-active': { content: "" }, + 'barChartSimple-12-inactive': { content: "" }, + 'barChartSimple-16-active': { content: "" }, + 'barChartSimple-16-inactive': { content: "" }, + 'barChartSimple-24-active': { content: "" }, + 'barChartSimple-24-inactive': { content: "" }, + 'barChartWindow-12-active': { content: "" }, + 'barChartWindow-12-inactive': { content: "" }, + 'barChartWindow-16-active': { content: "" }, + 'barChartWindow-16-inactive': { content: "" }, + 'barChartWindow-24-active': { content: "" }, + 'barChartWindow-24-inactive': { content: "" }, + 'base-12-active': { content: "" }, + 'base-12-inactive': { content: "" }, + 'base-16-active': { content: "" }, + 'base-16-inactive': { content: "" }, + 'base-24-active': { content: "" }, + 'base-24-inactive': { content: "" }, + 'baseApps-12-active': { content: "" }, + 'baseApps-12-inactive': { content: "" }, + 'baseApps-16-active': { content: "" }, + 'baseApps-16-inactive': { content: "" }, + 'baseApps-24-active': { content: "" }, + 'baseApps-24-inactive': { content: "" }, + 'baseball-12-active': { content: "" }, + 'baseball-12-inactive': { content: "" }, + 'baseball-16-active': { content: "" }, + 'baseball-16-inactive': { content: "" }, + 'baseball-24-active': { content: "" }, + 'baseball-24-inactive': { content: "" }, + 'baseFeed-12-active': { content: "" }, + 'baseFeed-12-inactive': { content: "" }, + 'baseFeed-16-active': { content: "" }, + 'baseFeed-16-inactive': { content: "" }, + 'baseFeed-24-active': { content: "" }, + 'baseFeed-24-inactive': { content: "" }, + 'baseNotification-12-active': { content: "" }, + 'baseNotification-12-inactive': { content: "" }, + 'baseNotification-16-active': { content: "" }, + 'baseNotification-16-inactive': { content: "" }, + 'baseNotification-24-active': { content: "" }, + 'baseNotification-24-inactive': { content: "" }, + 'baseQuickBuy-12-active': { content: "" }, + 'baseQuickBuy-12-inactive': { content: "" }, + 'baseQuickBuy-16-active': { content: "" }, + 'baseQuickBuy-16-inactive': { content: "" }, + 'baseQuickBuy-24-active': { content: "" }, + 'baseQuickBuy-24-inactive': { content: "" }, + 'baseSquare-12-active': { content: "" }, + 'baseSquare-12-inactive': { content: "" }, + 'baseSquare-16-active': { content: "" }, + 'baseSquare-16-inactive': { content: "" }, + 'baseSquare-24-active': { content: "" }, + 'baseSquare-24-inactive': { content: "" }, + 'baseTransact-12-active': { content: "" }, + 'baseTransact-12-inactive': { content: "" }, + 'baseTransact-16-active': { content: "" }, + 'baseTransact-16-inactive': { content: "" }, + 'baseTransact-24-active': { content: "" }, + 'baseTransact-24-inactive': { content: "" }, + 'baseVerification-12-active': { content: "" }, + 'baseVerification-12-inactive': { content: "" }, + 'baseVerification-16-active': { content: "" }, + 'baseVerification-16-inactive': { content: "" }, + 'baseVerification-24-active': { content: "" }, + 'baseVerification-24-inactive': { content: "" }, + 'baseWallet-12-active': { content: "" }, + 'baseWallet-12-inactive': { content: "" }, + 'baseWallet-16-active': { content: "" }, + 'baseWallet-16-inactive': { content: "" }, + 'baseWallet-24-active': { content: "" }, + 'baseWallet-24-inactive': { content: "" }, + 'basketball-12-active': { content: "" }, + 'basketball-12-inactive': { content: "" }, + 'basketball-16-active': { content: "" }, + 'basketball-16-inactive': { content: "" }, + 'basketball-24-active': { content: "" }, + 'basketball-24-inactive': { content: "" }, + 'beaker-12-active': { content: "" }, + 'beaker-12-inactive': { content: "" }, + 'beaker-16-active': { content: "" }, + 'beaker-16-inactive': { content: "" }, + 'beaker-24-active': { content: "" }, + 'beaker-24-inactive': { content: "" }, + 'beginningArrow-12-active': { content: "" }, + 'beginningArrow-12-inactive': { content: "" }, + 'beginningArrow-16-active': { content: "" }, + 'beginningArrow-16-inactive': { content: "" }, + 'beginningArrow-24-active': { content: "" }, + 'beginningArrow-24-inactive': { content: "" }, + 'bell-12-active': { content: "" }, + 'bell-12-inactive': { content: "" }, + 'bell-16-active': { content: "" }, + 'bell-16-inactive': { content: "" }, + 'bell-24-active': { content: "" }, + 'bell-24-inactive': { content: "" }, + 'bellCheck-12-active': { content: "" }, + 'bellCheck-12-inactive': { content: "" }, + 'bellCheck-16-active': { content: "" }, + 'bellCheck-16-inactive': { content: "" }, + 'bellCheck-24-active': { content: "" }, + 'bellCheck-24-inactive': { content: "" }, + 'bellPlus-12-active': { content: "" }, + 'bellPlus-12-inactive': { content: "" }, + 'bellPlus-16-active': { content: "" }, + 'bellPlus-16-inactive': { content: "" }, + 'bellPlus-24-active': { content: "" }, + 'bellPlus-24-inactive': { content: "" }, + 'birthcertificate-12-active': { content: "" }, + 'birthcertificate-12-inactive': { content: "" }, + 'birthcertificate-16-active': { content: "" }, + 'birthcertificate-16-inactive': { content: "" }, + 'birthcertificate-24-active': { content: "" }, + 'birthcertificate-24-inactive': { content: "" }, + 'block-12-active': { content: "" }, + 'block-12-inactive': { content: "" }, + 'block-16-active': { content: "" }, + 'block-16-inactive': { content: "" }, + 'block-24-active': { content: "" }, + 'block-24-inactive': { content: "" }, + 'blockchain-12-active': { content: "" }, + 'blockchain-12-inactive': { content: "" }, + 'blockchain-16-active': { content: "" }, + 'blockchain-16-inactive': { content: "" }, + 'blockchain-24-active': { content: "" }, + 'blockchain-24-inactive': { content: "" }, + 'blog-12-active': { content: "" }, + 'blog-12-inactive': { content: "" }, + 'blog-16-active': { content: "" }, + 'blog-16-inactive': { content: "" }, + 'blog-24-active': { content: "" }, + 'blog-24-inactive': { content: "" }, + 'book-12-active': { content: "" }, + 'book-12-inactive': { content: "" }, + 'book-16-active': { content: "" }, + 'book-16-inactive': { content: "" }, + 'book-24-active': { content: "" }, + 'book-24-inactive': { content: "" }, + 'bookmark-12-active': { content: "" }, + 'bookmark-12-inactive': { content: "" }, + 'bookmark-16-active': { content: "" }, + 'bookmark-16-inactive': { content: "" }, + 'bookmark-24-active': { content: "" }, + 'bookmark-24-inactive': { content: "" }, + 'borrowProduct-12-active': { content: "" }, + 'borrowProduct-12-inactive': { content: "" }, + 'borrowProduct-16-active': { content: "" }, + 'borrowProduct-16-inactive': { content: "" }, + 'borrowProduct-24-active': { content: "" }, + 'borrowProduct-24-inactive': { content: "" }, + 'boxing-12-active': { content: "" }, + 'boxing-12-inactive': { content: "" }, + 'boxing-16-active': { content: "" }, + 'boxing-16-inactive': { content: "" }, + 'boxing-24-active': { content: "" }, + 'boxing-24-inactive': { content: "" }, + 'bridging-12-active': { content: "" }, + 'bridging-12-inactive': { content: "" }, + 'bridging-16-active': { content: "" }, + 'bridging-16-inactive': { content: "" }, + 'bridging-24-active': { content: "" }, + 'bridging-24-inactive': { content: "" }, + 'briefcase-12-active': { content: "" }, + 'briefcase-12-inactive': { content: "" }, + 'briefcase-16-active': { content: "" }, + 'briefcase-16-inactive': { content: "" }, + 'briefcase-24-active': { content: "" }, + 'briefcase-24-inactive': { content: "" }, + 'briefcaseAlt-12-active': { content: "" }, + 'briefcaseAlt-12-inactive': { content: "" }, + 'briefcaseAlt-16-active': { content: "" }, + 'briefcaseAlt-16-inactive': { content: "" }, + 'briefcaseAlt-24-active': { content: "" }, + 'briefcaseAlt-24-inactive': { content: "" }, + 'browser-12-active': { content: "" }, + 'browser-12-inactive': { content: "" }, + 'browser-16-active': { content: "" }, + 'browser-16-inactive': { content: "" }, + 'browser-24-active': { content: "" }, + 'browser-24-inactive': { content: "" }, + 'bug-12-active': { content: "" }, + 'bug-12-inactive': { content: "" }, + 'bug-16-active': { content: "" }, + 'bug-16-inactive': { content: "" }, + 'bug-24-active': { content: "" }, + 'bug-24-inactive': { content: "" }, + 'building-12-active': { content: "" }, + 'building-12-inactive': { content: "" }, + 'building-16-active': { content: "" }, + 'building-16-inactive': { content: "" }, + 'building-24-active': { content: "" }, + 'building-24-inactive': { content: "" }, + 'calculator-12-active': { content: "" }, + 'calculator-12-inactive': { content: "" }, + 'calculator-16-active': { content: "" }, + 'calculator-16-inactive': { content: "" }, + 'calculator-24-active': { content: "" }, + 'calculator-24-inactive': { content: "" }, + 'calendar-12-active': { content: "" }, + 'calendar-12-inactive': { content: "" }, + 'calendar-16-active': { content: "" }, + 'calendar-16-inactive': { content: "" }, + 'calendar-24-active': { content: "" }, + 'calendar-24-inactive': { content: "" }, + 'calendarBlank-12-active': { content: "" }, + 'calendarBlank-12-inactive': { content: "" }, + 'calendarBlank-16-active': { content: "" }, + 'calendarBlank-16-inactive': { content: "" }, + 'calendarBlank-24-active': { content: "" }, + 'calendarBlank-24-inactive': { content: "" }, + 'calendarDates-12-active': { content: "" }, + 'calendarDates-12-inactive': { content: "" }, + 'calendarDates-16-active': { content: "" }, + 'calendarDates-16-inactive': { content: "" }, + 'calendarDates-24-active': { content: "" }, + 'calendarDates-24-inactive': { content: "" }, + 'calendarEmpty-12-active': { content: "" }, + 'calendarEmpty-12-inactive': { content: "" }, + 'calendarEmpty-16-active': { content: "" }, + 'calendarEmpty-16-inactive': { content: "" }, + 'calendarEmpty-24-active': { content: "" }, + 'calendarEmpty-24-inactive': { content: "" }, + 'calendarHeart-12-active': { content: "" }, + 'calendarHeart-12-inactive': { content: "" }, + 'calendarHeart-16-active': { content: "" }, + 'calendarHeart-16-inactive': { content: "" }, + 'calendarHeart-24-active': { content: "" }, + 'calendarHeart-24-inactive': { content: "" }, + 'calendarMoney-12-active': { content: "" }, + 'calendarMoney-12-inactive': { content: "" }, + 'calendarMoney-16-active': { content: "" }, + 'calendarMoney-16-inactive': { content: "" }, + 'calendarMoney-24-active': { content: "" }, + 'calendarMoney-24-inactive': { content: "" }, + 'calendarStar-12-active': { content: "" }, + 'calendarStar-12-inactive': { content: "" }, + 'calendarStar-16-active': { content: "" }, + 'calendarStar-16-inactive': { content: "" }, + 'calendarStar-24-active': { content: "" }, + 'calendarStar-24-inactive': { content: "" }, + 'camera-12-active': { content: "" }, + 'camera-12-inactive': { content: "" }, + 'camera-16-active': { content: "" }, + 'camera-16-inactive': { content: "" }, + 'camera-24-active': { content: "" }, + 'camera-24-inactive': { content: "" }, + 'candlesticks-12-active': { content: "" }, + 'candlesticks-12-inactive': { content: "" }, + 'candlesticks-16-active': { content: "" }, + 'candlesticks-16-inactive': { content: "" }, + 'candlesticks-24-active': { content: "" }, + 'candlesticks-24-inactive': { content: "" }, + 'car-12-active': { content: "" }, + 'car-12-inactive': { content: "" }, + 'car-16-active': { content: "" }, + 'car-16-inactive': { content: "" }, + 'car-24-active': { content: "" }, + 'car-24-inactive': { content: "" }, + 'card-12-active': { content: "" }, + 'card-12-inactive': { content: "" }, + 'card-16-active': { content: "" }, + 'card-16-inactive': { content: "" }, + 'card-24-active': { content: "" }, + 'card-24-inactive': { content: "" }, + 'caret-12-active': { content: "" }, + 'caret-12-inactive': { content: "" }, + 'caret-16-active': { content: "" }, + 'caret-16-inactive': { content: "" }, + 'caret-24-active': { content: "" }, + 'caret-24-inactive': { content: "" }, + 'caretDown-12-active': { content: "" }, + 'caretDown-12-inactive': { content: "" }, + 'caretDown-16-active': { content: "" }, + 'caretDown-16-inactive': { content: "" }, + 'caretDown-24-active': { content: "" }, + 'caretDown-24-inactive': { content: "" }, + 'caretLeft-12-active': { content: "" }, + 'caretLeft-12-inactive': { content: "" }, + 'caretLeft-16-active': { content: "" }, + 'caretLeft-16-inactive': { content: "" }, + 'caretLeft-24-active': { content: "" }, + 'caretLeft-24-inactive': { content: "" }, + 'caretRight-12-active': { content: "" }, + 'caretRight-12-inactive': { content: "" }, + 'caretRight-16-active': { content: "" }, + 'caretRight-16-inactive': { content: "" }, + 'caretRight-24-active': { content: "" }, + 'caretRight-24-inactive': { content: "" }, + 'caretUp-12-active': { content: "" }, + 'caretUp-12-inactive': { content: "" }, + 'caretUp-16-active': { content: "" }, + 'caretUp-16-inactive': { content: "" }, + 'caretUp-24-active': { content: "" }, + 'caretUp-24-inactive': { content: "" }, + 'cash-12-active': { content: "" }, + 'cash-12-inactive': { content: "" }, + 'cash-16-active': { content: "" }, + 'cash-16-inactive': { content: "" }, + 'cash-24-active': { content: "" }, + 'cash-24-inactive': { content: "" }, + 'cashAustralianDollar-12-active': { content: "" }, + 'cashAustralianDollar-12-inactive': { content: "" }, + 'cashAustralianDollar-16-active': { content: "" }, + 'cashAustralianDollar-16-inactive': { content: "" }, + 'cashAustralianDollar-24-active': { content: "" }, + 'cashAustralianDollar-24-inactive': { content: "" }, + 'cashBrazilianReal-12-active': { content: "" }, + 'cashBrazilianReal-12-inactive': { content: "" }, + 'cashBrazilianReal-16-active': { content: "" }, + 'cashBrazilianReal-16-inactive': { content: "" }, + 'cashBrazilianReal-24-active': { content: "" }, + 'cashBrazilianReal-24-inactive': { content: "" }, + 'cashBrazillianReal-12-active': { content: "" }, + 'cashBrazillianReal-12-inactive': { content: "" }, + 'cashBrazillianReal-16-active': { content: "" }, + 'cashBrazillianReal-16-inactive': { content: "" }, + 'cashBrazillianReal-24-active': { content: "" }, + 'cashBrazillianReal-24-inactive': { content: "" }, + 'cashCanadianDollar-12-active': { content: "" }, + 'cashCanadianDollar-12-inactive': { content: "" }, + 'cashCanadianDollar-16-active': { content: "" }, + 'cashCanadianDollar-16-inactive': { content: "" }, + 'cashCanadianDollar-24-active': { content: "" }, + 'cashCanadianDollar-24-inactive': { content: "" }, + 'cashCoins-12-active': { content: "" }, + 'cashCoins-12-inactive': { content: "" }, + 'cashCoins-16-active': { content: "" }, + 'cashCoins-16-inactive': { content: "" }, + 'cashCoins-24-active': { content: "" }, + 'cashCoins-24-inactive': { content: "" }, + 'cashEUR-12-active': { content: "" }, + 'cashEUR-12-inactive': { content: "" }, + 'cashEUR-16-active': { content: "" }, + 'cashEUR-16-inactive': { content: "" }, + 'cashEUR-24-active': { content: "" }, + 'cashEUR-24-inactive': { content: "" }, + 'cashGBP-12-active': { content: "" }, + 'cashGBP-12-inactive': { content: "" }, + 'cashGBP-16-active': { content: "" }, + 'cashGBP-16-inactive': { content: "" }, + 'cashGBP-24-active': { content: "" }, + 'cashGBP-24-inactive': { content: "" }, + 'cashIndonesianRupiah-12-active': { content: "" }, + 'cashIndonesianRupiah-12-inactive': { content: "" }, + 'cashIndonesianRupiah-16-active': { content: "" }, + 'cashIndonesianRupiah-16-inactive': { content: "" }, + 'cashIndonesianRupiah-24-active': { content: "" }, + 'cashIndonesianRupiah-24-inactive': { content: "" }, + 'cashJPY-12-active': { content: "" }, + 'cashJPY-12-inactive': { content: "" }, + 'cashJPY-16-active': { content: "" }, + 'cashJPY-16-inactive': { content: "" }, + 'cashJPY-24-active': { content: "" }, + 'cashJPY-24-inactive': { content: "" }, + 'cashPhilippinePeso-12-active': { content: "" }, + 'cashPhilippinePeso-12-inactive': { content: "" }, + 'cashPhilippinePeso-16-active': { content: "" }, + 'cashPhilippinePeso-16-inactive': { content: "" }, + 'cashPhilippinePeso-24-active': { content: "" }, + 'cashPhilippinePeso-24-inactive': { content: "" }, + 'cashPolishZloty-12-active': { content: "" }, + 'cashPolishZloty-12-inactive': { content: "" }, + 'cashPolishZloty-16-active': { content: "" }, + 'cashPolishZloty-16-inactive': { content: "" }, + 'cashPolishZloty-24-active': { content: "" }, + 'cashPolishZloty-24-inactive': { content: "" }, + 'cashRupee-12-active': { content: "" }, + 'cashRupee-12-inactive': { content: "" }, + 'cashRupee-16-active': { content: "" }, + 'cashRupee-16-inactive': { content: "" }, + 'cashRupee-24-active': { content: "" }, + 'cashRupee-24-inactive': { content: "" }, + 'cashSingaporeDollar-12-active': { content: "" }, + 'cashSingaporeDollar-12-inactive': { content: "" }, + 'cashSingaporeDollar-16-active': { content: "" }, + 'cashSingaporeDollar-16-inactive': { content: "" }, + 'cashSingaporeDollar-24-active': { content: "" }, + 'cashSingaporeDollar-24-inactive': { content: "" }, + 'cashSwissFranc-12-active': { content: "" }, + 'cashSwissFranc-12-inactive': { content: "" }, + 'cashSwissFranc-16-active': { content: "" }, + 'cashSwissFranc-16-inactive': { content: "" }, + 'cashSwissFranc-24-active': { content: "" }, + 'cashSwissFranc-24-inactive': { content: "" }, + 'cashThaiBaht-12-active': { content: "" }, + 'cashThaiBaht-12-inactive': { content: "" }, + 'cashThaiBaht-16-active': { content: "" }, + 'cashThaiBaht-16-inactive': { content: "" }, + 'cashThaiBaht-24-active': { content: "" }, + 'cashThaiBaht-24-inactive': { content: "" }, + 'cashTurkishLira-12-active': { content: "" }, + 'cashTurkishLira-12-inactive': { content: "" }, + 'cashTurkishLira-16-active': { content: "" }, + 'cashTurkishLira-16-inactive': { content: "" }, + 'cashTurkishLira-24-active': { content: "" }, + 'cashTurkishLira-24-inactive': { content: "" }, + 'cashUaeDirham-12-active': { content: "" }, + 'cashUaeDirham-12-inactive': { content: "" }, + 'cashUaeDirham-16-active': { content: "" }, + 'cashUaeDirham-16-inactive': { content: "" }, + 'cashUaeDirham-24-active': { content: "" }, + 'cashUaeDirham-24-inactive': { content: "" }, + 'cashUSD-12-active': { content: "" }, + 'cashUSD-12-inactive': { content: "" }, + 'cashUSD-16-active': { content: "" }, + 'cashUSD-16-inactive': { content: "" }, + 'cashUSD-24-active': { content: "" }, + 'cashUSD-24-inactive': { content: "" }, + 'cashVietnameseDong-12-active': { content: "" }, + 'cashVietnameseDong-12-inactive': { content: "" }, + 'cashVietnameseDong-16-active': { content: "" }, + 'cashVietnameseDong-16-inactive': { content: "" }, + 'cashVietnameseDong-24-active': { content: "" }, + 'cashVietnameseDong-24-inactive': { content: "" }, + 'chainLink-12-active': { content: "" }, + 'chainLink-12-inactive': { content: "" }, + 'chainLink-16-active': { content: "" }, + 'chainLink-16-inactive': { content: "" }, + 'chainLink-24-active': { content: "" }, + 'chainLink-24-inactive': { content: "" }, + 'chartBar-12-active': { content: "" }, + 'chartBar-12-inactive': { content: "" }, + 'chartBar-16-active': { content: "" }, + 'chartBar-16-inactive': { content: "" }, + 'chartBar-24-active': { content: "" }, + 'chartBar-24-inactive': { content: "" }, + 'chartCandles-12-active': { content: "" }, + 'chartCandles-12-inactive': { content: "" }, + 'chartCandles-16-active': { content: "" }, + 'chartCandles-16-inactive': { content: "" }, + 'chartCandles-24-active': { content: "" }, + 'chartCandles-24-inactive': { content: "" }, + 'chartLine-12-active': { content: "" }, + 'chartLine-12-inactive': { content: "" }, + 'chartLine-16-active': { content: "" }, + 'chartLine-16-inactive': { content: "" }, + 'chartLine-24-active': { content: "" }, + 'chartLine-24-inactive': { content: "" }, + 'chartPie-12-active': { content: "" }, + 'chartPie-12-inactive': { content: "" }, + 'chartPie-16-active': { content: "" }, + 'chartPie-16-inactive': { content: "" }, + 'chartPie-24-active': { content: "" }, + 'chartPie-24-inactive': { content: "" }, + 'chartPieCircle-12-active': { content: "" }, + 'chartPieCircle-12-inactive': { content: "" }, + 'chartPieCircle-16-active': { content: "" }, + 'chartPieCircle-16-inactive': { content: "" }, + 'chartPieCircle-24-active': { content: "" }, + 'chartPieCircle-24-inactive': { content: "" }, + 'chartVolume-12-active': { content: "" }, + 'chartVolume-12-inactive': { content: "" }, + 'chartVolume-16-active': { content: "" }, + 'chartVolume-16-inactive': { content: "" }, + 'chartVolume-24-active': { content: "" }, + 'chartVolume-24-inactive': { content: "" }, + 'chatBotAgent-12-active': { content: "" }, + 'chatBotAgent-12-inactive': { content: "" }, + 'chatBotAgent-16-active': { content: "" }, + 'chatBotAgent-16-inactive': { content: "" }, + 'chatBotAgent-24-active': { content: "" }, + 'chatBotAgent-24-inactive': { content: "" }, + 'chatBubble-12-active': { content: "" }, + 'chatBubble-12-inactive': { content: "" }, + 'chatBubble-16-active': { content: "" }, + 'chatBubble-16-inactive': { content: "" }, + 'chatBubble-24-active': { content: "" }, + 'chatBubble-24-inactive': { content: "" }, + 'chatRequests-12-active': { content: "" }, + 'chatRequests-12-inactive': { content: "" }, + 'chatRequests-16-active': { content: "" }, + 'chatRequests-16-inactive': { content: "" }, + 'chatRequests-24-active': { content: "" }, + 'chatRequests-24-inactive': { content: "" }, + 'checkboxChecked-12-active': { content: "" }, + 'checkboxChecked-12-inactive': { content: "" }, + 'checkboxChecked-16-active': { content: "" }, + 'checkboxChecked-16-inactive': { content: "" }, + 'checkboxChecked-24-active': { content: "" }, + 'checkboxChecked-24-inactive': { content: "" }, + 'checkboxEmpty-12-active': { content: "" }, + 'checkboxEmpty-12-inactive': { content: "" }, + 'checkboxEmpty-16-active': { content: "" }, + 'checkboxEmpty-16-inactive': { content: "" }, + 'checkboxEmpty-24-active': { content: "" }, + 'checkboxEmpty-24-inactive': { content: "" }, + 'checkmark-12-active': { content: "" }, + 'checkmark-12-inactive': { content: "" }, + 'checkmark-16-active': { content: "" }, + 'checkmark-16-inactive': { content: "" }, + 'checkmark-24-active': { content: "" }, + 'checkmark-24-inactive': { content: "" }, + 'chess-12-active': { content: "" }, + 'chess-12-inactive': { content: "" }, + 'chess-16-active': { content: "" }, + 'chess-16-inactive': { content: "" }, + 'chess-24-active': { content: "" }, + 'chess-24-inactive': { content: "" }, + 'circleCheckmark-12-active': { content: "" }, + 'circleCheckmark-12-inactive': { content: "" }, + 'circleCheckmark-16-active': { content: "" }, + 'circleCheckmark-16-inactive': { content: "" }, + 'circleCheckmark-24-active': { content: "" }, + 'circleCheckmark-24-inactive': { content: "" }, + 'circleCross-12-active': { content: "" }, + 'circleCross-12-inactive': { content: "" }, + 'circleCross-16-active': { content: "" }, + 'circleCross-16-inactive': { content: "" }, + 'circleCross-24-active': { content: "" }, + 'circleCross-24-inactive': { content: "" }, + 'circulatingSupply-12-active': { content: "" }, + 'circulatingSupply-12-inactive': { content: "" }, + 'circulatingSupply-16-active': { content: "" }, + 'circulatingSupply-16-inactive': { content: "" }, + 'circulatingSupply-24-active': { content: "" }, + 'circulatingSupply-24-inactive': { content: "" }, + 'city-12-active': { content: "" }, + 'city-12-inactive': { content: "" }, + 'city-16-active': { content: "" }, + 'city-16-inactive': { content: "" }, + 'city-24-active': { content: "" }, + 'city-24-inactive': { content: "" }, + 'clipboard-12-active': { content: "" }, + 'clipboard-12-inactive': { content: "" }, + 'clipboard-16-active': { content: "" }, + 'clipboard-16-inactive': { content: "" }, + 'clipboard-24-active': { content: "" }, + 'clipboard-24-inactive': { content: "" }, + 'clock-12-active': { content: "" }, + 'clock-12-inactive': { content: "" }, + 'clock-16-active': { content: "" }, + 'clock-16-inactive': { content: "" }, + 'clock-24-active': { content: "" }, + 'clock-24-inactive': { content: "" }, + 'clockOutline-12-active': { content: "" }, + 'clockOutline-12-inactive': { content: "" }, + 'clockOutline-16-active': { content: "" }, + 'clockOutline-16-inactive': { content: "" }, + 'clockOutline-24-active': { content: "" }, + 'clockOutline-24-inactive': { content: "" }, + 'close-12-active': { content: "" }, + 'close-12-inactive': { content: "" }, + 'close-16-active': { content: "" }, + 'close-16-inactive': { content: "" }, + 'close-24-active': { content: "" }, + 'close-24-inactive': { content: "" }, + 'closeCaption-12-active': { content: "" }, + 'closeCaption-12-inactive': { content: "" }, + 'closeCaption-16-active': { content: "" }, + 'closeCaption-16-inactive': { content: "" }, + 'closeCaption-24-active': { content: "" }, + 'closeCaption-24-inactive': { content: "" }, + 'clothing-12-active': { content: "" }, + 'clothing-12-inactive': { content: "" }, + 'clothing-16-active': { content: "" }, + 'clothing-16-inactive': { content: "" }, + 'clothing-24-active': { content: "" }, + 'clothing-24-inactive': { content: "" }, + 'cloud-12-active': { content: "" }, + 'cloud-12-inactive': { content: "" }, + 'cloud-16-active': { content: "" }, + 'cloud-16-inactive': { content: "" }, + 'cloud-24-active': { content: "" }, + 'cloud-24-inactive': { content: "" }, + 'cloudPartial-12-active': { content: "" }, + 'cloudPartial-12-inactive': { content: "" }, + 'cloudPartial-16-active': { content: "" }, + 'cloudPartial-16-inactive': { content: "" }, + 'cloudPartial-24-active': { content: "" }, + 'cloudPartial-24-inactive': { content: "" }, + 'cloudProduct-12-active': { content: "" }, + 'cloudProduct-12-inactive': { content: "" }, + 'cloudProduct-16-active': { content: "" }, + 'cloudProduct-16-inactive': { content: "" }, + 'cloudProduct-24-active': { content: "" }, + 'cloudProduct-24-inactive': { content: "" }, + 'cluster-12-active': { content: "" }, + 'cluster-12-inactive': { content: "" }, + 'cluster-16-active': { content: "" }, + 'cluster-16-inactive': { content: "" }, + 'cluster-24-active': { content: "" }, + 'cluster-24-inactive': { content: "" }, + 'coinbase-12-active': { content: "" }, + 'coinbase-12-inactive': { content: "" }, + 'coinbase-16-active': { content: "" }, + 'coinbase-16-inactive': { content: "" }, + 'coinbase-24-active': { content: "" }, + 'coinbase-24-inactive': { content: "" }, + 'coinbaseCardProduct-12-active': { content: "" }, + 'coinbaseCardProduct-12-inactive': { content: "" }, + 'coinbaseCardProduct-16-active': { content: "" }, + 'coinbaseCardProduct-16-inactive': { content: "" }, + 'coinbaseCardProduct-24-active': { content: "" }, + 'coinbaseCardProduct-24-inactive': { content: "" }, + 'coinbaseOne-12-active': { content: "" }, + 'coinbaseOne-12-inactive': { content: "" }, + 'coinbaseOne-16-active': { content: "" }, + 'coinbaseOne-16-inactive': { content: "" }, + 'coinbaseOne-24-active': { content: "" }, + 'coinbaseOne-24-inactive': { content: "" }, + 'coinbaseOneCard-12-active': { content: "" }, + 'coinbaseOneCard-12-inactive': { content: "" }, + 'coinbaseOneCard-16-active': { content: "" }, + 'coinbaseOneCard-16-inactive': { content: "" }, + 'coinbaseOneCard-24-active': { content: "" }, + 'coinbaseOneCard-24-inactive': { content: "" }, + 'coinbaseOneLogo-12-active': { content: "" }, + 'coinbaseOneLogo-12-inactive': { content: "" }, + 'coinbaseOneLogo-16-active': { content: "" }, + 'coinbaseOneLogo-16-inactive': { content: "" }, + 'coinbaseOneLogo-24-active': { content: "" }, + 'coinbaseOneLogo-24-inactive': { content: "" }, + 'coinbaseRewards-12-active': { content: "" }, + 'coinbaseRewards-12-inactive': { content: "" }, + 'coinbaseRewards-16-active': { content: "" }, + 'coinbaseRewards-16-inactive': { content: "" }, + 'coinbaseRewards-24-active': { content: "" }, + 'coinbaseRewards-24-inactive': { content: "" }, + 'coinsCrypto-12-active': { content: "" }, + 'coinsCrypto-12-inactive': { content: "" }, + 'coinsCrypto-16-active': { content: "" }, + 'coinsCrypto-16-inactive': { content: "" }, + 'coinsCrypto-24-active': { content: "" }, + 'coinsCrypto-24-inactive': { content: "" }, + 'collapse-12-active': { content: "" }, + 'collapse-12-inactive': { content: "" }, + 'collapse-16-active': { content: "" }, + 'collapse-16-inactive': { content: "" }, + 'collapse-24-active': { content: "" }, + 'collapse-24-inactive': { content: "" }, + 'collectibles-12-active': { content: "" }, + 'collectibles-12-inactive': { content: "" }, + 'collectibles-16-active': { content: "" }, + 'collectibles-16-inactive': { content: "" }, + 'collectibles-24-active': { content: "" }, + 'collectibles-24-inactive': { content: "" }, + 'collection-12-active': { content: "" }, + 'collection-12-inactive': { content: "" }, + 'collection-16-active': { content: "" }, + 'collection-16-inactive': { content: "" }, + 'collection-24-active': { content: "" }, + 'collection-24-inactive': { content: "" }, + 'comment-12-active': { content: "" }, + 'comment-12-inactive': { content: "" }, + 'comment-16-active': { content: "" }, + 'comment-16-inactive': { content: "" }, + 'comment-24-active': { content: "" }, + 'comment-24-inactive': { content: "" }, + 'commentPlus-12-active': { content: "" }, + 'commentPlus-12-inactive': { content: "" }, + 'commentPlus-16-active': { content: "" }, + 'commentPlus-16-inactive': { content: "" }, + 'commentPlus-24-active': { content: "" }, + 'commentPlus-24-inactive': { content: "" }, + 'commerceProduct-12-active': { content: "" }, + 'commerceProduct-12-inactive': { content: "" }, + 'commerceProduct-16-active': { content: "" }, + 'commerceProduct-16-inactive': { content: "" }, + 'commerceProduct-24-active': { content: "" }, + 'commerceProduct-24-inactive': { content: "" }, + 'compass-12-active': { content: "" }, + 'compass-12-inactive': { content: "" }, + 'compass-16-active': { content: "" }, + 'compass-16-inactive': { content: "" }, + 'compass-24-active': { content: "" }, + 'compass-24-inactive': { content: "" }, + 'complianceProduct-12-active': { content: "" }, + 'complianceProduct-12-inactive': { content: "" }, + 'complianceProduct-16-active': { content: "" }, + 'complianceProduct-16-inactive': { content: "" }, + 'complianceProduct-24-active': { content: "" }, + 'complianceProduct-24-inactive': { content: "" }, + 'compose-12-active': { content: "" }, + 'compose-12-inactive': { content: "" }, + 'compose-16-active': { content: "" }, + 'compose-16-inactive': { content: "" }, + 'compose-24-active': { content: "" }, + 'compose-24-inactive': { content: "" }, + 'computerChip-12-active': { content: "" }, + 'computerChip-12-inactive': { content: "" }, + 'computerChip-16-active': { content: "" }, + 'computerChip-16-inactive': { content: "" }, + 'computerChip-24-active': { content: "" }, + 'computerChip-24-inactive': { content: "" }, + 'concierge-12-active': { content: "" }, + 'concierge-12-inactive': { content: "" }, + 'concierge-16-active': { content: "" }, + 'concierge-16-inactive': { content: "" }, + 'concierge-24-active': { content: "" }, + 'concierge-24-inactive': { content: "" }, + 'conciergeBell-12-active': { content: "" }, + 'conciergeBell-12-inactive': { content: "" }, + 'conciergeBell-16-active': { content: "" }, + 'conciergeBell-16-inactive': { content: "" }, + 'conciergeBell-24-active': { content: "" }, + 'conciergeBell-24-inactive': { content: "" }, + 'config-12-active': { content: "" }, + 'config-12-inactive': { content: "" }, + 'config-16-active': { content: "" }, + 'config-16-inactive': { content: "" }, + 'config-24-active': { content: "" }, + 'config-24-inactive': { content: "" }, + 'continuous-12-active': { content: "" }, + 'continuous-12-inactive': { content: "" }, + 'continuous-16-active': { content: "" }, + 'continuous-16-inactive': { content: "" }, + 'continuous-24-active': { content: "" }, + 'continuous-24-inactive': { content: "" }, + 'convert-12-active': { content: "" }, + 'convert-12-inactive': { content: "" }, + 'convert-16-active': { content: "" }, + 'convert-16-inactive': { content: "" }, + 'convert-24-active': { content: "" }, + 'convert-24-inactive': { content: "" }, + 'copy-12-active': { content: "" }, + 'copy-12-inactive': { content: "" }, + 'copy-16-active': { content: "" }, + 'copy-16-inactive': { content: "" }, + 'copy-24-active': { content: "" }, + 'copy-24-inactive': { content: "" }, + 'corporation-12-active': { content: "" }, + 'corporation-12-inactive': { content: "" }, + 'corporation-16-active': { content: "" }, + 'corporation-16-inactive': { content: "" }, + 'corporation-24-active': { content: "" }, + 'corporation-24-inactive': { content: "" }, + 'creatorCoin-12-active': { content: "" }, + 'creatorCoin-12-inactive': { content: "" }, + 'creatorCoin-16-active': { content: "" }, + 'creatorCoin-16-inactive': { content: "" }, + 'creatorCoin-24-active': { content: "" }, + 'creatorCoin-24-inactive': { content: "" }, + 'cricket-12-active': { content: "" }, + 'cricket-12-inactive': { content: "" }, + 'cricket-16-active': { content: "" }, + 'cricket-16-inactive': { content: "" }, + 'cricket-24-active': { content: "" }, + 'cricket-24-inactive': { content: "" }, + 'cross-12-active': { content: "" }, + 'cross-12-inactive': { content: "" }, + 'cross-16-active': { content: "" }, + 'cross-16-inactive': { content: "" }, + 'cross-24-active': { content: "" }, + 'cross-24-inactive': { content: "" }, + 'crossTrade-12-active': { content: "" }, + 'crossTrade-12-inactive': { content: "" }, + 'crossTrade-16-active': { content: "" }, + 'crossTrade-16-inactive': { content: "" }, + 'crossTrade-24-active': { content: "" }, + 'crossTrade-24-inactive': { content: "" }, + 'crypto-12-active': { content: "" }, + 'crypto-12-inactive': { content: "" }, + 'crypto-16-active': { content: "" }, + 'crypto-16-inactive': { content: "" }, + 'crypto-24-active': { content: "" }, + 'crypto-24-inactive': { content: "" }, + 'cryptobasics-12-active': { content: "" }, + 'cryptobasics-12-inactive': { content: "" }, + 'cryptobasics-16-active': { content: "" }, + 'cryptobasics-16-inactive': { content: "" }, + 'cryptobasics-24-active': { content: "" }, + 'cryptobasics-24-inactive': { content: "" }, + 'crystalBall-12-active': { content: "" }, + 'crystalBall-12-inactive': { content: "" }, + 'crystalBall-16-active': { content: "" }, + 'crystalBall-16-inactive': { content: "" }, + 'crystalBall-24-active': { content: "" }, + 'crystalBall-24-inactive': { content: "" }, + 'crystalBallInsight-12-active': { content: "" }, + 'crystalBallInsight-12-inactive': { content: "" }, + 'crystalBallInsight-16-active': { content: "" }, + 'crystalBallInsight-16-inactive': { content: "" }, + 'crystalBallInsight-24-active': { content: "" }, + 'crystalBallInsight-24-inactive': { content: "" }, + 'currencies-12-active': { content: "" }, + 'currencies-12-inactive': { content: "" }, + 'currencies-16-active': { content: "" }, + 'currencies-16-inactive': { content: "" }, + 'currencies-24-active': { content: "" }, + 'currencies-24-inactive': { content: "" }, + 'custodyProduct-12-active': { content: "" }, + 'custodyProduct-12-inactive': { content: "" }, + 'custodyProduct-16-active': { content: "" }, + 'custodyProduct-16-inactive': { content: "" }, + 'custodyProduct-24-active': { content: "" }, + 'custodyProduct-24-inactive': { content: "" }, + 'dashboard-12-active': { content: "" }, + 'dashboard-12-inactive': { content: "" }, + 'dashboard-16-active': { content: "" }, + 'dashboard-16-inactive': { content: "" }, + 'dashboard-24-active': { content: "" }, + 'dashboard-24-inactive': { content: "" }, + 'dataMarketplaceProduct-12-active': { content: "" }, + 'dataMarketplaceProduct-12-inactive': { content: "" }, + 'dataMarketplaceProduct-16-active': { content: "" }, + 'dataMarketplaceProduct-16-inactive': { content: "" }, + 'dataMarketplaceProduct-24-active': { content: "" }, + 'dataMarketplaceProduct-24-inactive': { content: "" }, + 'dataStack-12-active': { content: "" }, + 'dataStack-12-inactive': { content: "" }, + 'dataStack-16-active': { content: "" }, + 'dataStack-16-inactive': { content: "" }, + 'dataStack-24-active': { content: "" }, + 'dataStack-24-inactive': { content: "" }, + 'defi-12-active': { content: "" }, + 'defi-12-inactive': { content: "" }, + 'defi-16-active': { content: "" }, + 'defi-16-inactive': { content: "" }, + 'defi-24-active': { content: "" }, + 'defi-24-inactive': { content: "" }, + 'delegateProduct-12-active': { content: "" }, + 'delegateProduct-12-inactive': { content: "" }, + 'delegateProduct-16-active': { content: "" }, + 'delegateProduct-16-inactive': { content: "" }, + 'delegateProduct-24-active': { content: "" }, + 'delegateProduct-24-inactive': { content: "" }, + 'deposit-12-active': { content: "" }, + 'deposit-12-inactive': { content: "" }, + 'deposit-16-active': { content: "" }, + 'deposit-16-inactive': { content: "" }, + 'deposit-24-active': { content: "" }, + 'deposit-24-inactive': { content: "" }, + 'derivatives-12-active': { content: "" }, + 'derivatives-12-inactive': { content: "" }, + 'derivatives-16-active': { content: "" }, + 'derivatives-16-inactive': { content: "" }, + 'derivatives-24-active': { content: "" }, + 'derivatives-24-inactive': { content: "" }, + 'derivativesProduct-12-active': { content: "" }, + 'derivativesProduct-12-inactive': { content: "" }, + 'derivativesProduct-16-active': { content: "" }, + 'derivativesProduct-16-inactive': { content: "" }, + 'derivativesProduct-24-active': { content: "" }, + 'derivativesProduct-24-inactive': { content: "" }, + 'derivativesProductNew-12-active': { content: "" }, + 'derivativesProductNew-12-inactive': { content: "" }, + 'derivativesProductNew-16-active': { content: "" }, + 'derivativesProductNew-16-inactive': { content: "" }, + 'derivativesProductNew-24-active': { content: "" }, + 'derivativesProductNew-24-inactive': { content: "" }, + 'developerAPIProduct-12-active': { content: "" }, + 'developerAPIProduct-12-inactive': { content: "" }, + 'developerAPIProduct-16-active': { content: "" }, + 'developerAPIProduct-16-inactive': { content: "" }, + 'developerAPIProduct-24-active': { content: "" }, + 'developerAPIProduct-24-inactive': { content: "" }, + 'developerPlatformProduct-12-active': { content: "" }, + 'developerPlatformProduct-12-inactive': { content: "" }, + 'developerPlatformProduct-16-active': { content: "" }, + 'developerPlatformProduct-16-inactive': { content: "" }, + 'developerPlatformProduct-24-active': { content: "" }, + 'developerPlatformProduct-24-inactive': { content: "" }, + 'dex-12-active': { content: "" }, + 'dex-12-inactive': { content: "" }, + 'dex-16-active': { content: "" }, + 'dex-16-inactive': { content: "" }, + 'dex-24-active': { content: "" }, + 'dex-24-inactive': { content: "" }, + 'diagonalDownArrow-12-active': { content: "" }, + 'diagonalDownArrow-12-inactive': { content: "" }, + 'diagonalDownArrow-16-active': { content: "" }, + 'diagonalDownArrow-16-inactive': { content: "" }, + 'diagonalDownArrow-24-active': { content: "" }, + 'diagonalDownArrow-24-inactive': { content: "" }, + 'diagonalRightArrow-12-active': { content: "" }, + 'diagonalRightArrow-12-inactive': { content: "" }, + 'diagonalRightArrow-16-active': { content: "" }, + 'diagonalRightArrow-16-inactive': { content: "" }, + 'diagonalRightArrow-24-active': { content: "" }, + 'diagonalRightArrow-24-inactive': { content: "" }, + 'diagonalUpArrow-12-active': { content: "" }, + 'diagonalUpArrow-12-inactive': { content: "" }, + 'diagonalUpArrow-16-active': { content: "" }, + 'diagonalUpArrow-16-inactive': { content: "" }, + 'diagonalUpArrow-24-active': { content: "" }, + 'diagonalUpArrow-24-inactive': { content: "" }, + 'diamond-12-active': { content: "" }, + 'diamond-12-inactive': { content: "" }, + 'diamond-16-active': { content: "" }, + 'diamond-16-inactive': { content: "" }, + 'diamond-24-active': { content: "" }, + 'diamond-24-inactive': { content: "" }, + 'diamondIncentives-12-active': { content: "" }, + 'diamondIncentives-12-inactive': { content: "" }, + 'diamondIncentives-16-active': { content: "" }, + 'diamondIncentives-16-inactive': { content: "" }, + 'diamondIncentives-24-active': { content: "" }, + 'diamondIncentives-24-inactive': { content: "" }, + 'dinnerPlate-12-active': { content: "" }, + 'dinnerPlate-12-inactive': { content: "" }, + 'dinnerPlate-16-active': { content: "" }, + 'dinnerPlate-16-inactive': { content: "" }, + 'dinnerPlate-24-active': { content: "" }, + 'dinnerPlate-24-inactive': { content: "" }, + 'directDeposit-12-active': { content: "" }, + 'directDeposit-12-inactive': { content: "" }, + 'directDeposit-16-active': { content: "" }, + 'directDeposit-16-inactive': { content: "" }, + 'directDeposit-24-active': { content: "" }, + 'directDeposit-24-inactive': { content: "" }, + 'directDepositIcon-12-active': { content: "" }, + 'directDepositIcon-12-inactive': { content: "" }, + 'directDepositIcon-16-active': { content: "" }, + 'directDepositIcon-16-inactive': { content: "" }, + 'directDepositIcon-24-active': { content: "" }, + 'directDepositIcon-24-inactive': { content: "" }, + 'disabledPhone-12-active': { content: "" }, + 'disabledPhone-12-inactive': { content: "" }, + 'disabledPhone-16-active': { content: "" }, + 'disabledPhone-16-inactive': { content: "" }, + 'disabledPhone-24-active': { content: "" }, + 'disabledPhone-24-inactive': { content: "" }, + 'discordLogo-12-active': { content: "" }, + 'discordLogo-12-inactive': { content: "" }, + 'discordLogo-16-active': { content: "" }, + 'discordLogo-16-inactive': { content: "" }, + 'discordLogo-24-active': { content: "" }, + 'discordLogo-24-inactive': { content: "" }, + 'distribution-12-active': { content: "" }, + 'distribution-12-inactive': { content: "" }, + 'distribution-16-active': { content: "" }, + 'distribution-16-inactive': { content: "" }, + 'distribution-24-active': { content: "" }, + 'distribution-24-inactive': { content: "" }, + 'document-12-active': { content: "" }, + 'document-12-inactive': { content: "" }, + 'document-16-active': { content: "" }, + 'document-16-inactive': { content: "" }, + 'document-24-active': { content: "" }, + 'document-24-inactive': { content: "" }, + 'documentation-12-active': { content: "" }, + 'documentation-12-inactive': { content: "" }, + 'documentation-16-active': { content: "" }, + 'documentation-16-inactive': { content: "" }, + 'documentation-24-active': { content: "" }, + 'documentation-24-inactive': { content: "" }, + 'dot-12-active': { content: "" }, + 'dot-12-inactive': { content: "" }, + 'dot-16-active': { content: "" }, + 'dot-16-inactive': { content: "" }, + 'dot-24-active': { content: "" }, + 'dot-24-inactive': { content: "" }, + 'doubleChevronRight-12-active': { content: "" }, + 'doubleChevronRight-12-inactive': { content: "" }, + 'doubleChevronRight-16-active': { content: "" }, + 'doubleChevronRight-16-inactive': { content: "" }, + 'doubleChevronRight-24-active': { content: "" }, + 'doubleChevronRight-24-inactive': { content: "" }, + 'downArrow-12-active': { content: "" }, + 'downArrow-12-inactive': { content: "" }, + 'downArrow-16-active': { content: "" }, + 'downArrow-16-inactive': { content: "" }, + 'downArrow-24-active': { content: "" }, + 'downArrow-24-inactive': { content: "" }, + 'download-12-active': { content: "" }, + 'download-12-inactive': { content: "" }, + 'download-16-active': { content: "" }, + 'download-16-inactive': { content: "" }, + 'download-24-active': { content: "" }, + 'download-24-inactive': { content: "" }, + 'drag-12-active': { content: "" }, + 'drag-12-inactive': { content: "" }, + 'drag-16-active': { content: "" }, + 'drag-16-inactive': { content: "" }, + 'drag-24-active': { content: "" }, + 'drag-24-inactive': { content: "" }, + 'drops-12-active': { content: "" }, + 'drops-12-inactive': { content: "" }, + 'drops-16-active': { content: "" }, + 'drops-16-inactive': { content: "" }, + 'drops-24-active': { content: "" }, + 'drops-24-inactive': { content: "" }, + 'earn-12-active': { content: "" }, + 'earn-12-inactive': { content: "" }, + 'earn-16-active': { content: "" }, + 'earn-16-inactive': { content: "" }, + 'earn-24-active': { content: "" }, + 'earn-24-inactive': { content: "" }, + 'earnProduct-12-active': { content: "" }, + 'earnProduct-12-inactive': { content: "" }, + 'earnProduct-16-active': { content: "" }, + 'earnProduct-16-inactive': { content: "" }, + 'earnProduct-24-active': { content: "" }, + 'earnProduct-24-inactive': { content: "" }, + 'earnRewards-12-active': { content: "" }, + 'earnRewards-12-inactive': { content: "" }, + 'earnRewards-16-active': { content: "" }, + 'earnRewards-16-inactive': { content: "" }, + 'earnRewards-24-active': { content: "" }, + 'earnRewards-24-inactive': { content: "" }, + 'earthquake-12-active': { content: "" }, + 'earthquake-12-inactive': { content: "" }, + 'earthquake-16-active': { content: "" }, + 'earthquake-16-inactive': { content: "" }, + 'earthquake-24-active': { content: "" }, + 'earthquake-24-inactive': { content: "" }, + 'educationBook-12-active': { content: "" }, + 'educationBook-12-inactive': { content: "" }, + 'educationBook-16-active': { content: "" }, + 'educationBook-16-inactive': { content: "" }, + 'educationBook-24-active': { content: "" }, + 'educationBook-24-inactive': { content: "" }, + 'educationPencil-12-active': { content: "" }, + 'educationPencil-12-inactive': { content: "" }, + 'educationPencil-16-active': { content: "" }, + 'educationPencil-16-inactive': { content: "" }, + 'educationPencil-24-active': { content: "" }, + 'educationPencil-24-inactive': { content: "" }, + 'email-12-active': { content: "" }, + 'email-12-inactive': { content: "" }, + 'email-16-active': { content: "" }, + 'email-16-inactive': { content: "" }, + 'email-24-active': { content: "" }, + 'email-24-inactive': { content: "" }, + 'endArrow-12-active': { content: "" }, + 'endArrow-12-inactive': { content: "" }, + 'endArrow-16-active': { content: "" }, + 'endArrow-16-inactive': { content: "" }, + 'endArrow-24-active': { content: "" }, + 'endArrow-24-inactive': { content: "" }, + 'entertainment-12-active': { content: "" }, + 'entertainment-12-inactive': { content: "" }, + 'entertainment-16-active': { content: "" }, + 'entertainment-16-inactive': { content: "" }, + 'entertainment-24-active': { content: "" }, + 'entertainment-24-inactive': { content: "" }, + 'error-12-active': { content: "" }, + 'error-12-inactive': { content: "" }, + 'error-16-active': { content: "" }, + 'error-16-inactive': { content: "" }, + 'error-24-active': { content: "" }, + 'error-24-inactive': { content: "" }, + 'ethereum-12-active': { content: "" }, + 'ethereum-12-inactive': { content: "" }, + 'ethereum-16-active': { content: "" }, + 'ethereum-16-inactive': { content: "" }, + 'ethereum-24-active': { content: "" }, + 'ethereum-24-inactive': { content: "" }, + 'eventContracts-12-active': { content: "" }, + 'eventContracts-12-inactive': { content: "" }, + 'eventContracts-16-active': { content: "" }, + 'eventContracts-16-inactive': { content: "" }, + 'eventContracts-24-active': { content: "" }, + 'eventContracts-24-inactive': { content: "" }, + 'exchangeProduct-12-active': { content: "" }, + 'exchangeProduct-12-inactive': { content: "" }, + 'exchangeProduct-16-active': { content: "" }, + 'exchangeProduct-16-inactive': { content: "" }, + 'exchangeProduct-24-active': { content: "" }, + 'exchangeProduct-24-inactive': { content: "" }, + 'exclamationMark-12-active': { content: "" }, + 'exclamationMark-12-inactive': { content: "" }, + 'exclamationMark-16-active': { content: "" }, + 'exclamationMark-16-inactive': { content: "" }, + 'exclamationMark-24-active': { content: "" }, + 'exclamationMark-24-inactive': { content: "" }, + 'expand-12-active': { content: "" }, + 'expand-12-inactive': { content: "" }, + 'expand-16-active': { content: "" }, + 'expand-16-inactive': { content: "" }, + 'expand-24-active': { content: "" }, + 'expand-24-inactive': { content: "" }, + 'expandAddress-12-active': { content: "" }, + 'expandAddress-12-inactive': { content: "" }, + 'expandAddress-16-active': { content: "" }, + 'expandAddress-16-inactive': { content: "" }, + 'expandAddress-24-active': { content: "" }, + 'expandAddress-24-inactive': { content: "" }, + 'expandAll-12-active': { content: "" }, + 'expandAll-12-inactive': { content: "" }, + 'expandAll-16-active': { content: "" }, + 'expandAll-16-inactive': { content: "" }, + 'expandAll-24-active': { content: "" }, + 'expandAll-24-inactive': { content: "" }, + 'externalLink-12-active': { content: "" }, + 'externalLink-12-inactive': { content: "" }, + 'externalLink-16-active': { content: "" }, + 'externalLink-16-inactive': { content: "" }, + 'externalLink-24-active': { content: "" }, + 'externalLink-24-inactive': { content: "" }, + 'eye-12-active': { content: "" }, + 'eye-12-inactive': { content: "" }, + 'eye-16-active': { content: "" }, + 'eye-16-inactive': { content: "" }, + 'eye-24-active': { content: "" }, + 'eye-24-inactive': { content: "" }, + 'faces-12-active': { content: "" }, + 'faces-12-inactive': { content: "" }, + 'faces-16-active': { content: "" }, + 'faces-16-inactive': { content: "" }, + 'faces-24-active': { content: "" }, + 'faces-24-inactive': { content: "" }, + 'faceScan-12-active': { content: "" }, + 'faceScan-12-inactive': { content: "" }, + 'faceScan-16-active': { content: "" }, + 'faceScan-16-inactive': { content: "" }, + 'faceScan-24-active': { content: "" }, + 'faceScan-24-inactive': { content: "" }, + 'factory-12-active': { content: "" }, + 'factory-12-inactive': { content: "" }, + 'factory-16-active': { content: "" }, + 'factory-16-inactive': { content: "" }, + 'factory-24-active': { content: "" }, + 'factory-24-inactive': { content: "" }, + 'faucet-12-active': { content: "" }, + 'faucet-12-inactive': { content: "" }, + 'faucet-16-active': { content: "" }, + 'faucet-16-inactive': { content: "" }, + 'faucet-24-active': { content: "" }, + 'faucet-24-inactive': { content: "" }, + 'fib-12-active': { content: "" }, + 'fib-12-inactive': { content: "" }, + 'fib-16-active': { content: "" }, + 'fib-16-inactive': { content: "" }, + 'fib-24-active': { content: "" }, + 'fib-24-inactive': { content: "" }, + 'filmStrip-12-active': { content: "" }, + 'filmStrip-12-inactive': { content: "" }, + 'filmStrip-16-active': { content: "" }, + 'filmStrip-16-inactive': { content: "" }, + 'filmStrip-24-active': { content: "" }, + 'filmStrip-24-inactive': { content: "" }, + 'filter-12-active': { content: "" }, + 'filter-12-inactive': { content: "" }, + 'filter-16-active': { content: "" }, + 'filter-16-inactive': { content: "" }, + 'filter-24-active': { content: "" }, + 'filter-24-inactive': { content: "" }, + 'fingerprint-12-active': { content: "" }, + 'fingerprint-12-inactive': { content: "" }, + 'fingerprint-16-active': { content: "" }, + 'fingerprint-16-inactive': { content: "" }, + 'fingerprint-24-active': { content: "" }, + 'fingerprint-24-inactive': { content: "" }, + 'flame-12-active': { content: "" }, + 'flame-12-inactive': { content: "" }, + 'flame-16-active': { content: "" }, + 'flame-16-inactive': { content: "" }, + 'flame-24-active': { content: "" }, + 'flame-24-inactive': { content: "" }, + 'folder-12-active': { content: "" }, + 'folder-12-inactive': { content: "" }, + 'folder-16-active': { content: "" }, + 'folder-16-inactive': { content: "" }, + 'folder-24-active': { content: "" }, + 'folder-24-inactive': { content: "" }, + 'folderArrow-12-active': { content: "" }, + 'folderArrow-12-inactive': { content: "" }, + 'folderArrow-16-active': { content: "" }, + 'folderArrow-16-inactive': { content: "" }, + 'folderArrow-24-active': { content: "" }, + 'folderArrow-24-inactive': { content: "" }, + 'folderOpen-12-active': { content: "" }, + 'folderOpen-12-inactive': { content: "" }, + 'folderOpen-16-active': { content: "" }, + 'folderOpen-16-inactive': { content: "" }, + 'folderOpen-24-active': { content: "" }, + 'folderOpen-24-inactive': { content: "" }, + 'followAdd-12-active': { content: "" }, + 'followAdd-12-inactive': { content: "" }, + 'followAdd-16-active': { content: "" }, + 'followAdd-16-inactive': { content: "" }, + 'followAdd-24-active': { content: "" }, + 'followAdd-24-inactive': { content: "" }, + 'following-12-active': { content: "" }, + 'following-12-inactive': { content: "" }, + 'following-16-active': { content: "" }, + 'following-16-inactive': { content: "" }, + 'following-24-active': { content: "" }, + 'following-24-inactive': { content: "" }, + 'football-12-active': { content: "" }, + 'football-12-inactive': { content: "" }, + 'football-16-active': { content: "" }, + 'football-16-inactive': { content: "" }, + 'football-24-active': { content: "" }, + 'football-24-inactive': { content: "" }, + 'fork-12-active': { content: "" }, + 'fork-12-inactive': { content: "" }, + 'fork-16-active': { content: "" }, + 'fork-16-inactive': { content: "" }, + 'fork-24-active': { content: "" }, + 'fork-24-inactive': { content: "" }, + 'forwardArrow-12-active': { content: "" }, + 'forwardArrow-12-inactive': { content: "" }, + 'forwardArrow-16-active': { content: "" }, + 'forwardArrow-16-inactive': { content: "" }, + 'forwardArrow-24-active': { content: "" }, + 'forwardArrow-24-inactive': { content: "" }, + 'fscsProtection-12-active': { content: "" }, + 'fscsProtection-12-inactive': { content: "" }, + 'fscsProtection-16-active': { content: "" }, + 'fscsProtection-16-inactive': { content: "" }, + 'fscsProtection-24-active': { content: "" }, + 'fscsProtection-24-inactive': { content: "" }, + 'gab-12-active': { content: "" }, + 'gab-12-inactive': { content: "" }, + 'gab-16-active': { content: "" }, + 'gab-16-inactive': { content: "" }, + 'gab-24-active': { content: "" }, + 'gab-24-inactive': { content: "" }, + 'games-12-active': { content: "" }, + 'games-12-inactive': { content: "" }, + 'games-16-active': { content: "" }, + 'games-16-inactive': { content: "" }, + 'games-24-active': { content: "" }, + 'games-24-inactive': { content: "" }, + 'gaming-12-active': { content: "" }, + 'gaming-12-inactive': { content: "" }, + 'gaming-16-active': { content: "" }, + 'gaming-16-inactive': { content: "" }, + 'gaming-24-active': { content: "" }, + 'gaming-24-inactive': { content: "" }, + 'gasFees-12-active': { content: "" }, + 'gasFees-12-inactive': { content: "" }, + 'gasFees-16-active': { content: "" }, + 'gasFees-16-inactive': { content: "" }, + 'gasFees-24-active': { content: "" }, + 'gasFees-24-inactive': { content: "" }, + 'gasFeesAlt-12-active': { content: "" }, + 'gasFeesAlt-12-inactive': { content: "" }, + 'gasFeesAlt-16-active': { content: "" }, + 'gasFeesAlt-16-inactive': { content: "" }, + 'gasFeesAlt-24-active': { content: "" }, + 'gasFeesAlt-24-inactive': { content: "" }, + 'gauge-12-active': { content: "" }, + 'gauge-12-inactive': { content: "" }, + 'gauge-16-active': { content: "" }, + 'gauge-16-inactive': { content: "" }, + 'gauge-24-active': { content: "" }, + 'gauge-24-inactive': { content: "" }, + 'gaugeEmpty-12-active': { content: "" }, + 'gaugeEmpty-12-inactive': { content: "" }, + 'gaugeEmpty-16-active': { content: "" }, + 'gaugeEmpty-16-inactive': { content: "" }, + 'gaugeEmpty-24-active': { content: "" }, + 'gaugeEmpty-24-inactive': { content: "" }, + 'gaugeHigh-12-active': { content: "" }, + 'gaugeHigh-12-inactive': { content: "" }, + 'gaugeHigh-16-active': { content: "" }, + 'gaugeHigh-16-inactive': { content: "" }, + 'gaugeHigh-24-active': { content: "" }, + 'gaugeHigh-24-inactive': { content: "" }, + 'gaugeHighLow-12-active': { content: "" }, + 'gaugeHighLow-12-inactive': { content: "" }, + 'gaugeHighLow-16-active': { content: "" }, + 'gaugeHighLow-16-inactive': { content: "" }, + 'gaugeHighLow-24-active': { content: "" }, + 'gaugeHighLow-24-inactive': { content: "" }, + 'gaugeHighMid-12-active': { content: "" }, + 'gaugeHighMid-12-inactive': { content: "" }, + 'gaugeHighMid-16-active': { content: "" }, + 'gaugeHighMid-16-inactive': { content: "" }, + 'gaugeHighMid-24-active': { content: "" }, + 'gaugeHighMid-24-inactive': { content: "" }, + 'gaugeLow-12-active': { content: "" }, + 'gaugeLow-12-inactive': { content: "" }, + 'gaugeLow-16-active': { content: "" }, + 'gaugeLow-16-inactive': { content: "" }, + 'gaugeLow-24-active': { content: "" }, + 'gaugeLow-24-inactive': { content: "" }, + 'gaugeLowHigh-12-active': { content: "" }, + 'gaugeLowHigh-12-inactive': { content: "" }, + 'gaugeLowHigh-16-active': { content: "" }, + 'gaugeLowHigh-16-inactive': { content: "" }, + 'gaugeLowHigh-24-active': { content: "" }, + 'gaugeLowHigh-24-inactive': { content: "" }, + 'gaugeLowMid-12-active': { content: "" }, + 'gaugeLowMid-12-inactive': { content: "" }, + 'gaugeLowMid-16-active': { content: "" }, + 'gaugeLowMid-16-inactive': { content: "" }, + 'gaugeLowMid-24-active': { content: "" }, + 'gaugeLowMid-24-inactive': { content: "" }, + 'gaugeMedium-12-active': { content: "" }, + 'gaugeMedium-12-inactive': { content: "" }, + 'gaugeMedium-16-active': { content: "" }, + 'gaugeMedium-16-inactive': { content: "" }, + 'gaugeMedium-24-active': { content: "" }, + 'gaugeMedium-24-inactive': { content: "" }, + 'gavel-12-active': { content: "" }, + 'gavel-12-inactive': { content: "" }, + 'gavel-16-active': { content: "" }, + 'gavel-16-inactive': { content: "" }, + 'gavel-24-active': { content: "" }, + 'gavel-24-inactive': { content: "" }, + 'gear-12-active': { content: "" }, + 'gear-12-inactive': { content: "" }, + 'gear-16-active': { content: "" }, + 'gear-16-inactive': { content: "" }, + 'gear-24-active': { content: "" }, + 'gear-24-inactive': { content: "" }, + 'generalCharacter-12-active': { content: "" }, + 'generalCharacter-12-inactive': { content: "" }, + 'generalCharacter-16-active': { content: "" }, + 'generalCharacter-16-inactive': { content: "" }, + 'generalCharacter-24-active': { content: "" }, + 'generalCharacter-24-inactive': { content: "" }, + 'ghost-12-active': { content: "" }, + 'ghost-12-inactive': { content: "" }, + 'ghost-16-active': { content: "" }, + 'ghost-16-inactive': { content: "" }, + 'ghost-24-active': { content: "" }, + 'ghost-24-inactive': { content: "" }, + 'gif-12-active': { content: "" }, + 'gif-12-inactive': { content: "" }, + 'gif-16-active': { content: "" }, + 'gif-16-inactive': { content: "" }, + 'gif-24-active': { content: "" }, + 'gif-24-inactive': { content: "" }, + 'giftBox-12-active': { content: "" }, + 'giftBox-12-inactive': { content: "" }, + 'giftBox-16-active': { content: "" }, + 'giftBox-16-inactive': { content: "" }, + 'giftBox-24-active': { content: "" }, + 'giftBox-24-inactive': { content: "" }, + 'giftCard-12-active': { content: "" }, + 'giftCard-12-inactive': { content: "" }, + 'giftCard-16-active': { content: "" }, + 'giftCard-16-inactive': { content: "" }, + 'giftCard-24-active': { content: "" }, + 'giftCard-24-inactive': { content: "" }, + 'gitHubLogo-12-active': { content: "" }, + 'gitHubLogo-12-inactive': { content: "" }, + 'gitHubLogo-16-active': { content: "" }, + 'gitHubLogo-16-inactive': { content: "" }, + 'gitHubLogo-24-active': { content: "" }, + 'gitHubLogo-24-inactive': { content: "" }, + 'globe-12-active': { content: "" }, + 'globe-12-inactive': { content: "" }, + 'globe-16-active': { content: "" }, + 'globe-16-inactive': { content: "" }, + 'globe-24-active': { content: "" }, + 'globe-24-inactive': { content: "" }, + 'golf-12-active': { content: "" }, + 'golf-12-inactive': { content: "" }, + 'golf-16-active': { content: "" }, + 'golf-16-inactive': { content: "" }, + 'golf-24-active': { content: "" }, + 'golf-24-inactive': { content: "" }, + 'googleLogo-12-active': { content: "" }, + 'googleLogo-12-inactive': { content: "" }, + 'googleLogo-16-active': { content: "" }, + 'googleLogo-16-inactive': { content: "" }, + 'googleLogo-24-active': { content: "" }, + 'googleLogo-24-inactive': { content: "" }, + 'greenEnergy-12-active': { content: "" }, + 'greenEnergy-12-inactive': { content: "" }, + 'greenEnergy-16-active': { content: "" }, + 'greenEnergy-16-inactive': { content: "" }, + 'greenEnergy-24-active': { content: "" }, + 'greenEnergy-24-inactive': { content: "" }, + 'grid-12-active': { content: "" }, + 'grid-12-inactive': { content: "" }, + 'grid-16-active': { content: "" }, + 'grid-16-inactive': { content: "" }, + 'grid-24-active': { content: "" }, + 'grid-24-inactive': { content: "" }, + 'group-12-active': { content: "" }, + 'group-12-inactive': { content: "" }, + 'group-16-active': { content: "" }, + 'group-16-inactive': { content: "" }, + 'group-24-active': { content: "" }, + 'group-24-inactive': { content: "" }, + 'hamburger-12-active': { content: "" }, + 'hamburger-12-inactive': { content: "" }, + 'hamburger-16-active': { content: "" }, + 'hamburger-16-inactive': { content: "" }, + 'hamburger-24-active': { content: "" }, + 'hamburger-24-inactive': { content: "" }, + 'hammer-12-active': { content: "" }, + 'hammer-12-inactive': { content: "" }, + 'hammer-16-active': { content: "" }, + 'hammer-16-inactive': { content: "" }, + 'hammer-24-active': { content: "" }, + 'hammer-24-inactive': { content: "" }, + 'heart-12-active': { content: "" }, + 'heart-12-inactive': { content: "" }, + 'heart-16-active': { content: "" }, + 'heart-16-inactive': { content: "" }, + 'heart-24-active': { content: "" }, + 'heart-24-inactive': { content: "" }, + 'helpCenterProduct-12-active': { content: "" }, + 'helpCenterProduct-12-inactive': { content: "" }, + 'helpCenterProduct-16-active': { content: "" }, + 'helpCenterProduct-16-inactive': { content: "" }, + 'helpCenterProduct-24-active': { content: "" }, + 'helpCenterProduct-24-inactive': { content: "" }, + 'helpCenterQuestionMark-12-active': { content: "" }, + 'helpCenterQuestionMark-12-inactive': { content: "" }, + 'helpCenterQuestionMark-16-active': { content: "" }, + 'helpCenterQuestionMark-16-inactive': { content: "" }, + 'helpCenterQuestionMark-24-active': { content: "" }, + 'helpCenterQuestionMark-24-inactive': { content: "" }, + 'hiddenEye-12-active': { content: "" }, + 'hiddenEye-12-inactive': { content: "" }, + 'hiddenEye-16-active': { content: "" }, + 'hiddenEye-16-inactive': { content: "" }, + 'hiddenEye-24-active': { content: "" }, + 'hiddenEye-24-inactive': { content: "" }, + 'hockey-12-active': { content: "" }, + 'hockey-12-inactive': { content: "" }, + 'hockey-16-active': { content: "" }, + 'hockey-16-inactive': { content: "" }, + 'hockey-24-active': { content: "" }, + 'hockey-24-inactive': { content: "" }, + 'home-12-active': { content: "" }, + 'home-12-inactive': { content: "" }, + 'home-16-active': { content: "" }, + 'home-16-inactive': { content: "" }, + 'home-24-active': { content: "" }, + 'home-24-inactive': { content: "" }, + 'horizontalLine-12-active': { content: "" }, + 'horizontalLine-12-inactive': { content: "" }, + 'horizontalLine-16-active': { content: "" }, + 'horizontalLine-16-inactive': { content: "" }, + 'horizontalLine-24-active': { content: "" }, + 'horizontalLine-24-inactive': { content: "" }, + 'hospital-12-active': { content: "" }, + 'hospital-12-inactive': { content: "" }, + 'hospital-16-active': { content: "" }, + 'hospital-16-inactive': { content: "" }, + 'hospital-24-active': { content: "" }, + 'hospital-24-inactive': { content: "" }, + 'hospitalCross-12-active': { content: "" }, + 'hospitalCross-12-inactive': { content: "" }, + 'hospitalCross-16-active': { content: "" }, + 'hospitalCross-16-inactive': { content: "" }, + 'hospitalCross-24-active': { content: "" }, + 'hospitalCross-24-inactive': { content: "" }, + 'hurricane-12-active': { content: "" }, + 'hurricane-12-inactive': { content: "" }, + 'hurricane-16-active': { content: "" }, + 'hurricane-16-inactive': { content: "" }, + 'hurricane-24-active': { content: "" }, + 'hurricane-24-inactive': { content: "" }, + 'ideal-12-active': { content: "" }, + 'ideal-12-inactive': { content: "" }, + 'ideal-16-active': { content: "" }, + 'ideal-16-inactive': { content: "" }, + 'ideal-24-active': { content: "" }, + 'ideal-24-inactive': { content: "" }, + 'identityCard-12-active': { content: "" }, + 'identityCard-12-inactive': { content: "" }, + 'identityCard-16-active': { content: "" }, + 'identityCard-16-inactive': { content: "" }, + 'identityCard-24-active': { content: "" }, + 'identityCard-24-inactive': { content: "" }, + 'image-12-active': { content: "" }, + 'image-12-inactive': { content: "" }, + 'image-16-active': { content: "" }, + 'image-16-inactive': { content: "" }, + 'image-24-active': { content: "" }, + 'image-24-inactive': { content: "" }, + 'info-12-active': { content: "" }, + 'info-12-inactive': { content: "" }, + 'info-16-active': { content: "" }, + 'info-16-inactive': { content: "" }, + 'info-24-active': { content: "" }, + 'info-24-inactive': { content: "" }, + 'initiator-12-active': { content: "" }, + 'initiator-12-inactive': { content: "" }, + 'initiator-16-active': { content: "" }, + 'initiator-16-inactive': { content: "" }, + 'initiator-24-active': { content: "" }, + 'initiator-24-inactive': { content: "" }, + 'instagramLogo-12-active': { content: "" }, + 'instagramLogo-12-inactive': { content: "" }, + 'instagramLogo-16-active': { content: "" }, + 'instagramLogo-16-inactive': { content: "" }, + 'instagramLogo-24-active': { content: "" }, + 'instagramLogo-24-inactive': { content: "" }, + 'instantUnstakingClock-12-active': { content: "" }, + 'instantUnstakingClock-12-inactive': { content: "" }, + 'instantUnstakingClock-16-active': { content: "" }, + 'instantUnstakingClock-16-inactive': { content: "" }, + 'instantUnstakingClock-24-active': { content: "" }, + 'instantUnstakingClock-24-inactive': { content: "" }, + 'institute-12-active': { content: "" }, + 'institute-12-inactive': { content: "" }, + 'institute-16-active': { content: "" }, + 'institute-16-inactive': { content: "" }, + 'institute-24-active': { content: "" }, + 'institute-24-inactive': { content: "" }, + 'institutionalProduct-12-active': { content: "" }, + 'institutionalProduct-12-inactive': { content: "" }, + 'institutionalProduct-16-active': { content: "" }, + 'institutionalProduct-16-inactive': { content: "" }, + 'institutionalProduct-24-active': { content: "" }, + 'institutionalProduct-24-inactive': { content: "" }, + 'interest-12-active': { content: "" }, + 'interest-12-inactive': { content: "" }, + 'interest-16-active': { content: "" }, + 'interest-16-inactive': { content: "" }, + 'interest-24-active': { content: "" }, + 'interest-24-inactive': { content: "" }, + 'invisible-12-active': { content: "" }, + 'invisible-12-inactive': { content: "" }, + 'invisible-16-active': { content: "" }, + 'invisible-16-inactive': { content: "" }, + 'invisible-24-active': { content: "" }, + 'invisible-24-inactive': { content: "" }, + 'invoice-12-active': { content: "" }, + 'invoice-12-inactive': { content: "" }, + 'invoice-16-active': { content: "" }, + 'invoice-16-inactive': { content: "" }, + 'invoice-24-active': { content: "" }, + 'invoice-24-inactive': { content: "" }, + 'key-12-active': { content: "" }, + 'key-12-inactive': { content: "" }, + 'key-16-active': { content: "" }, + 'key-16-inactive': { content: "" }, + 'key-24-active': { content: "" }, + 'key-24-inactive': { content: "" }, + 'keyboard-12-active': { content: "" }, + 'keyboard-12-inactive': { content: "" }, + 'keyboard-16-active': { content: "" }, + 'keyboard-16-inactive': { content: "" }, + 'keyboard-24-active': { content: "" }, + 'keyboard-24-inactive': { content: "" }, + 'laptop-12-active': { content: "" }, + 'laptop-12-inactive': { content: "" }, + 'laptop-16-active': { content: "" }, + 'laptop-16-inactive': { content: "" }, + 'laptop-24-active': { content: "" }, + 'laptop-24-inactive': { content: "" }, + 'leadChart-12-active': { content: "" }, + 'leadChart-12-inactive': { content: "" }, + 'leadChart-16-active': { content: "" }, + 'leadChart-16-inactive': { content: "" }, + 'leadChart-24-active': { content: "" }, + 'leadChart-24-inactive': { content: "" }, + 'leadCoin-12-active': { content: "" }, + 'leadCoin-12-inactive': { content: "" }, + 'leadCoin-16-active': { content: "" }, + 'leadCoin-16-inactive': { content: "" }, + 'leadCoin-24-active': { content: "" }, + 'leadCoin-24-inactive': { content: "" }, + 'learningRewardsProduct-12-active': { content: "" }, + 'learningRewardsProduct-12-inactive': { content: "" }, + 'learningRewardsProduct-16-active': { content: "" }, + 'learningRewardsProduct-16-inactive': { content: "" }, + 'learningRewardsProduct-24-active': { content: "" }, + 'learningRewardsProduct-24-inactive': { content: "" }, + 'light-12-active': { content: "" }, + 'light-12-inactive': { content: "" }, + 'light-16-active': { content: "" }, + 'light-16-inactive': { content: "" }, + 'light-24-active': { content: "" }, + 'light-24-inactive': { content: "" }, + 'lightbulb-12-active': { content: "" }, + 'lightbulb-12-inactive': { content: "" }, + 'lightbulb-16-active': { content: "" }, + 'lightbulb-16-inactive': { content: "" }, + 'lightbulb-24-active': { content: "" }, + 'lightbulb-24-inactive': { content: "" }, + 'lightning-12-active': { content: "" }, + 'lightning-12-inactive': { content: "" }, + 'lightning-16-active': { content: "" }, + 'lightning-16-inactive': { content: "" }, + 'lightning-24-active': { content: "" }, + 'lightning-24-inactive': { content: "" }, + 'lightningBolt-12-active': { content: "" }, + 'lightningBolt-12-inactive': { content: "" }, + 'lightningBolt-16-active': { content: "" }, + 'lightningBolt-16-inactive': { content: "" }, + 'lightningBolt-24-active': { content: "" }, + 'lightningBolt-24-inactive': { content: "" }, + 'lineChartCrypto-12-active': { content: "" }, + 'lineChartCrypto-12-inactive': { content: "" }, + 'lineChartCrypto-16-active': { content: "" }, + 'lineChartCrypto-16-inactive': { content: "" }, + 'lineChartCrypto-24-active': { content: "" }, + 'lineChartCrypto-24-inactive': { content: "" }, + 'list-12-active': { content: "" }, + 'list-12-inactive': { content: "" }, + 'list-16-active': { content: "" }, + 'list-16-inactive': { content: "" }, + 'list-24-active': { content: "" }, + 'list-24-inactive': { content: "" }, + 'location-12-active': { content: "" }, + 'location-12-inactive': { content: "" }, + 'location-16-active': { content: "" }, + 'location-16-inactive': { content: "" }, + 'location-24-active': { content: "" }, + 'location-24-inactive': { content: "" }, + 'lock-12-active': { content: "" }, + 'lock-12-inactive': { content: "" }, + 'lock-16-active': { content: "" }, + 'lock-16-inactive': { content: "" }, + 'lock-24-active': { content: "" }, + 'lock-24-inactive': { content: "" }, + 'login-12-active': { content: "" }, + 'login-12-inactive': { content: "" }, + 'login-16-active': { content: "" }, + 'login-16-inactive': { content: "" }, + 'login-24-active': { content: "" }, + 'login-24-inactive': { content: "" }, + 'logout-12-active': { content: "" }, + 'logout-12-inactive': { content: "" }, + 'logout-16-active': { content: "" }, + 'logout-16-inactive': { content: "" }, + 'logout-24-active': { content: "" }, + 'logout-24-inactive': { content: "" }, + 'loop-12-active': { content: "" }, + 'loop-12-inactive': { content: "" }, + 'loop-16-active': { content: "" }, + 'loop-16-inactive': { content: "" }, + 'loop-24-active': { content: "" }, + 'loop-24-inactive': { content: "" }, + 'magnifyingGlass-12-active': { content: "" }, + 'magnifyingGlass-12-inactive': { content: "" }, + 'magnifyingGlass-16-active': { content: "" }, + 'magnifyingGlass-16-inactive': { content: "" }, + 'magnifyingGlass-24-active': { content: "" }, + 'magnifyingGlass-24-inactive': { content: "" }, + 'marketCap-12-active': { content: "" }, + 'marketCap-12-inactive': { content: "" }, + 'marketCap-16-active': { content: "" }, + 'marketCap-16-inactive': { content: "" }, + 'marketCap-24-active': { content: "" }, + 'marketCap-24-inactive': { content: "" }, + 'medal-12-active': { content: "" }, + 'medal-12-inactive': { content: "" }, + 'medal-16-active': { content: "" }, + 'medal-16-inactive': { content: "" }, + 'medal-24-active': { content: "" }, + 'medal-24-inactive': { content: "" }, + 'megaphone-12-active': { content: "" }, + 'megaphone-12-inactive': { content: "" }, + 'megaphone-16-active': { content: "" }, + 'megaphone-16-inactive': { content: "" }, + 'megaphone-24-active': { content: "" }, + 'megaphone-24-inactive': { content: "" }, + 'menu-12-active': { content: "" }, + 'menu-12-inactive': { content: "" }, + 'menu-16-active': { content: "" }, + 'menu-16-inactive': { content: "" }, + 'menu-24-active': { content: "" }, + 'menu-24-inactive': { content: "" }, + 'metaverse-12-active': { content: "" }, + 'metaverse-12-inactive': { content: "" }, + 'metaverse-16-active': { content: "" }, + 'metaverse-16-inactive': { content: "" }, + 'metaverse-24-active': { content: "" }, + 'metaverse-24-inactive': { content: "" }, + 'microphone-12-active': { content: "" }, + 'microphone-12-inactive': { content: "" }, + 'microphone-16-active': { content: "" }, + 'microphone-16-inactive': { content: "" }, + 'microphone-24-active': { content: "" }, + 'microphone-24-inactive': { content: "" }, + 'microphoneCordless-12-active': { content: "" }, + 'microphoneCordless-12-inactive': { content: "" }, + 'microphoneCordless-16-active': { content: "" }, + 'microphoneCordless-16-inactive': { content: "" }, + 'microphoneCordless-24-active': { content: "" }, + 'microphoneCordless-24-inactive': { content: "" }, + 'microscope-12-active': { content: "" }, + 'microscope-12-inactive': { content: "" }, + 'microscope-16-active': { content: "" }, + 'microscope-16-inactive': { content: "" }, + 'microscope-24-active': { content: "" }, + 'microscope-24-inactive': { content: "" }, + 'mint-12-active': { content: "" }, + 'mint-12-inactive': { content: "" }, + 'mint-16-active': { content: "" }, + 'mint-16-inactive': { content: "" }, + 'mint-24-active': { content: "" }, + 'mint-24-inactive': { content: "" }, + 'minus-12-active': { content: "" }, + 'minus-12-inactive': { content: "" }, + 'minus-16-active': { content: "" }, + 'minus-16-inactive': { content: "" }, + 'minus-24-active': { content: "" }, + 'minus-24-inactive': { content: "" }, + 'mma-12-active': { content: "" }, + 'mma-12-inactive': { content: "" }, + 'mma-16-active': { content: "" }, + 'mma-16-inactive': { content: "" }, + 'mma-24-active': { content: "" }, + 'mma-24-inactive': { content: "" }, + 'moneyCardCoin-12-active': { content: "" }, + 'moneyCardCoin-12-inactive': { content: "" }, + 'moneyCardCoin-16-active': { content: "" }, + 'moneyCardCoin-16-inactive': { content: "" }, + 'moneyCardCoin-24-active': { content: "" }, + 'moneyCardCoin-24-inactive': { content: "" }, + 'moon-12-active': { content: "" }, + 'moon-12-inactive': { content: "" }, + 'moon-16-active': { content: "" }, + 'moon-16-inactive': { content: "" }, + 'moon-24-active': { content: "" }, + 'moon-24-inactive': { content: "" }, + 'more-12-active': { content: "" }, + 'more-12-inactive': { content: "" }, + 'more-16-active': { content: "" }, + 'more-16-inactive': { content: "" }, + 'more-24-active': { content: "" }, + 'more-24-inactive': { content: "" }, + 'moreVertical-12-active': { content: "" }, + 'moreVertical-12-inactive': { content: "" }, + 'moreVertical-16-active': { content: "" }, + 'moreVertical-16-inactive': { content: "" }, + 'moreVertical-24-active': { content: "" }, + 'moreVertical-24-inactive': { content: "" }, + 'motorsport-12-active': { content: "" }, + 'motorsport-12-inactive': { content: "" }, + 'motorsport-16-active': { content: "" }, + 'motorsport-16-inactive': { content: "" }, + 'motorsport-24-active': { content: "" }, + 'motorsport-24-inactive': { content: "" }, + 'music-12-active': { content: "" }, + 'music-12-inactive': { content: "" }, + 'music-16-active': { content: "" }, + 'music-16-inactive': { content: "" }, + 'music-24-active': { content: "" }, + 'music-24-inactive': { content: "" }, + 'musicArticles-12-active': { content: "" }, + 'musicArticles-12-inactive': { content: "" }, + 'musicArticles-16-active': { content: "" }, + 'musicArticles-16-inactive': { content: "" }, + 'musicArticles-24-active': { content: "" }, + 'musicArticles-24-inactive': { content: "" }, + 'needle-12-active': { content: "" }, + 'needle-12-inactive': { content: "" }, + 'needle-16-active': { content: "" }, + 'needle-16-inactive': { content: "" }, + 'needle-24-active': { content: "" }, + 'needle-24-inactive': { content: "" }, + 'newsFeed-12-active': { content: "" }, + 'newsFeed-12-inactive': { content: "" }, + 'newsFeed-16-active': { content: "" }, + 'newsFeed-16-inactive': { content: "" }, + 'newsFeed-24-active': { content: "" }, + 'newsFeed-24-inactive': { content: "" }, + 'newsletter-12-active': { content: "" }, + 'newsletter-12-inactive': { content: "" }, + 'newsletter-16-active': { content: "" }, + 'newsletter-16-inactive': { content: "" }, + 'newsletter-24-active': { content: "" }, + 'newsletter-24-inactive': { content: "" }, + 'nft-12-active': { content: "" }, + 'nft-12-inactive': { content: "" }, + 'nft-16-active': { content: "" }, + 'nft-16-inactive': { content: "" }, + 'nft-24-active': { content: "" }, + 'nft-24-inactive': { content: "" }, + 'nftBuy-12-active': { content: "" }, + 'nftBuy-12-inactive': { content: "" }, + 'nftBuy-16-active': { content: "" }, + 'nftBuy-16-inactive': { content: "" }, + 'nftBuy-24-active': { content: "" }, + 'nftBuy-24-inactive': { content: "" }, + 'nftOffer-12-active': { content: "" }, + 'nftOffer-12-inactive': { content: "" }, + 'nftOffer-16-active': { content: "" }, + 'nftOffer-16-inactive': { content: "" }, + 'nftOffer-24-active': { content: "" }, + 'nftOffer-24-inactive': { content: "" }, + 'nftProduct-12-active': { content: "" }, + 'nftProduct-12-inactive': { content: "" }, + 'nftProduct-16-active': { content: "" }, + 'nftProduct-16-inactive': { content: "" }, + 'nftProduct-24-active': { content: "" }, + 'nftProduct-24-inactive': { content: "" }, + 'nftSale-12-active': { content: "" }, + 'nftSale-12-inactive': { content: "" }, + 'nftSale-16-active': { content: "" }, + 'nftSale-16-inactive': { content: "" }, + 'nftSale-24-active': { content: "" }, + 'nftSale-24-inactive': { content: "" }, + 'nodeProduct-12-active': { content: "" }, + 'nodeProduct-12-inactive': { content: "" }, + 'nodeProduct-16-active': { content: "" }, + 'nodeProduct-16-inactive': { content: "" }, + 'nodeProduct-24-active': { content: "" }, + 'nodeProduct-24-inactive': { content: "" }, + 'noRocket-12-active': { content: "" }, + 'noRocket-12-inactive': { content: "" }, + 'noRocket-16-active': { content: "" }, + 'noRocket-16-inactive': { content: "" }, + 'noRocket-24-active': { content: "" }, + 'noRocket-24-inactive': { content: "" }, + 'noWifi-12-active': { content: "" }, + 'noWifi-12-inactive': { content: "" }, + 'noWifi-16-active': { content: "" }, + 'noWifi-16-inactive': { content: "" }, + 'noWifi-24-active': { content: "" }, + 'noWifi-24-inactive': { content: "" }, + 'oil-12-active': { content: "" }, + 'oil-12-inactive': { content: "" }, + 'oil-16-active': { content: "" }, + 'oil-16-inactive': { content: "" }, + 'oil-24-active': { content: "" }, + 'oil-24-inactive': { content: "" }, + 'options-12-active': { content: "" }, + 'options-12-inactive': { content: "" }, + 'options-16-active': { content: "" }, + 'options-16-inactive': { content: "" }, + 'options-24-active': { content: "" }, + 'options-24-inactive': { content: "" }, + 'orderBook-12-active': { content: "" }, + 'orderBook-12-inactive': { content: "" }, + 'orderBook-16-active': { content: "" }, + 'orderBook-16-inactive': { content: "" }, + 'orderBook-24-active': { content: "" }, + 'orderBook-24-inactive': { content: "" }, + 'orderHistory-12-active': { content: "" }, + 'orderHistory-12-inactive': { content: "" }, + 'orderHistory-16-active': { content: "" }, + 'orderHistory-16-inactive': { content: "" }, + 'orderHistory-24-active': { content: "" }, + 'orderHistory-24-inactive': { content: "" }, + 'outline-12-active': { content: "" }, + 'outline-12-inactive': { content: "" }, + 'outline-16-active': { content: "" }, + 'outline-16-inactive': { content: "" }, + 'outline-24-active': { content: "" }, + 'outline-24-inactive': { content: "" }, + 'paperAirplane-12-active': { content: "" }, + 'paperAirplane-12-inactive': { content: "" }, + 'paperAirplane-16-active': { content: "" }, + 'paperAirplane-16-inactive': { content: "" }, + 'paperAirplane-24-active': { content: "" }, + 'paperAirplane-24-inactive': { content: "" }, + 'paperclip-12-active': { content: "" }, + 'paperclip-12-inactive': { content: "" }, + 'paperclip-16-active': { content: "" }, + 'paperclip-16-inactive': { content: "" }, + 'paperclip-24-active': { content: "" }, + 'paperclip-24-inactive': { content: "" }, + 'participate-12-active': { content: "" }, + 'participate-12-inactive': { content: "" }, + 'participate-16-active': { content: "" }, + 'participate-16-inactive': { content: "" }, + 'participate-24-active': { content: "" }, + 'participate-24-inactive': { content: "" }, + 'participateProduct-12-active': { content: "" }, + 'participateProduct-12-inactive': { content: "" }, + 'participateProduct-16-active': { content: "" }, + 'participateProduct-16-inactive': { content: "" }, + 'participateProduct-24-active': { content: "" }, + 'participateProduct-24-inactive': { content: "" }, + 'passKey-12-active': { content: "" }, + 'passKey-12-inactive': { content: "" }, + 'passKey-16-active': { content: "" }, + 'passKey-16-inactive': { content: "" }, + 'passKey-24-active': { content: "" }, + 'passKey-24-inactive': { content: "" }, + 'passport-12-active': { content: "" }, + 'passport-12-inactive': { content: "" }, + 'passport-16-active': { content: "" }, + 'passport-16-inactive': { content: "" }, + 'passport-24-active': { content: "" }, + 'passport-24-inactive': { content: "" }, + 'pause-12-active': { content: "" }, + 'pause-12-inactive': { content: "" }, + 'pause-16-active': { content: "" }, + 'pause-16-inactive': { content: "" }, + 'pause-24-active': { content: "" }, + 'pause-24-inactive': { content: "" }, + 'pay-12-active': { content: "" }, + 'pay-12-inactive': { content: "" }, + 'pay-16-active': { content: "" }, + 'pay-16-inactive': { content: "" }, + 'pay-24-active': { content: "" }, + 'pay-24-inactive': { content: "" }, + 'paymentCard-12-active': { content: "" }, + 'paymentCard-12-inactive': { content: "" }, + 'paymentCard-16-active': { content: "" }, + 'paymentCard-16-inactive': { content: "" }, + 'paymentCard-24-active': { content: "" }, + 'paymentCard-24-inactive': { content: "" }, + 'payments-12-active': { content: "" }, + 'payments-12-inactive': { content: "" }, + 'payments-16-active': { content: "" }, + 'payments-16-inactive': { content: "" }, + 'payments-24-active': { content: "" }, + 'payments-24-inactive': { content: "" }, + 'payouts-12-active': { content: "" }, + 'payouts-12-inactive': { content: "" }, + 'payouts-16-active': { content: "" }, + 'payouts-16-inactive': { content: "" }, + 'payouts-24-active': { content: "" }, + 'payouts-24-inactive': { content: "" }, + 'paypal-12-active': { content: "" }, + 'paypal-12-inactive': { content: "" }, + 'paypal-16-active': { content: "" }, + 'paypal-16-inactive': { content: "" }, + 'paypal-24-active': { content: "" }, + 'paypal-24-inactive': { content: "" }, + 'payProduct-12-active': { content: "" }, + 'payProduct-12-inactive': { content: "" }, + 'payProduct-16-active': { content: "" }, + 'payProduct-16-inactive': { content: "" }, + 'payProduct-24-active': { content: "" }, + 'payProduct-24-inactive': { content: "" }, + 'pencil-12-active': { content: "" }, + 'pencil-12-inactive': { content: "" }, + 'pencil-16-active': { content: "" }, + 'pencil-16-inactive': { content: "" }, + 'pencil-24-active': { content: "" }, + 'pencil-24-inactive': { content: "" }, + 'peopleGroup-12-active': { content: "" }, + 'peopleGroup-12-inactive': { content: "" }, + 'peopleGroup-16-active': { content: "" }, + 'peopleGroup-16-inactive': { content: "" }, + 'peopleGroup-24-active': { content: "" }, + 'peopleGroup-24-inactive': { content: "" }, + 'peopleStar-12-active': { content: "" }, + 'peopleStar-12-inactive': { content: "" }, + 'peopleStar-16-active': { content: "" }, + 'peopleStar-16-inactive': { content: "" }, + 'peopleStar-24-active': { content: "" }, + 'peopleStar-24-inactive': { content: "" }, + 'percentage-12-active': { content: "" }, + 'percentage-12-inactive': { content: "" }, + 'percentage-16-active': { content: "" }, + 'percentage-16-inactive': { content: "" }, + 'percentage-24-active': { content: "" }, + 'percentage-24-inactive': { content: "" }, + 'perpetualSwap-12-active': { content: "" }, + 'perpetualSwap-12-inactive': { content: "" }, + 'perpetualSwap-16-active': { content: "" }, + 'perpetualSwap-16-inactive': { content: "" }, + 'perpetualSwap-24-active': { content: "" }, + 'perpetualSwap-24-inactive': { content: "" }, + 'pFPS-12-active': { content: "" }, + 'pFPS-12-inactive': { content: "" }, + 'pFPS-16-active': { content: "" }, + 'pFPS-16-inactive': { content: "" }, + 'pFPS-24-active': { content: "" }, + 'pFPS-24-inactive': { content: "" }, + 'phone-12-active': { content: "" }, + 'phone-12-inactive': { content: "" }, + 'phone-16-active': { content: "" }, + 'phone-16-inactive': { content: "" }, + 'phone-24-active': { content: "" }, + 'phone-24-inactive': { content: "" }, + 'pieChartData-12-active': { content: "" }, + 'pieChartData-12-inactive': { content: "" }, + 'pieChartData-16-active': { content: "" }, + 'pieChartData-16-inactive': { content: "" }, + 'pieChartData-24-active': { content: "" }, + 'pieChartData-24-inactive': { content: "" }, + 'pillBottle-12-active': { content: "" }, + 'pillBottle-12-inactive': { content: "" }, + 'pillBottle-16-active': { content: "" }, + 'pillBottle-16-inactive': { content: "" }, + 'pillBottle-24-active': { content: "" }, + 'pillBottle-24-inactive': { content: "" }, + 'pillCapsule-12-active': { content: "" }, + 'pillCapsule-12-inactive': { content: "" }, + 'pillCapsule-16-active': { content: "" }, + 'pillCapsule-16-inactive': { content: "" }, + 'pillCapsule-24-active': { content: "" }, + 'pillCapsule-24-inactive': { content: "" }, + 'pin-12-active': { content: "" }, + 'pin-12-inactive': { content: "" }, + 'pin-16-active': { content: "" }, + 'pin-16-inactive': { content: "" }, + 'pin-24-active': { content: "" }, + 'pin-24-inactive': { content: "" }, + 'plane-12-active': { content: "" }, + 'plane-12-inactive': { content: "" }, + 'plane-16-active': { content: "" }, + 'plane-16-inactive': { content: "" }, + 'plane-24-active': { content: "" }, + 'plane-24-inactive': { content: "" }, + 'planet-12-active': { content: "" }, + 'planet-12-inactive': { content: "" }, + 'planet-16-active': { content: "" }, + 'planet-16-inactive': { content: "" }, + 'planet-24-active': { content: "" }, + 'planet-24-inactive': { content: "" }, + 'play-12-active': { content: "" }, + 'play-12-inactive': { content: "" }, + 'play-16-active': { content: "" }, + 'play-16-inactive': { content: "" }, + 'play-24-active': { content: "" }, + 'play-24-inactive': { content: "" }, + 'playbutton-12-active': { content: "" }, + 'playbutton-12-inactive': { content: "" }, + 'playbutton-16-active': { content: "" }, + 'playbutton-16-inactive': { content: "" }, + 'playbutton-24-active': { content: "" }, + 'playbutton-24-inactive': { content: "" }, + 'plusMinus-12-active': { content: "" }, + 'plusMinus-12-inactive': { content: "" }, + 'plusMinus-16-active': { content: "" }, + 'plusMinus-16-inactive': { content: "" }, + 'plusMinus-24-active': { content: "" }, + 'plusMinus-24-inactive': { content: "" }, + 'podiumStar-12-active': { content: "" }, + 'podiumStar-12-inactive': { content: "" }, + 'podiumStar-16-active': { content: "" }, + 'podiumStar-16-inactive': { content: "" }, + 'podiumStar-24-active': { content: "" }, + 'podiumStar-24-inactive': { content: "" }, + 'politicsBuilding-12-active': { content: "" }, + 'politicsBuilding-12-inactive': { content: "" }, + 'politicsBuilding-16-active': { content: "" }, + 'politicsBuilding-16-inactive': { content: "" }, + 'politicsBuilding-24-active': { content: "" }, + 'politicsBuilding-24-inactive': { content: "" }, + 'politicsCandidate-12-active': { content: "" }, + 'politicsCandidate-12-inactive': { content: "" }, + 'politicsCandidate-16-active': { content: "" }, + 'politicsCandidate-16-inactive': { content: "" }, + 'politicsCandidate-24-active': { content: "" }, + 'politicsCandidate-24-inactive': { content: "" }, + 'politicsFlag-12-active': { content: "" }, + 'politicsFlag-12-inactive': { content: "" }, + 'politicsFlag-16-active': { content: "" }, + 'politicsFlag-16-inactive': { content: "" }, + 'politicsFlag-24-active': { content: "" }, + 'politicsFlag-24-inactive': { content: "" }, + 'politicsGavel-12-active': { content: "" }, + 'politicsGavel-12-inactive': { content: "" }, + 'politicsGavel-16-active': { content: "" }, + 'politicsGavel-16-inactive': { content: "" }, + 'politicsGavel-24-active': { content: "" }, + 'politicsGavel-24-inactive': { content: "" }, + 'politicsPodium-12-active': { content: "" }, + 'politicsPodium-12-inactive': { content: "" }, + 'politicsPodium-16-active': { content: "" }, + 'politicsPodium-16-inactive': { content: "" }, + 'politicsPodium-24-active': { content: "" }, + 'politicsPodium-24-inactive': { content: "" }, + 'politicsStar-12-active': { content: "" }, + 'politicsStar-12-inactive': { content: "" }, + 'politicsStar-16-active': { content: "" }, + 'politicsStar-16-inactive': { content: "" }, + 'politicsStar-24-active': { content: "" }, + 'politicsStar-24-inactive': { content: "" }, + 'powerTool-12-active': { content: "" }, + 'powerTool-12-inactive': { content: "" }, + 'powerTool-16-active': { content: "" }, + 'powerTool-16-inactive': { content: "" }, + 'powerTool-24-active': { content: "" }, + 'powerTool-24-inactive': { content: "" }, + 'priceAlerts-12-active': { content: "" }, + 'priceAlerts-12-inactive': { content: "" }, + 'priceAlerts-16-active': { content: "" }, + 'priceAlerts-16-inactive': { content: "" }, + 'priceAlerts-24-active': { content: "" }, + 'priceAlerts-24-inactive': { content: "" }, + 'priceAlertsCheck-12-active': { content: "" }, + 'priceAlertsCheck-12-inactive': { content: "" }, + 'priceAlertsCheck-16-active': { content: "" }, + 'priceAlertsCheck-16-inactive': { content: "" }, + 'priceAlertsCheck-24-active': { content: "" }, + 'priceAlertsCheck-24-inactive': { content: "" }, + 'primePoduct-12-active': { content: "" }, + 'primePoduct-12-inactive': { content: "" }, + 'primePoduct-16-active': { content: "" }, + 'primePoduct-16-inactive': { content: "" }, + 'primePoduct-24-active': { content: "" }, + 'primePoduct-24-inactive': { content: "" }, + 'privateClientProduct-12-active': { content: "" }, + 'privateClientProduct-12-inactive': { content: "" }, + 'privateClientProduct-16-active': { content: "" }, + 'privateClientProduct-16-inactive': { content: "" }, + 'privateClientProduct-24-active': { content: "" }, + 'privateClientProduct-24-inactive': { content: "" }, + 'profile-12-active': { content: "" }, + 'profile-12-inactive': { content: "" }, + 'profile-16-active': { content: "" }, + 'profile-16-inactive': { content: "" }, + 'profile-24-active': { content: "" }, + 'profile-24-inactive': { content: "" }, + 'proProduct-12-active': { content: "" }, + 'proProduct-12-inactive': { content: "" }, + 'proProduct-16-active': { content: "" }, + 'proProduct-16-inactive': { content: "" }, + 'proProduct-24-active': { content: "" }, + 'proProduct-24-inactive': { content: "" }, + 'protection-12-active': { content: "" }, + 'protection-12-inactive': { content: "" }, + 'protection-16-active': { content: "" }, + 'protection-16-inactive': { content: "" }, + 'protection-24-active': { content: "" }, + 'protection-24-inactive': { content: "" }, + 'pulse-12-active': { content: "" }, + 'pulse-12-inactive': { content: "" }, + 'pulse-16-active': { content: "" }, + 'pulse-16-inactive': { content: "" }, + 'pulse-24-active': { content: "" }, + 'pulse-24-inactive': { content: "" }, + 'pyramid-12-active': { content: "" }, + 'pyramid-12-inactive': { content: "" }, + 'pyramid-16-active': { content: "" }, + 'pyramid-16-inactive': { content: "" }, + 'pyramid-24-active': { content: "" }, + 'pyramid-24-inactive': { content: "" }, + 'qrCode-12-active': { content: "" }, + 'qrCode-12-inactive': { content: "" }, + 'qrCode-16-active': { content: "" }, + 'qrCode-16-inactive': { content: "" }, + 'qrCode-24-active': { content: "" }, + 'qrCode-24-inactive': { content: "" }, + 'qrCodeAlt-12-active': { content: "" }, + 'qrCodeAlt-12-inactive': { content: "" }, + 'qrCodeAlt-16-active': { content: "" }, + 'qrCodeAlt-16-inactive': { content: "" }, + 'qrCodeAlt-24-active': { content: "" }, + 'qrCodeAlt-24-inactive': { content: "" }, + 'queryTransact-12-active': { content: "" }, + 'queryTransact-12-inactive': { content: "" }, + 'queryTransact-16-active': { content: "" }, + 'queryTransact-16-inactive': { content: "" }, + 'queryTransact-24-active': { content: "" }, + 'queryTransact-24-inactive': { content: "" }, + 'questionMark-12-active': { content: "" }, + 'questionMark-12-inactive': { content: "" }, + 'questionMark-16-active': { content: "" }, + 'questionMark-16-inactive': { content: "" }, + 'questionMark-24-active': { content: "" }, + 'questionMark-24-inactive': { content: "" }, + 'quotation-12-active': { content: "" }, + 'quotation-12-inactive': { content: "" }, + 'quotation-16-active': { content: "" }, + 'quotation-16-inactive': { content: "" }, + 'quotation-24-active': { content: "" }, + 'quotation-24-inactive': { content: "" }, + 'rain-12-active': { content: "" }, + 'rain-12-inactive': { content: "" }, + 'rain-16-active': { content: "" }, + 'rain-16-inactive': { content: "" }, + 'rain-24-active': { content: "" }, + 'rain-24-inactive': { content: "" }, + 'ratingsCheck-12-active': { content: "" }, + 'ratingsCheck-12-inactive': { content: "" }, + 'ratingsCheck-16-active': { content: "" }, + 'ratingsCheck-16-inactive': { content: "" }, + 'ratingsCheck-24-active': { content: "" }, + 'ratingsCheck-24-inactive': { content: "" }, + 'ratingsChecks-12-active': { content: "" }, + 'ratingsChecks-12-inactive': { content: "" }, + 'ratingsChecks-16-active': { content: "" }, + 'ratingsChecks-16-inactive': { content: "" }, + 'ratingsChecks-24-active': { content: "" }, + 'ratingsChecks-24-inactive': { content: "" }, + 'ratingsStar-12-active': { content: "" }, + 'ratingsStar-12-inactive': { content: "" }, + 'ratingsStar-16-active': { content: "" }, + 'ratingsStar-16-inactive': { content: "" }, + 'ratingsStar-24-active': { content: "" }, + 'ratingsStar-24-inactive': { content: "" }, + 'reCenter-12-active': { content: "" }, + 'reCenter-12-inactive': { content: "" }, + 'reCenter-16-active': { content: "" }, + 'reCenter-16-inactive': { content: "" }, + 'reCenter-24-active': { content: "" }, + 'reCenter-24-inactive': { content: "" }, + 'rectangle-12-active': { content: "" }, + 'rectangle-12-inactive': { content: "" }, + 'rectangle-16-active': { content: "" }, + 'rectangle-16-inactive': { content: "" }, + 'rectangle-24-active': { content: "" }, + 'rectangle-24-inactive': { content: "" }, + 'recurring-12-active': { content: "" }, + 'recurring-12-inactive': { content: "" }, + 'recurring-16-active': { content: "" }, + 'recurring-16-inactive': { content: "" }, + 'recurring-24-active': { content: "" }, + 'recurring-24-inactive': { content: "" }, + 'refresh-12-active': { content: "" }, + 'refresh-12-inactive': { content: "" }, + 'refresh-16-active': { content: "" }, + 'refresh-16-inactive': { content: "" }, + 'refresh-24-active': { content: "" }, + 'refresh-24-inactive': { content: "" }, + 'regulated-12-active': { content: "" }, + 'regulated-12-inactive': { content: "" }, + 'regulated-16-active': { content: "" }, + 'regulated-16-inactive': { content: "" }, + 'regulated-24-active': { content: "" }, + 'regulated-24-inactive': { content: "" }, + 'regulatedFutures-12-active': { content: "" }, + 'regulatedFutures-12-inactive': { content: "" }, + 'regulatedFutures-16-active': { content: "" }, + 'regulatedFutures-16-inactive': { content: "" }, + 'regulatedFutures-24-active': { content: "" }, + 'regulatedFutures-24-inactive': { content: "" }, + 'report-12-active': { content: "" }, + 'report-12-inactive': { content: "" }, + 'report-16-active': { content: "" }, + 'report-16-inactive': { content: "" }, + 'report-24-active': { content: "" }, + 'report-24-inactive': { content: "" }, + 'rewardsProduct-12-active': { content: "" }, + 'rewardsProduct-12-inactive': { content: "" }, + 'rewardsProduct-16-active': { content: "" }, + 'rewardsProduct-16-inactive': { content: "" }, + 'rewardsProduct-24-active': { content: "" }, + 'rewardsProduct-24-inactive': { content: "" }, + 'ribbon-12-active': { content: "" }, + 'ribbon-12-inactive': { content: "" }, + 'ribbon-16-active': { content: "" }, + 'ribbon-16-inactive': { content: "" }, + 'ribbon-24-active': { content: "" }, + 'ribbon-24-inactive': { content: "" }, + 'robot-12-active': { content: "" }, + 'robot-12-inactive': { content: "" }, + 'robot-16-active': { content: "" }, + 'robot-16-inactive': { content: "" }, + 'robot-24-active': { content: "" }, + 'robot-24-inactive': { content: "" }, + 'rocket-12-active': { content: "" }, + 'rocket-12-inactive': { content: "" }, + 'rocket-16-active': { content: "" }, + 'rocket-16-inactive': { content: "" }, + 'rocket-24-active': { content: "" }, + 'rocket-24-inactive': { content: "" }, + 'rocketShip-12-active': { content: "" }, + 'rocketShip-12-inactive': { content: "" }, + 'rocketShip-16-active': { content: "" }, + 'rocketShip-16-inactive': { content: "" }, + 'rocketShip-24-active': { content: "" }, + 'rocketShip-24-inactive': { content: "" }, + 'rollingSpot-12-active': { content: "" }, + 'rollingSpot-12-inactive': { content: "" }, + 'rollingSpot-16-active': { content: "" }, + 'rollingSpot-16-inactive': { content: "" }, + 'rollingSpot-24-active': { content: "" }, + 'rollingSpot-24-inactive': { content: "" }, + 'rosettaProduct-12-active': { content: "" }, + 'rosettaProduct-12-inactive': { content: "" }, + 'rosettaProduct-16-active': { content: "" }, + 'rosettaProduct-16-inactive': { content: "" }, + 'rosettaProduct-24-active': { content: "" }, + 'rosettaProduct-24-inactive': { content: "" }, + 'rottenTomato-12-active': { content: "" }, + 'rottenTomato-12-inactive': { content: "" }, + 'rottenTomato-16-active': { content: "" }, + 'rottenTomato-16-inactive': { content: "" }, + 'rottenTomato-24-active': { content: "" }, + 'rottenTomato-24-inactive': { content: "" }, + 'royalty-12-active': { content: "" }, + 'royalty-12-inactive': { content: "" }, + 'royalty-16-active': { content: "" }, + 'royalty-16-inactive': { content: "" }, + 'royalty-24-active': { content: "" }, + 'royalty-24-inactive': { content: "" }, + 'safe-12-active': { content: "" }, + 'safe-12-inactive': { content: "" }, + 'safe-16-active': { content: "" }, + 'safe-16-inactive': { content: "" }, + 'safe-24-active': { content: "" }, + 'safe-24-inactive': { content: "" }, + 'save-12-active': { content: "" }, + 'save-12-inactive': { content: "" }, + 'save-16-active': { content: "" }, + 'save-16-inactive': { content: "" }, + 'save-24-active': { content: "" }, + 'save-24-inactive': { content: "" }, + 'savingsBank-12-active': { content: "" }, + 'savingsBank-12-inactive': { content: "" }, + 'savingsBank-16-active': { content: "" }, + 'savingsBank-16-inactive': { content: "" }, + 'savingsBank-24-active': { content: "" }, + 'savingsBank-24-inactive': { content: "" }, + 'scanQrCode-12-active': { content: "" }, + 'scanQrCode-12-inactive': { content: "" }, + 'scanQrCode-16-active': { content: "" }, + 'scanQrCode-16-inactive': { content: "" }, + 'scanQrCode-24-active': { content: "" }, + 'scanQrCode-24-inactive': { content: "" }, + 'scienceAtom-12-active': { content: "" }, + 'scienceAtom-12-inactive': { content: "" }, + 'scienceAtom-16-active': { content: "" }, + 'scienceAtom-16-inactive': { content: "" }, + 'scienceAtom-24-active': { content: "" }, + 'scienceAtom-24-inactive': { content: "" }, + 'scienceBeaker-12-active': { content: "" }, + 'scienceBeaker-12-inactive': { content: "" }, + 'scienceBeaker-16-active': { content: "" }, + 'scienceBeaker-16-inactive': { content: "" }, + 'scienceBeaker-24-active': { content: "" }, + 'scienceBeaker-24-inactive': { content: "" }, + 'scienceMoon-12-active': { content: "" }, + 'scienceMoon-12-inactive': { content: "" }, + 'scienceMoon-16-active': { content: "" }, + 'scienceMoon-16-inactive': { content: "" }, + 'scienceMoon-24-active': { content: "" }, + 'scienceMoon-24-inactive': { content: "" }, + 'search-12-active': { content: "" }, + 'search-12-inactive': { content: "" }, + 'search-16-active': { content: "" }, + 'search-16-inactive': { content: "" }, + 'search-24-active': { content: "" }, + 'search-24-inactive': { content: "" }, + 'securityKey-12-active': { content: "" }, + 'securityKey-12-inactive': { content: "" }, + 'securityKey-16-active': { content: "" }, + 'securityKey-16-inactive': { content: "" }, + 'securityKey-24-active': { content: "" }, + 'securityKey-24-inactive': { content: "" }, + 'securityShield-12-active': { content: "" }, + 'securityShield-12-inactive': { content: "" }, + 'securityShield-16-active': { content: "" }, + 'securityShield-16-inactive': { content: "" }, + 'securityShield-24-active': { content: "" }, + 'securityShield-24-inactive': { content: "" }, + 'seen-12-active': { content: "" }, + 'seen-12-inactive': { content: "" }, + 'seen-16-active': { content: "" }, + 'seen-16-inactive': { content: "" }, + 'seen-24-active': { content: "" }, + 'seen-24-inactive': { content: "" }, + 'sendReceive-12-active': { content: "" }, + 'sendReceive-12-inactive': { content: "" }, + 'sendReceive-16-active': { content: "" }, + 'sendReceive-16-inactive': { content: "" }, + 'sendReceive-24-active': { content: "" }, + 'sendReceive-24-inactive': { content: "" }, + 'setPinCode-12-active': { content: "" }, + 'setPinCode-12-inactive': { content: "" }, + 'setPinCode-16-active': { content: "" }, + 'setPinCode-16-inactive': { content: "" }, + 'setPinCode-24-active': { content: "" }, + 'setPinCode-24-inactive': { content: "" }, + 'settings-12-active': { content: "" }, + 'settings-12-inactive': { content: "" }, + 'settings-16-active': { content: "" }, + 'settings-16-inactive': { content: "" }, + 'settings-24-active': { content: "" }, + 'settings-24-inactive': { content: "" }, + 'share-12-active': { content: "" }, + 'share-12-inactive': { content: "" }, + 'share-16-active': { content: "" }, + 'share-16-inactive': { content: "" }, + 'share-24-active': { content: "" }, + 'share-24-inactive': { content: "" }, + 'shield-12-active': { content: "" }, + 'shield-12-inactive': { content: "" }, + 'shield-16-active': { content: "" }, + 'shield-16-inactive': { content: "" }, + 'shield-24-active': { content: "" }, + 'shield-24-inactive': { content: "" }, + 'shieldOutline-12-active': { content: "" }, + 'shieldOutline-12-inactive': { content: "" }, + 'shieldOutline-16-active': { content: "" }, + 'shieldOutline-16-inactive': { content: "" }, + 'shieldOutline-24-active': { content: "" }, + 'shieldOutline-24-inactive': { content: "" }, + 'shoe-12-active': { content: "" }, + 'shoe-12-inactive': { content: "" }, + 'shoe-16-active': { content: "" }, + 'shoe-16-inactive': { content: "" }, + 'shoe-24-active': { content: "" }, + 'shoe-24-inactive': { content: "" }, + 'shoppingCart-12-active': { content: "" }, + 'shoppingCart-12-inactive': { content: "" }, + 'shoppingCart-16-active': { content: "" }, + 'shoppingCart-16-inactive': { content: "" }, + 'shoppingCart-24-active': { content: "" }, + 'shoppingCart-24-inactive': { content: "" }, + 'signinProduct-12-active': { content: "" }, + 'signinProduct-12-inactive': { content: "" }, + 'signinProduct-16-active': { content: "" }, + 'signinProduct-16-inactive': { content: "" }, + 'signinProduct-24-active': { content: "" }, + 'signinProduct-24-inactive': { content: "" }, + 'singlecloud-12-active': { content: "" }, + 'singlecloud-12-inactive': { content: "" }, + 'singlecloud-16-active': { content: "" }, + 'singlecloud-16-inactive': { content: "" }, + 'singlecloud-24-active': { content: "" }, + 'singlecloud-24-inactive': { content: "" }, + 'singleCoin-12-active': { content: "" }, + 'singleCoin-12-inactive': { content: "" }, + 'singleCoin-16-active': { content: "" }, + 'singleCoin-16-inactive': { content: "" }, + 'singleCoin-24-active': { content: "" }, + 'singleCoin-24-inactive': { content: "" }, + 'singleNote-12-active': { content: "" }, + 'singleNote-12-inactive': { content: "" }, + 'singleNote-16-active': { content: "" }, + 'singleNote-16-inactive': { content: "" }, + 'singleNote-24-active': { content: "" }, + 'singleNote-24-inactive': { content: "" }, + 'smartContract-12-active': { content: "" }, + 'smartContract-12-inactive': { content: "" }, + 'smartContract-16-active': { content: "" }, + 'smartContract-16-inactive': { content: "" }, + 'smartContract-24-active': { content: "" }, + 'smartContract-24-inactive': { content: "" }, + 'snow-12-active': { content: "" }, + 'snow-12-inactive': { content: "" }, + 'snow-16-active': { content: "" }, + 'snow-16-inactive': { content: "" }, + 'snow-24-active': { content: "" }, + 'snow-24-inactive': { content: "" }, + 'soccer-12-active': { content: "" }, + 'soccer-12-inactive': { content: "" }, + 'soccer-16-active': { content: "" }, + 'soccer-16-inactive': { content: "" }, + 'soccer-24-active': { content: "" }, + 'soccer-24-inactive': { content: "" }, + 'socialChat-12-active': { content: "" }, + 'socialChat-12-inactive': { content: "" }, + 'socialChat-16-active': { content: "" }, + 'socialChat-16-inactive': { content: "" }, + 'socialChat-24-active': { content: "" }, + 'socialChat-24-inactive': { content: "" }, + 'socialReshare-12-active': { content: "" }, + 'socialReshare-12-inactive': { content: "" }, + 'socialReshare-16-active': { content: "" }, + 'socialReshare-16-inactive': { content: "" }, + 'socialReshare-24-active': { content: "" }, + 'socialReshare-24-inactive': { content: "" }, + 'socialShare-12-active': { content: "" }, + 'socialShare-12-inactive': { content: "" }, + 'socialShare-16-active': { content: "" }, + 'socialShare-16-inactive': { content: "" }, + 'socialShare-24-active': { content: "" }, + 'socialShare-24-inactive': { content: "" }, + 'sofort-12-active': { content: "" }, + 'sofort-12-inactive': { content: "" }, + 'sofort-16-active': { content: "" }, + 'sofort-16-inactive': { content: "" }, + 'sofort-24-active': { content: "" }, + 'sofort-24-inactive': { content: "" }, + 'sortDoubleArrow-12-active': { content: "" }, + 'sortDoubleArrow-12-inactive': { content: "" }, + 'sortDoubleArrow-16-active': { content: "" }, + 'sortDoubleArrow-16-inactive': { content: "" }, + 'sortDoubleArrow-24-active': { content: "" }, + 'sortDoubleArrow-24-inactive': { content: "" }, + 'sortDown-12-active': { content: "" }, + 'sortDown-12-inactive': { content: "" }, + 'sortDown-16-active': { content: "" }, + 'sortDown-16-inactive': { content: "" }, + 'sortDown-24-active': { content: "" }, + 'sortDown-24-inactive': { content: "" }, + 'sortDownCenter-12-active': { content: "" }, + 'sortDownCenter-12-inactive': { content: "" }, + 'sortDownCenter-16-active': { content: "" }, + 'sortDownCenter-16-inactive': { content: "" }, + 'sortDownCenter-24-active': { content: "" }, + 'sortDownCenter-24-inactive': { content: "" }, + 'sortUp-12-active': { content: "" }, + 'sortUp-12-inactive': { content: "" }, + 'sortUp-16-active': { content: "" }, + 'sortUp-16-inactive': { content: "" }, + 'sortUp-24-active': { content: "" }, + 'sortUp-24-inactive': { content: "" }, + 'sortUpCenter-12-active': { content: "" }, + 'sortUpCenter-12-inactive': { content: "" }, + 'sortUpCenter-16-active': { content: "" }, + 'sortUpCenter-16-inactive': { content: "" }, + 'sortUpCenter-24-active': { content: "" }, + 'sortUpCenter-24-inactive': { content: "" }, + 'soundOff-12-active': { content: "" }, + 'soundOff-12-inactive': { content: "" }, + 'soundOff-16-active': { content: "" }, + 'soundOff-16-inactive': { content: "" }, + 'soundOff-24-active': { content: "" }, + 'soundOff-24-inactive': { content: "" }, + 'soundOn-12-active': { content: "" }, + 'soundOn-12-inactive': { content: "" }, + 'soundOn-16-active': { content: "" }, + 'soundOn-16-inactive': { content: "" }, + 'soundOn-24-active': { content: "" }, + 'soundOn-24-inactive': { content: "" }, + 'sparkle-12-active': { content: "" }, + 'sparkle-12-inactive': { content: "" }, + 'sparkle-16-active': { content: "" }, + 'sparkle-16-inactive': { content: "" }, + 'sparkle-24-active': { content: "" }, + 'sparkle-24-inactive': { content: "" }, + 'speaker-12-active': { content: "" }, + 'speaker-12-inactive': { content: "" }, + 'speaker-16-active': { content: "" }, + 'speaker-16-inactive': { content: "" }, + 'speaker-24-active': { content: "" }, + 'speaker-24-inactive': { content: "" }, + 'speechBubble-12-active': { content: "" }, + 'speechBubble-12-inactive': { content: "" }, + 'speechBubble-16-active': { content: "" }, + 'speechBubble-16-inactive': { content: "" }, + 'speechBubble-24-active': { content: "" }, + 'speechBubble-24-inactive': { content: "" }, + 'stableCoin-12-active': { content: "" }, + 'stableCoin-12-inactive': { content: "" }, + 'stableCoin-16-active': { content: "" }, + 'stableCoin-16-inactive': { content: "" }, + 'stableCoin-24-active': { content: "" }, + 'stableCoin-24-inactive': { content: "" }, + 'stablecoinStack-12-active': { content: "" }, + 'stablecoinStack-12-inactive': { content: "" }, + 'stablecoinStack-16-active': { content: "" }, + 'stablecoinStack-16-inactive': { content: "" }, + 'stablecoinStack-24-active': { content: "" }, + 'stablecoinStack-24-inactive': { content: "" }, + 'staggeredList-12-active': { content: "" }, + 'staggeredList-12-inactive': { content: "" }, + 'staggeredList-16-active': { content: "" }, + 'staggeredList-16-inactive': { content: "" }, + 'staggeredList-24-active': { content: "" }, + 'staggeredList-24-inactive': { content: "" }, + 'stake-12-active': { content: "" }, + 'stake-12-inactive': { content: "" }, + 'stake-16-active': { content: "" }, + 'stake-16-inactive': { content: "" }, + 'stake-24-active': { content: "" }, + 'stake-24-inactive': { content: "" }, + 'staking-12-active': { content: "" }, + 'staking-12-inactive': { content: "" }, + 'staking-16-active': { content: "" }, + 'staking-16-inactive': { content: "" }, + 'staking-24-active': { content: "" }, + 'staking-24-inactive': { content: "" }, + 'star-12-active': { content: "" }, + 'star-12-inactive': { content: "" }, + 'star-16-active': { content: "" }, + 'star-16-inactive': { content: "" }, + 'star-24-active': { content: "" }, + 'star-24-inactive': { content: "" }, + 'starAward-12-active': { content: "" }, + 'starAward-12-inactive': { content: "" }, + 'starAward-16-active': { content: "" }, + 'starAward-16-inactive': { content: "" }, + 'starAward-24-active': { content: "" }, + 'starAward-24-inactive': { content: "" }, + 'starBubble-12-active': { content: "" }, + 'starBubble-12-inactive': { content: "" }, + 'starBubble-16-active': { content: "" }, + 'starBubble-16-inactive': { content: "" }, + 'starBubble-24-active': { content: "" }, + 'starBubble-24-inactive': { content: "" }, + 'starTrophy-12-active': { content: "" }, + 'starTrophy-12-inactive': { content: "" }, + 'starTrophy-16-active': { content: "" }, + 'starTrophy-16-inactive': { content: "" }, + 'starTrophy-24-active': { content: "" }, + 'starTrophy-24-inactive': { content: "" }, + 'statusDot-12-active': { content: "" }, + 'statusDot-12-inactive': { content: "" }, + 'statusDot-16-active': { content: "" }, + 'statusDot-16-inactive': { content: "" }, + 'statusDot-24-active': { content: "" }, + 'statusDot-24-inactive': { content: "" }, + 'step0-12-active': { content: "" }, + 'step0-12-inactive': { content: "" }, + 'step0-16-active': { content: "" }, + 'step0-16-inactive': { content: "" }, + 'step0-24-active': { content: "" }, + 'step0-24-inactive': { content: "" }, + 'step1-12-active': { content: "" }, + 'step1-12-inactive': { content: "" }, + 'step1-16-active': { content: "" }, + 'step1-16-inactive': { content: "" }, + 'step1-24-active': { content: "" }, + 'step1-24-inactive': { content: "" }, + 'step2-12-active': { content: "" }, + 'step2-12-inactive': { content: "" }, + 'step2-16-active': { content: "" }, + 'step2-16-inactive': { content: "" }, + 'step2-24-active': { content: "" }, + 'step2-24-inactive': { content: "" }, + 'step3-12-active': { content: "" }, + 'step3-12-inactive': { content: "" }, + 'step3-16-active': { content: "" }, + 'step3-16-inactive': { content: "" }, + 'step3-24-active': { content: "" }, + 'step3-24-inactive': { content: "" }, + 'step4-12-active': { content: "" }, + 'step4-12-inactive': { content: "" }, + 'step4-16-active': { content: "" }, + 'step4-16-inactive': { content: "" }, + 'step4-24-active': { content: "" }, + 'step4-24-inactive': { content: "" }, + 'step5-12-active': { content: "" }, + 'step5-12-inactive': { content: "" }, + 'step5-16-active': { content: "" }, + 'step5-16-inactive': { content: "" }, + 'step5-24-active': { content: "" }, + 'step5-24-inactive': { content: "" }, + 'step6-12-active': { content: "" }, + 'step6-12-inactive': { content: "" }, + 'step6-16-active': { content: "" }, + 'step6-16-inactive': { content: "" }, + 'step6-24-active': { content: "" }, + 'step6-24-inactive': { content: "" }, + 'step7-12-active': { content: "" }, + 'step7-12-inactive': { content: "" }, + 'step7-16-active': { content: "" }, + 'step7-16-inactive': { content: "" }, + 'step7-24-active': { content: "" }, + 'step7-24-inactive': { content: "" }, + 'step8-12-active': { content: "" }, + 'step8-12-inactive': { content: "" }, + 'step8-16-active': { content: "" }, + 'step8-16-inactive': { content: "" }, + 'step8-24-active': { content: "" }, + 'step8-24-inactive': { content: "" }, + 'step9-12-active': { content: "" }, + 'step9-12-inactive': { content: "" }, + 'step9-16-active': { content: "" }, + 'step9-16-inactive': { content: "" }, + 'step9-24-active': { content: "" }, + 'step9-24-inactive': { content: "" }, + 'strategy-12-active': { content: "" }, + 'strategy-12-inactive': { content: "" }, + 'strategy-16-active': { content: "" }, + 'strategy-16-inactive': { content: "" }, + 'strategy-24-active': { content: "" }, + 'strategy-24-inactive': { content: "" }, + 'sun-12-active': { content: "" }, + 'sun-12-inactive': { content: "" }, + 'sun-16-active': { content: "" }, + 'sun-16-inactive': { content: "" }, + 'sun-24-active': { content: "" }, + 'sun-24-inactive': { content: "" }, + 'support-12-active': { content: "" }, + 'support-12-inactive': { content: "" }, + 'support-16-active': { content: "" }, + 'support-16-inactive': { content: "" }, + 'support-24-active': { content: "" }, + 'support-24-inactive': { content: "" }, + 'tag-12-active': { content: "" }, + 'tag-12-inactive': { content: "" }, + 'tag-16-active': { content: "" }, + 'tag-16-inactive': { content: "" }, + 'tag-24-active': { content: "" }, + 'tag-24-inactive': { content: "" }, + 'taxes-12-active': { content: "" }, + 'taxes-12-inactive': { content: "" }, + 'taxes-16-active': { content: "" }, + 'taxes-16-inactive': { content: "" }, + 'taxes-24-active': { content: "" }, + 'taxes-24-inactive': { content: "" }, + 'taxesReceipt-12-active': { content: "" }, + 'taxesReceipt-12-inactive': { content: "" }, + 'taxesReceipt-16-active': { content: "" }, + 'taxesReceipt-16-inactive': { content: "" }, + 'taxesReceipt-24-active': { content: "" }, + 'taxesReceipt-24-inactive': { content: "" }, + 'telephone-12-active': { content: "" }, + 'telephone-12-inactive': { content: "" }, + 'telephone-16-active': { content: "" }, + 'telephone-16-inactive': { content: "" }, + 'telephone-24-active': { content: "" }, + 'telephone-24-inactive': { content: "" }, + 'tennis-12-active': { content: "" }, + 'tennis-12-inactive': { content: "" }, + 'tennis-16-active': { content: "" }, + 'tennis-16-inactive': { content: "" }, + 'tennis-24-active': { content: "" }, + 'tennis-24-inactive': { content: "" }, + 'test-12-active': { content: "" }, + 'test-12-inactive': { content: "" }, + 'test-16-active': { content: "" }, + 'test-16-inactive': { content: "" }, + 'test-24-active': { content: "" }, + 'test-24-inactive': { content: "" }, + 'thermometer-12-active': { content: "" }, + 'thermometer-12-inactive': { content: "" }, + 'thermometer-16-active': { content: "" }, + 'thermometer-16-inactive': { content: "" }, + 'thermometer-24-active': { content: "" }, + 'thermometer-24-inactive': { content: "" }, + 'thumbsDown-12-active': { content: "" }, + 'thumbsDown-12-inactive': { content: "" }, + 'thumbsDown-16-active': { content: "" }, + 'thumbsDown-16-inactive': { content: "" }, + 'thumbsDown-24-active': { content: "" }, + 'thumbsDown-24-inactive': { content: "" }, + 'thumbsDownOutline-12-active': { content: "" }, + 'thumbsDownOutline-12-inactive': { content: "" }, + 'thumbsDownOutline-16-active': { content: "" }, + 'thumbsDownOutline-16-inactive': { content: "" }, + 'thumbsDownOutline-24-active': { content: "" }, + 'thumbsDownOutline-24-inactive': { content: "" }, + 'thumbsUp-12-active': { content: "" }, + 'thumbsUp-12-inactive': { content: "" }, + 'thumbsUp-16-active': { content: "" }, + 'thumbsUp-16-inactive': { content: "" }, + 'thumbsUp-24-active': { content: "" }, + 'thumbsUp-24-inactive': { content: "" }, + 'thumbsUpOutline-12-active': { content: "" }, + 'thumbsUpOutline-12-inactive': { content: "" }, + 'thumbsUpOutline-16-active': { content: "" }, + 'thumbsUpOutline-16-inactive': { content: "" }, + 'thumbsUpOutline-24-active': { content: "" }, + 'thumbsUpOutline-24-inactive': { content: "" }, + 'tokenLaunchCoin-12-active': { content: "" }, + 'tokenLaunchCoin-12-inactive': { content: "" }, + 'tokenLaunchCoin-16-active': { content: "" }, + 'tokenLaunchCoin-16-inactive': { content: "" }, + 'tokenLaunchCoin-24-active': { content: "" }, + 'tokenLaunchCoin-24-inactive': { content: "" }, + 'tokenLaunchRocket-12-active': { content: "" }, + 'tokenLaunchRocket-12-inactive': { content: "" }, + 'tokenLaunchRocket-16-active': { content: "" }, + 'tokenLaunchRocket-16-inactive': { content: "" }, + 'tokenLaunchRocket-24-active': { content: "" }, + 'tokenLaunchRocket-24-inactive': { content: "" }, + 'tokenSales-12-active': { content: "" }, + 'tokenSales-12-inactive': { content: "" }, + 'tokenSales-16-active': { content: "" }, + 'tokenSales-16-inactive': { content: "" }, + 'tokenSales-24-active': { content: "" }, + 'tokenSales-24-inactive': { content: "" }, + 'tornado-12-active': { content: "" }, + 'tornado-12-inactive': { content: "" }, + 'tornado-16-active': { content: "" }, + 'tornado-16-inactive': { content: "" }, + 'tornado-24-active': { content: "" }, + 'tornado-24-inactive': { content: "" }, + 'trading-12-active': { content: "" }, + 'trading-12-inactive': { content: "" }, + 'trading-16-active': { content: "" }, + 'trading-16-inactive': { content: "" }, + 'trading-24-active': { content: "" }, + 'trading-24-inactive': { content: "" }, + 'transactions-12-active': { content: "" }, + 'transactions-12-inactive': { content: "" }, + 'transactions-16-active': { content: "" }, + 'transactions-16-inactive': { content: "" }, + 'transactions-24-active': { content: "" }, + 'transactions-24-inactive': { content: "" }, + 'trashCan-12-active': { content: "" }, + 'trashCan-12-inactive': { content: "" }, + 'trashCan-16-active': { content: "" }, + 'trashCan-16-inactive': { content: "" }, + 'trashCan-24-active': { content: "" }, + 'trashCan-24-inactive': { content: "" }, + 'trophy-12-active': { content: "" }, + 'trophy-12-inactive': { content: "" }, + 'trophy-16-active': { content: "" }, + 'trophy-16-inactive': { content: "" }, + 'trophy-24-active': { content: "" }, + 'trophy-24-inactive': { content: "" }, + 'trophyCup-12-active': { content: "" }, + 'trophyCup-12-inactive': { content: "" }, + 'trophyCup-16-active': { content: "" }, + 'trophyCup-16-inactive': { content: "" }, + 'trophyCup-24-active': { content: "" }, + 'trophyCup-24-inactive': { content: "" }, + 'tshirt-12-active': { content: "" }, + 'tshirt-12-inactive': { content: "" }, + 'tshirt-16-active': { content: "" }, + 'tshirt-16-inactive': { content: "" }, + 'tshirt-24-active': { content: "" }, + 'tshirt-24-inactive': { content: "" }, + 'tv-12-active': { content: "" }, + 'tv-12-inactive': { content: "" }, + 'tv-16-active': { content: "" }, + 'tv-16-inactive': { content: "" }, + 'tv-24-active': { content: "" }, + 'tv-24-inactive': { content: "" }, + 'tvStand-12-active': { content: "" }, + 'tvStand-12-inactive': { content: "" }, + 'tvStand-16-active': { content: "" }, + 'tvStand-16-inactive': { content: "" }, + 'tvStand-24-active': { content: "" }, + 'tvStand-24-inactive': { content: "" }, + 'twitterLogo-12-active': { content: "" }, + 'twitterLogo-12-inactive': { content: "" }, + 'twitterLogo-16-active': { content: "" }, + 'twitterLogo-16-inactive': { content: "" }, + 'twitterLogo-24-active': { content: "" }, + 'twitterLogo-24-inactive': { content: "" }, + 'ultility-12-active': { content: "" }, + 'ultility-12-inactive': { content: "" }, + 'ultility-16-active': { content: "" }, + 'ultility-16-inactive': { content: "" }, + 'ultility-24-active': { content: "" }, + 'ultility-24-inactive': { content: "" }, + 'umbrella-12-active': { content: "" }, + 'umbrella-12-inactive': { content: "" }, + 'umbrella-16-active': { content: "" }, + 'umbrella-16-inactive': { content: "" }, + 'umbrella-24-active': { content: "" }, + 'umbrella-24-inactive': { content: "" }, + 'undo-12-active': { content: "" }, + 'undo-12-inactive': { content: "" }, + 'undo-16-active': { content: "" }, + 'undo-16-inactive': { content: "" }, + 'undo-24-active': { content: "" }, + 'undo-24-inactive': { content: "" }, + 'unfollowPeople-12-active': { content: "" }, + 'unfollowPeople-12-inactive': { content: "" }, + 'unfollowPeople-16-active': { content: "" }, + 'unfollowPeople-16-inactive': { content: "" }, + 'unfollowPeople-24-active': { content: "" }, + 'unfollowPeople-24-inactive': { content: "" }, + 'unknown-12-active': { content: "" }, + 'unknown-12-inactive': { content: "" }, + 'unknown-16-active': { content: "" }, + 'unknown-16-inactive': { content: "" }, + 'unknown-24-active': { content: "" }, + 'unknown-24-inactive': { content: "" }, + 'unlock-12-active': { content: "" }, + 'unlock-12-inactive': { content: "" }, + 'unlock-16-active': { content: "" }, + 'unlock-16-inactive': { content: "" }, + 'unlock-24-active': { content: "" }, + 'unlock-24-inactive': { content: "" }, + 'upArrow-12-active': { content: "" }, + 'upArrow-12-inactive': { content: "" }, + 'upArrow-16-active': { content: "" }, + 'upArrow-16-inactive': { content: "" }, + 'upArrow-24-active': { content: "" }, + 'upArrow-24-inactive': { content: "" }, + 'upload-12-active': { content: "" }, + 'upload-12-inactive': { content: "" }, + 'upload-16-active': { content: "" }, + 'upload-16-inactive': { content: "" }, + 'upload-24-active': { content: "" }, + 'upload-24-inactive': { content: "" }, + 'venturesProduct-12-active': { content: "" }, + 'venturesProduct-12-inactive': { content: "" }, + 'venturesProduct-16-active': { content: "" }, + 'venturesProduct-16-inactive': { content: "" }, + 'venturesProduct-24-active': { content: "" }, + 'venturesProduct-24-inactive': { content: "" }, + 'verifiedBadge-12-active': { content: "" }, + 'verifiedBadge-12-inactive': { content: "" }, + 'verifiedBadge-16-active': { content: "" }, + 'verifiedBadge-16-inactive': { content: "" }, + 'verifiedBadge-24-active': { content: "" }, + 'verifiedBadge-24-inactive': { content: "" }, + 'verifiedPools-12-active': { content: "" }, + 'verifiedPools-12-inactive': { content: "" }, + 'verifiedPools-16-active': { content: "" }, + 'verifiedPools-16-inactive': { content: "" }, + 'verifiedPools-24-active': { content: "" }, + 'verifiedPools-24-inactive': { content: "" }, + 'verticalLine-12-active': { content: "" }, + 'verticalLine-12-inactive': { content: "" }, + 'verticalLine-16-active': { content: "" }, + 'verticalLine-16-inactive': { content: "" }, + 'verticalLine-24-active': { content: "" }, + 'verticalLine-24-inactive': { content: "" }, + 'virus-12-active': { content: "" }, + 'virus-12-inactive': { content: "" }, + 'virus-16-active': { content: "" }, + 'virus-16-inactive': { content: "" }, + 'virus-24-active': { content: "" }, + 'virus-24-inactive': { content: "" }, + 'visible-12-active': { content: "" }, + 'visible-12-inactive': { content: "" }, + 'visible-16-active': { content: "" }, + 'visible-16-inactive': { content: "" }, + 'visible-24-active': { content: "" }, + 'visible-24-inactive': { content: "" }, + 'waasProduct-12-active': { content: "" }, + 'waasProduct-12-inactive': { content: "" }, + 'waasProduct-16-active': { content: "" }, + 'waasProduct-16-inactive': { content: "" }, + 'waasProduct-24-active': { content: "" }, + 'waasProduct-24-inactive': { content: "" }, + 'wallet-12-active': { content: "" }, + 'wallet-12-inactive': { content: "" }, + 'wallet-16-active': { content: "" }, + 'wallet-16-inactive': { content: "" }, + 'wallet-24-active': { content: "" }, + 'wallet-24-inactive': { content: "" }, + 'walletLogo-12-active': { content: "" }, + 'walletLogo-12-inactive': { content: "" }, + 'walletLogo-16-active': { content: "" }, + 'walletLogo-16-inactive': { content: "" }, + 'walletLogo-24-active': { content: "" }, + 'walletLogo-24-inactive': { content: "" }, + 'walletProduct-12-active': { content: "" }, + 'walletProduct-12-inactive': { content: "" }, + 'walletProduct-16-active': { content: "" }, + 'walletProduct-16-inactive': { content: "" }, + 'walletProduct-24-active': { content: "" }, + 'walletProduct-24-inactive': { content: "" }, + 'warning-12-active': { content: "" }, + 'warning-12-inactive': { content: "" }, + 'warning-16-active': { content: "" }, + 'warning-16-inactive': { content: "" }, + 'warning-24-active': { content: "" }, + 'warning-24-inactive': { content: "" }, + 'webhooks-12-active': { content: "" }, + 'webhooks-12-inactive': { content: "" }, + 'webhooks-16-active': { content: "" }, + 'webhooks-16-inactive': { content: "" }, + 'webhooks-24-active': { content: "" }, + 'webhooks-24-inactive': { content: "" }, + 'wellness-12-active': { content: "" }, + 'wellness-12-inactive': { content: "" }, + 'wellness-16-active': { content: "" }, + 'wellness-16-inactive': { content: "" }, + 'wellness-24-active': { content: "" }, + 'wellness-24-inactive': { content: "" }, + 'wifi-12-active': { content: "" }, + 'wifi-12-inactive': { content: "" }, + 'wifi-16-active': { content: "" }, + 'wifi-16-inactive': { content: "" }, + 'wifi-24-active': { content: "" }, + 'wifi-24-inactive': { content: "" }, + 'wind-12-active': { content: "" }, + 'wind-12-inactive': { content: "" }, + 'wind-16-active': { content: "" }, + 'wind-16-inactive': { content: "" }, + 'wind-24-active': { content: "" }, + 'wind-24-inactive': { content: "" }, + 'wireTransfer-12-active': { content: "" }, + 'wireTransfer-12-inactive': { content: "" }, + 'wireTransfer-16-active': { content: "" }, + 'wireTransfer-16-inactive': { content: "" }, + 'wireTransfer-24-active': { content: "" }, + 'wireTransfer-24-inactive': { content: "" }, + 'withdraw-12-active': { content: "" }, + 'withdraw-12-inactive': { content: "" }, + 'withdraw-16-active': { content: "" }, + 'withdraw-16-inactive': { content: "" }, + 'withdraw-24-active': { content: "" }, + 'withdraw-24-inactive': { content: "" }, + 'wrapToken-12-active': { content: "" }, + 'wrapToken-12-inactive': { content: "" }, + 'wrapToken-16-active': { content: "" }, + 'wrapToken-16-inactive': { content: "" }, + 'wrapToken-24-active': { content: "" }, + 'wrapToken-24-inactive': { content: "" }, + 'xLogo-12-active': { content: "" }, + 'xLogo-12-inactive': { content: "" }, + 'xLogo-16-active': { content: "" }, + 'xLogo-16-inactive': { content: "" }, + 'xLogo-24-active': { content: "" }, + 'xLogo-24-inactive': { content: "" }, +} as const; + +export type SvgMapEntry = { content: string }; +export type SvgMap = Record; +export type SvgKey = keyof typeof svgMap; + +export default svgMap; diff --git a/apps/test-expo/src/hooks/useFonts.ts b/apps/test-expo/src/hooks/useFonts.ts new file mode 100644 index 0000000000..dbd71e608a --- /dev/null +++ b/apps/test-expo/src/hooks/useFonts.ts @@ -0,0 +1,32 @@ +import { Inter_400Regular } from '@expo-google-fonts/inter/400Regular'; +import { Inter_600SemiBold } from '@expo-google-fonts/inter/600SemiBold'; +import { useFonts as useFontsInter } from '@expo-google-fonts/inter/useFonts'; +import { SourceCodePro_400Regular } from '@expo-google-fonts/source-code-pro/400Regular'; +import { SourceCodePro_600SemiBold } from '@expo-google-fonts/source-code-pro/600SemiBold'; +import { useFonts as useFontsSourceCodePro } from '@expo-google-fonts/source-code-pro/useFonts'; +import { useFonts as useFontsExpo } from 'expo-font'; + +const localFonts = { + CoinbaseIcons: require('@coinbase/cds-icons/fonts/native/CoinbaseIcons.ttf') as string, +}; + +const interFonts = { + Inter_400Regular, + Inter_600SemiBold, +}; + +const sourceCodeProFonts = { + SourceCodePro_400Regular, + SourceCodePro_600SemiBold, +}; + +export function useFonts() { + const [loadedLocal, errorLocal] = useFontsExpo(localFonts); + const [loadedInter, errorInter] = useFontsInter(interFonts); + const [loadedSourceCodePro, errorSourceCodePro] = useFontsSourceCodePro(sourceCodeProFonts); + + return [ + loadedLocal && loadedInter && loadedSourceCodePro, + errorLocal || errorInter || errorSourceCodePro, + ]; +} diff --git a/apps/test-expo/src/playground/ExamplesListScreen.tsx b/apps/test-expo/src/playground/ExamplesListScreen.tsx new file mode 100644 index 0000000000..9217a0b51f --- /dev/null +++ b/apps/test-expo/src/playground/ExamplesListScreen.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useContext } from 'react'; +import { FlatList } from 'react-native'; +import type { ListRenderItem } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { CellSpacing } from '@coinbase/cds-mobile/cells/Cell'; +import { ListCell } from '@coinbase/cds-mobile/cells/ListCell'; +import { Box } from '@coinbase/cds-mobile/layout/Box'; +import { useNavigation } from '@react-navigation/native'; + +import { SearchFilterContext } from './ExamplesSearchProvider'; +import { keyToRouteName } from './keyToRouteName'; +import type { ExamplesListScreenProps } from './types'; + +const initialRouteKey = 'Examples'; +const searchRouteKey = 'Search'; + +const innerSpacingConfig: CellSpacing = { paddingX: 1 }; + +export function ExamplesListScreen({ route }: ExamplesListScreenProps) { + const searchFilter = useContext(SearchFilterContext); + const routeKeys = route.params?.routeKeys ?? []; + const { navigate } = useNavigation(); + const { bottom } = useSafeAreaInsets(); + + const renderItem: ListRenderItem = useCallback( + ({ item }) => { + const handlePress = () => { + navigate(keyToRouteName(item) as never); + }; + + return ( + + ); + }, + [navigate], + ); + + const data = [...routeKeys, 'IconSheet'] + .sort() + .filter((key) => key !== initialRouteKey && key !== searchRouteKey) + .filter((key) => { + if (searchFilter !== '') { + return key.toLowerCase().includes(searchFilter.toLowerCase()); + } + return true; + }); + + return ( + + + + ); +} diff --git a/apps/test-expo/src/playground/ExamplesSearchProvider.tsx b/apps/test-expo/src/playground/ExamplesSearchProvider.tsx new file mode 100644 index 0000000000..e274e5dc2c --- /dev/null +++ b/apps/test-expo/src/playground/ExamplesSearchProvider.tsx @@ -0,0 +1,18 @@ +import React, { useState } from 'react'; + +export const SearchFilterContext = React.createContext(''); +export const SetSearchFilterContext = React.createContext< + React.Dispatch> +>(() => {}); + +export const ExamplesSearchProvider: React.FC> = ({ + children, +}) => { + const [filter, setFilter] = useState(''); + + return ( + + {children} + + ); +}; diff --git a/apps/test-expo/src/playground/IconSheetScreen.tsx b/apps/test-expo/src/playground/IconSheetScreen.tsx new file mode 100644 index 0000000000..6b5505b4ec --- /dev/null +++ b/apps/test-expo/src/playground/IconSheetScreen.tsx @@ -0,0 +1,41 @@ +import { SvgXml } from 'react-native-svg'; +import type { IconSourcePixelSize } from '@coinbase/cds-common/types'; +import { useTheme } from '@coinbase/cds-mobile'; +import { IconSheet } from '@coinbase/cds-mobile/icons/__stories__/IconSheet'; + +import { svgMap } from '../__generated__/iconSvgMap'; + +const getIconSourceSize = (iconSize: number): IconSourcePixelSize => { + if (iconSize <= 12) return 12; + if (iconSize <= 16) return 16; + return 24; +}; + +export function IconSheetScreen() { + const theme = useTheme(); + return ( + { + const size = theme.iconSize[iconSize]; + const sourceSize = getIconSourceSize(size); + const key = `${iconName}-${sourceSize}-inactive`; + + if (!(key in svgMap)) { + throw new Error( + `Icon ${key} not found in iconSvgMap. You probably need to run the generateIconSvgMap script to update it.`, + ); + } + + return ( + + ); + }} + /> + ); +} diff --git a/apps/test-expo/src/playground/Playground.tsx b/apps/test-expo/src/playground/Playground.tsx new file mode 100644 index 0000000000..711269f195 --- /dev/null +++ b/apps/test-expo/src/playground/Playground.tsx @@ -0,0 +1,237 @@ +import React, { memo, useContext, useMemo } from 'react'; +import type { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { ColorScheme } from '@coinbase/cds-common/core/theme'; +import { IconButton } from '@coinbase/cds-mobile/buttons/IconButton'; +import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'; +import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { Box } from '@coinbase/cds-mobile/layout/Box'; +import { HStack } from '@coinbase/cds-mobile/layout/HStack'; +import { Spacer } from '@coinbase/cds-mobile/layout/Spacer'; +import { Text } from '@coinbase/cds-mobile/typography/Text'; +import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import { ExamplesListScreen } from './ExamplesListScreen'; +import { + ExamplesSearchProvider, + SearchFilterContext, + SetSearchFilterContext, +} from './ExamplesSearchProvider'; +import { IconSheetScreen } from './IconSheetScreen'; +import { keyToRouteName } from './keyToRouteName'; +import type { PlaygroundRoute } from './PlaygroundRoute'; +import type { PlaygroundStackParamList } from './types'; + +const initialRouteName = keyToRouteName('Examples'); +const searchRouteName = keyToRouteName('Search'); + +const Stack = createNativeStackNavigator(); + +const titleOverrides: Record = { + Examples: 'CDS', + Text: 'Text (all)', +}; + +type PlaygroundProps = { + routes?: PlaygroundRoute[]; + listScreenTitle?: string; + setColorScheme?: React.Dispatch>; +}; + +type HeaderProps = { + isSearch: boolean; + showBackButton: boolean; + showSearch: boolean; + title: string; + onGoBack: () => void; + onGoBackFromSearch: () => void; + onGoToSearch: () => void; + onToggleTheme: () => void; + onSearchChange: (e: NativeSyntheticEvent) => void; + searchFilter: string; + isDark: boolean; +}; + +const HeaderContent = memo( + ({ + isSearch, + showBackButton, + showSearch, + title, + onGoBack, + onGoBackFromSearch, + onGoToSearch, + onToggleTheme, + onSearchChange, + searchFilter, + isDark, + }: HeaderProps) => { + const { top } = useSafeAreaInsets(); + const style = useMemo(() => ({ paddingTop: top }), [top]); + + const iconButtonPlaceholder = ( + + + + ); + + const leftHeaderButton = showSearch ? ( + + + + ) : showBackButton ? ( + + + + ) : ( + iconButtonPlaceholder + ); + + const rightHeaderButton = isSearch ? ( + iconButtonPlaceholder + ) : ( + + + + ); + + return ( + + + {leftHeaderButton} + + + {isSearch ? ( + } + value={searchFilter} + /> + ) : ( + + {title} + + )} + + + {rightHeaderButton} + + + ); + }, +); + +const PlaygroundContent = memo( + ({ routes = [], listScreenTitle, setColorScheme }: PlaygroundProps) => { + const theme = useTheme(); + const searchFilter = useContext(SearchFilterContext); + const setFilter = useContext(SetSearchFilterContext); + + const routeKeys = useMemo(() => routes.map(({ key }) => key), [routes]); + + const screenOptions = useMemo( + (): NativeStackNavigationOptions => ({ + headerBackTitleVisible: false, + headerStyle: { + backgroundColor: theme.color.bg, + }, + headerShadowVisible: false, + header: ({ navigation, route, options }) => { + const routeName = route.name; + const isSearch = routeName === searchRouteName; + const showSearch = routeName === initialRouteName; + const canGoBack = navigation.canGoBack(); + const isFocused = navigation.isFocused(); + const showBackButton = isFocused && canGoBack && !isSearch; + + const handleGoBack = () => navigation.goBack(); + const handleGoBackFromSearch = () => { + setFilter(''); + navigation.goBack(); + }; + const handleGoToSearch = () => navigation.navigate(searchRouteName); + const handleToggleTheme = () => + setColorScheme?.((s) => (s === 'dark' ? 'light' : 'dark')); + const handleSearchChange = (e: NativeSyntheticEvent) => + setFilter(e.nativeEvent.text); + + return ( + + ); + }, + }), + [theme.color.bg, theme.activeColorScheme, searchFilter, setFilter, setColorScheme], + ); + + const exampleScreens = useMemo( + () => + [...routes].map((route) => { + const { key, getComponent } = route; + const name = keyToRouteName(key); + const title = titleOverrides[key] ?? key; + return ( + } + name={name} + options={{ title }} + /> + ); + }), + [routes], + ); + + return ( + + + + + {exampleScreens} + + ); + }, +); + +export const Playground = memo((props: PlaygroundProps) => { + return ( + + + + ); +}); diff --git a/apps/test-expo/src/playground/PlaygroundRoute.ts b/apps/test-expo/src/playground/PlaygroundRoute.ts new file mode 100644 index 0000000000..6ce4a62e0c --- /dev/null +++ b/apps/test-expo/src/playground/PlaygroundRoute.ts @@ -0,0 +1,6 @@ +import type React from 'react'; + +export type PlaygroundRoute = { + key: string; + getComponent: () => React.ComponentType; +}; diff --git a/apps/test-expo/src/playground/index.ts b/apps/test-expo/src/playground/index.ts new file mode 100644 index 0000000000..b163092b89 --- /dev/null +++ b/apps/test-expo/src/playground/index.ts @@ -0,0 +1,2 @@ +export { Playground } from './Playground'; +export type { PlaygroundRoute } from './PlaygroundRoute'; diff --git a/apps/test-expo/src/playground/keyToRouteName.ts b/apps/test-expo/src/playground/keyToRouteName.ts new file mode 100644 index 0000000000..7629a56ae9 --- /dev/null +++ b/apps/test-expo/src/playground/keyToRouteName.ts @@ -0,0 +1,3 @@ +export function keyToRouteName(key: string) { + return `Debug${key}` as const; +} diff --git a/apps/test-expo/src/playground/types.ts b/apps/test-expo/src/playground/types.ts new file mode 100644 index 0000000000..a62d3d9f05 --- /dev/null +++ b/apps/test-expo/src/playground/types.ts @@ -0,0 +1,28 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; + +type RouteParams = { routeKeys: string[] } | undefined; + +export type PlaygroundStackParamList = { + DebugExamples: { routeKeys: string[] }; + DebugSearch: { routeKeys: string[] }; + DebugIconSheet: undefined; +} & { + [key: string]: RouteParams; +}; + +export type ExamplesListScreenProps = NativeStackScreenProps< + PlaygroundStackParamList, + 'DebugExamples' | 'DebugSearch' +>; + +export type IconSheetScreenProps = NativeStackScreenProps< + PlaygroundStackParamList, + 'DebugIconSheet' +>; + +declare global { + namespace ReactNavigation { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-empty-object-type + interface RootParamList extends PlaygroundStackParamList {} + } +} diff --git a/apps/test-expo/src/routes.ts b/apps/test-expo/src/routes.ts new file mode 100644 index 0000000000..c6db612783 --- /dev/null +++ b/apps/test-expo/src/routes.ts @@ -0,0 +1,879 @@ +/** + * DO NOT MODIFY + * Generated from scripts/codegen/main.ts + */ +export const routes = [ + { + key: 'Accordion', + getComponent: () => + require('@coinbase/cds-mobile/accordion/__stories__/Accordion.stories').default, + }, + { + key: 'AlertBasic', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/AlertBasic.stories').default, + }, + { + key: 'AlertLongTitle', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/AlertLongTitle.stories').default, + }, + { + key: 'AlertOverModal', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/AlertOverModal.stories').default, + }, + { + key: 'AlertPortal', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/AlertPortal.stories').default, + }, + { + key: 'AlertSingleAction', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/AlertSingleAction.stories').default, + }, + { + key: 'AlertVerticalActions', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, + }, + { + key: 'AlphaSelect', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, + }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, + { + key: 'AlphaTabbedChips', + getComponent: () => + require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') + .default, + }, + { + key: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, + { + key: 'AnimatedCaret', + getComponent: () => + require('@coinbase/cds-mobile/motion/__stories__/AnimatedCaret.stories').default, + }, + { + key: 'AreaChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/area/__stories__/AreaChart.stories') + .default, + }, + { + key: 'Avatar', + getComponent: () => require('@coinbase/cds-mobile/media/__stories__/Avatar.stories').default, + }, + { + key: 'AvatarButton', + getComponent: () => + require('@coinbase/cds-mobile/buttons/__stories__/AvatarButton.stories').default, + }, + { + key: 'Axis', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/axis/__stories__/Axis.stories').default, + }, + { + key: 'Banner', + getComponent: () => require('@coinbase/cds-mobile/banner/__stories__/Banner.stories').default, + }, + { + key: 'BannerActions', + getComponent: () => + require('@coinbase/cds-mobile/banner/__stories__/BannerActions.stories').default, + }, + { + key: 'BannerLayout', + getComponent: () => + require('@coinbase/cds-mobile/banner/__stories__/BannerLayout.stories').default, + }, + { + key: 'BarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/BarChart.stories').default, + }, + { + key: 'Box', + getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Box.stories').default, + }, + { + key: 'BrowserBar', + getComponent: () => + require('@coinbase/cds-mobile/navigation/__stories__/BrowserBar.stories').default, + }, + { + key: 'BrowserBarSearchInput', + getComponent: () => + require('@coinbase/cds-mobile/navigation/__stories__/BrowserBarSearchInput.stories').default, + }, + { + key: 'Button', + getComponent: () => require('@coinbase/cds-mobile/buttons/__stories__/Button.stories').default, + }, + { + key: 'ButtonGroup', + getComponent: () => + require('@coinbase/cds-mobile/buttons/__stories__/ButtonGroup.stories').default, + }, + { + key: 'Card', + getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/Card.stories').default, + }, + { + key: 'Carousel', + getComponent: () => + require('@coinbase/cds-mobile/carousel/__stories__/Carousel.stories').default, + }, + { + key: 'CarouselMedia', + getComponent: () => + require('@coinbase/cds-mobile/media/__stories__/CarouselMedia.stories').default, + }, + { + key: 'CartesianChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/CartesianChart.stories') + .default, + }, + { + key: 'ChartTransitions', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, + }, + { + key: 'Checkbox', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/Checkbox.stories').default, + }, + { + key: 'CheckboxCell', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/CheckboxCell.stories').default, + }, + { + key: 'Chip', + getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/Chip.stories').default, + }, + { + key: 'Coachmark', + getComponent: () => + require('@coinbase/cds-mobile/coachmark/__stories__/Coachmark.stories').default, + }, + { + key: 'Collapsible', + getComponent: () => + require('@coinbase/cds-mobile/collapsible/__stories__/Collapsible.stories').default, + }, + { + key: 'Combobox', + getComponent: () => + require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, + }, + { + key: 'ContainedAssetCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/ContainedAssetCard.stories').default, + }, + { + key: 'ContentCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/ContentCard.stories').default, + }, + { + key: 'ContentCell', + getComponent: () => + require('@coinbase/cds-mobile/cells/__stories__/ContentCell.stories').default, + }, + { + key: 'ContentCellFallback', + getComponent: () => + require('@coinbase/cds-mobile/cells/__stories__/ContentCellFallback.stories').default, + }, + { + key: 'ControlGroup', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, + }, + { + key: 'DataCard', + getComponent: () => + require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, + }, + { + key: 'DateInput', + getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, + }, + { + key: 'DatePicker', + getComponent: () => + require('@coinbase/cds-mobile/dates/__stories__/DatePicker.stories').default, + }, + { + key: 'Divider', + getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Divider.stories').default, + }, + { + key: 'Dot', + getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/Dot.stories').default, + }, + { + key: 'DotMisc', + getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/DotMisc.stories').default, + }, + { + key: 'DrawerBottom', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerBottom.stories').default, + }, + { + key: 'DrawerFallback', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerFallback.stories').default, + }, + { + key: 'DrawerLeft', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerLeft.stories').default, + }, + { + key: 'DrawerMisc', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerMisc.stories').default, + }, + { + key: 'DrawerReduceMotion', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerReduceMotion.stories').default, + }, + { + key: 'DrawerRight', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerRight.stories').default, + }, + { + key: 'DrawerScrollable', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerScrollable.stories').default, + }, + { + key: 'DrawerTop', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerTop.stories').default, + }, + { + key: 'FloatingAssetCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/FloatingAssetCard.stories').default, + }, + { + key: 'Frontier', + getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Frontier.stories').default, + }, + { + key: 'Group', + getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Group.stories').default, + }, + { + key: 'HeroSquare', + getComponent: () => + require('@coinbase/cds-mobile/illustrations/__stories__/HeroSquare.stories').default, + }, + { + key: 'HintMotion', + getComponent: () => + require('@coinbase/cds-mobile/motion/__stories__/HintMotion.stories').default, + }, + { + key: 'IconButton', + getComponent: () => + require('@coinbase/cds-mobile/buttons/__stories__/IconButton.stories').default, + }, + { + key: 'IconCounterButton', + getComponent: () => + require('@coinbase/cds-mobile/buttons/__stories__/IconCounterButton.stories').default, + }, + { + key: 'InputChip', + getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/InputChip.stories').default, + }, + { + key: 'InputIcon', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/InputIcon.stories').default, + }, + { + key: 'InputIconButton', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/InputIconButton.stories').default, + }, + { + key: 'InputStack', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, + }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, + { + key: 'LinearGradient', + getComponent: () => + require('@coinbase/cds-mobile/gradients/__stories__/LinearGradient.stories').default, + }, + { + key: 'LineChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/line/__stories__/LineChart.stories') + .default, + }, + { + key: 'Link', + getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Link.stories').default, + }, + { + key: 'ListCell', + getComponent: () => require('@coinbase/cds-mobile/cells/__stories__/ListCell.stories').default, + }, + { + key: 'ListCellFallback', + getComponent: () => + require('@coinbase/cds-mobile/cells/__stories__/ListCellFallback.stories').default, + }, + { + key: 'Logo', + getComponent: () => require('@coinbase/cds-mobile/icons/__stories__/Logo.stories').default, + }, + { + key: 'Lottie', + getComponent: () => + require('@coinbase/cds-mobile/animation/__stories__/Lottie.stories').default, + }, + { + key: 'LottieStatusAnimation', + getComponent: () => + require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default, + }, + { + key: 'MediaCard', + getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default, + }, + { + key: 'MediaChip', + getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default, + }, + { + key: 'MessagingCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, + }, + { + key: 'ModalBackButton', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/ModalBackButton.stories').default, + }, + { + key: 'ModalBasic', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/ModalBasic.stories').default, + }, + { + key: 'ModalLong', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/ModalLong.stories').default, + }, + { + key: 'ModalPortal', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/ModalPortal.stories').default, + }, + { + key: 'MultiContentModule', + getComponent: () => + require('@coinbase/cds-mobile/multi-content-module/__stories__/MultiContentModule.stories') + .default, + }, + { + key: 'NavBarIconButton', + getComponent: () => + require('@coinbase/cds-mobile/navigation/__stories__/NavBarIconButton.stories').default, + }, + { + key: 'NavigationSubtitle', + getComponent: () => + require('@coinbase/cds-mobile/navigation/__stories__/NavigationSubtitle.stories').default, + }, + { + key: 'NavigationTitle', + getComponent: () => + require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitle.stories').default, + }, + { + key: 'NavigationTitleSelect', + getComponent: () => + require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitleSelect.stories').default, + }, + { + key: 'NudgeCard', + getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/NudgeCard.stories').default, + }, + { + key: 'Numpad', + getComponent: () => require('@coinbase/cds-mobile/numpad/__stories__/Numpad.stories').default, + }, + { + key: 'Overlay', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/Overlay.stories').default, + }, + { + key: 'PageFooter', + getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageFooter.stories').default, + }, + { + key: 'PageFooterInPage', + getComponent: () => + require('@coinbase/cds-mobile/page/__stories__/PageFooterInPage.stories').default, + }, + { + key: 'PageHeader', + getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageHeader.stories').default, + }, + { + key: 'PageHeaderInErrorEmptyState', + getComponent: () => + require('@coinbase/cds-mobile/page/__stories__/PageHeaderInErrorEmptyState.stories').default, + }, + { + key: 'PageHeaderInPage', + getComponent: () => + require('@coinbase/cds-mobile/page/__stories__/PageHeaderInPage.stories').default, + }, + { + key: 'Palette', + getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Palette.stories').default, + }, + { + key: 'PatternDisclosureHighFrictionBenefit', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionBenefit.stories') + .default, + }, + { + key: 'PatternDisclosureHighFrictionRisk', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionRisk.stories') + .default, + }, + { + key: 'PatternDisclosureLowFriction', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureLowFriction.stories') + .default, + }, + { + key: 'PatternDisclosureMedFriction', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureMedFriction.stories') + .default, + }, + { + key: 'PatternError', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, + }, + { + key: 'PeriodSelector', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/PeriodSelector.stories') + .default, + }, + { + key: 'Pictogram', + getComponent: () => + require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, + }, + { + key: 'Pressable', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/Pressable.stories').default, + }, + { + key: 'PressableOpacity', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/PressableOpacity.stories').default, + }, + { + key: 'ProgressBar', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/__stories__/ProgressBar.stories').default, + }, + { + key: 'ProgressCircle', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/__stories__/ProgressCircle.stories').default, + }, + { + key: 'RadioCell', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/RadioCell.stories').default, + }, + { + key: 'RadioGroup', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/RadioGroup.stories').default, + }, + { + key: 'ReferenceLine', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/line/__stories__/ReferenceLine.stories') + .default, + }, + { + key: 'RemoteImage', + getComponent: () => + require('@coinbase/cds-mobile/media/__stories__/RemoteImage.stories').default, + }, + { + key: 'RemoteImageGroup', + getComponent: () => + require('@coinbase/cds-mobile/media/__stories__/RemoteImageGroup.stories').default, + }, + { + key: 'RollingNumber', + getComponent: () => + require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, + }, + { + key: 'Scrubber', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') + .default, + }, + { + key: 'SearchInput', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/SearchInput.stories').default, + }, + { + key: 'SectionHeader', + getComponent: () => + require('@coinbase/cds-mobile/section-header/__stories__/SectionHeader.stories').default, + }, + { + key: 'SegmentedTabs', + getComponent: () => + require('@coinbase/cds-mobile/tabs/__stories__/SegmentedTabs.stories').default, + }, + { + key: 'Select', + getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Select.stories').default, + }, + { + key: 'SelectChip', + getComponent: () => + require('@coinbase/cds-mobile/chips/__stories__/SelectChip.stories').default, + }, + { + key: 'SelectOption', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/SelectOption.stories').default, + }, + { + key: 'SlideButton', + getComponent: () => + require('@coinbase/cds-mobile/buttons/__stories__/SlideButton.stories').default, + }, + { + key: 'Spacer', + getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Spacer.stories').default, + }, + { + key: 'Sparkline', + getComponent: () => + require('@coinbase/cds-mobile-visualization/sparkline/__stories__/Sparkline.stories').default, + }, + { + key: 'SparklineGradient', + getComponent: () => + require('@coinbase/cds-mobile-visualization/sparkline/__stories__/SparklineGradient.stories') + .default, + }, + { + key: 'SparklineInteractive', + getComponent: () => + require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') + .default, + }, + { + key: 'SparklineInteractiveHeader', + getComponent: () => + require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') + .default, + }, + { + key: 'Spectrum', + getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Spectrum.stories').default, + }, + { + key: 'Spinner', + getComponent: () => require('@coinbase/cds-mobile/loaders/__stories__/Spinner.stories').default, + }, + { + key: 'SpotIcon', + getComponent: () => + require('@coinbase/cds-mobile/illustrations/__stories__/SpotIcon.stories').default, + }, + { + key: 'SpotRectangle', + getComponent: () => + require('@coinbase/cds-mobile/illustrations/__stories__/SpotRectangle.stories').default, + }, + { + key: 'SpotSquare', + getComponent: () => + require('@coinbase/cds-mobile/illustrations/__stories__/SpotSquare.stories').default, + }, + { + key: 'StepperHorizontal', + getComponent: () => + require('@coinbase/cds-mobile/stepper/__stories__/StepperHorizontal.stories').default, + }, + { + key: 'StepperVertical', + getComponent: () => + require('@coinbase/cds-mobile/stepper/__stories__/StepperVertical.stories').default, + }, + { + key: 'StickyFooter', + getComponent: () => + require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooter.stories').default, + }, + { + key: 'StickyFooterWithTray', + getComponent: () => + require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooterWithTray.stories') + .default, + }, + { + key: 'Switch', + getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Switch.stories').default, + }, + { + key: 'TabbedChips', + getComponent: () => + require('@coinbase/cds-mobile/chips/__stories__/TabbedChips.stories').default, + }, + { + key: 'TabIndicator', + getComponent: () => + require('@coinbase/cds-mobile/tabs/__stories__/TabIndicator.stories').default, + }, + { + key: 'TabLabel', + getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/TabLabel.stories').default, + }, + { + key: 'TabNavigation', + getComponent: () => + require('@coinbase/cds-mobile/tabs/__stories__/TabNavigation.stories').default, + }, + { + key: 'Tabs', + getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/Tabs.stories').default, + }, + { + key: 'Tag', + getComponent: () => require('@coinbase/cds-mobile/tag/__stories__/Tag.stories').default, + }, + { + key: 'Text', + getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Text.stories').default, + }, + { + key: 'TextBody', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextBody.stories').default, + }, + { + key: 'TextCaption', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextCaption.stories').default, + }, + { + key: 'TextCore', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextCore.stories').default, + }, + { + key: 'TextDisplay1', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextDisplay1.stories').default, + }, + { + key: 'TextDisplay2', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextDisplay2.stories').default, + }, + { + key: 'TextDisplay3', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextDisplay3.stories').default, + }, + { + key: 'TextHeadline', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextHeadline.stories').default, + }, + { + key: 'TextInput', + getComponent: () => + require('@coinbase/cds-mobile/controls/__stories__/TextInput.stories').default, + }, + { + key: 'TextLabel1', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextLabel1.stories').default, + }, + { + key: 'TextLabel2', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextLabel2.stories').default, + }, + { + key: 'TextLegal', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextLegal.stories').default, + }, + { + key: 'TextTitle1', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextTitle1.stories').default, + }, + { + key: 'TextTitle2', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextTitle2.stories').default, + }, + { + key: 'TextTitle3', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextTitle3.stories').default, + }, + { + key: 'TextTitle4', + getComponent: () => + require('@coinbase/cds-mobile/typography/__stories__/TextTitle4.stories').default, + }, + { + key: 'ThemeProvider', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/ThemeProvider.stories').default, + }, + { + key: 'Toast', + getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/Toast.stories').default, + }, + { + key: 'TooltipV2', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TooltipV2.stories').default, + }, + { + key: 'TopNavBar', + getComponent: () => + require('@coinbase/cds-mobile/navigation/__stories__/TopNavBar.stories').default, + }, + { + key: 'Tour', + getComponent: () => require('@coinbase/cds-mobile/tour/__stories__/Tour.stories').default, + }, + { + key: 'TrayAction', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayAction.stories').default, + }, + { + key: 'TrayBasic', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayBasic.stories').default, + }, + { + key: 'TrayFallback', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayFallback.stories').default, + }, + { + key: 'TrayFeedCard', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, + }, + { + key: 'TrayInformational', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayInformational.stories').default, + }, + { + key: 'TrayMessaging', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayMessaging.stories').default, + }, + { + key: 'TrayMisc', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayMisc.stories').default, + }, + { + key: 'TrayNavigation', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayNavigation.stories').default, + }, + { + key: 'TrayPromotional', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayPromotional.stories').default, + }, + { + key: 'TrayRedesign', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayRedesign.stories').default, + }, + { + key: 'TrayReduceMotion', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayReduceMotion.stories').default, + }, + { + key: 'TrayScrollable', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayScrollable.stories').default, + }, + { + key: 'TrayTall', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayTall.stories').default, + }, + { + key: 'TrayWithTitle', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayWithTitle.stories').default, + }, + { + key: 'UpsellCard', + getComponent: () => + require('@coinbase/cds-mobile/cards/__stories__/UpsellCard.stories').default, + }, +]; diff --git a/apps/test-expo/tsconfig.json b/apps/test-expo/tsconfig.json new file mode 100644 index 0000000000..b1ea9e2cb7 --- /dev/null +++ b/apps/test-expo/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../tsconfig.project.json", + "compilerOptions": { + "declarationDir": "dts", + "jsx": "react-native", + "resolveJsonModule": true, + "moduleSuffixes": [ + ".ios", + ".android", + ".native", + "" + ] + }, + "include": [ + "src/**/*", + "*.tsx", + "*.config.js", + "*.config.ts", + "*.json" + ], + "exclude": [], + "references": [ + { + "path": "../../packages/common" + }, + { + "path": "../../packages/mobile" + }, + { + "path": "../../packages/icons" + }, + { + "path": "../../packages/illustrations" + }, + { + "path": "../../packages/mobile-visualization" + } + ] +} diff --git a/apps/vite-app/index.html b/apps/vite-app/index.html index c19f9c4a27..a14497510e 100644 --- a/apps/vite-app/index.html +++ b/apps/vite-app/index.html @@ -2,7 +2,6 @@ - CDS Vite App diff --git a/apps/vite-app/package.json b/apps/vite-app/package.json index c8f9a300e1..cc9c5f37c8 100644 --- a/apps/vite-app/package.json +++ b/apps/vite-app/package.json @@ -15,15 +15,16 @@ "@coinbase/cds-web": "workspace:^", "@coinbase/cds-web-visualization": "workspace:^", "framer-motion": "^10.18.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "19.1.2", + "react-dom": "19.1.2" }, "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.0.0", + "@coinbase/cds-migrator": "workspace:^", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "^5.1.2", "typescript": "~5.9.2", - "vite": "^7.1.2" + "vite": "^7.3.1" }, "packageManager": "yarn@4.7.0" } diff --git a/apps/vite-app/src/components/CardList/ETHStakingCard.tsx b/apps/vite-app/src/components/CardList/ETHStakingCard.tsx index 4d976dbebb..b33bda12f7 100644 --- a/apps/vite-app/src/components/CardList/ETHStakingCard.tsx +++ b/apps/vite-app/src/components/CardList/ETHStakingCard.tsx @@ -7,7 +7,6 @@ export const ETHStakingCard = () => { return ( Earn staking rewards on ETH by holding it on Coinbase @@ -18,6 +17,7 @@ export const ETHStakingCard = () => { } + style={{ backgroundColor: 'rgb(var(--purple70))' }} title={ Up to 3.29% APR on ETHs diff --git a/apps/vite-app/tsconfig.app.json b/apps/vite-app/tsconfig.app.json index e61b84d78c..99d8fc062e 100644 --- a/apps/vite-app/tsconfig.app.json +++ b/apps/vite-app/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.project.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, "noEmit": true, "noUncheckedSideEffectImports": true diff --git a/eslint.config.mjs b/eslint.config.mjs index ec60857091..3166c7a68f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,6 +31,7 @@ const ignores = [ '**/esm/**', '**/lib/**', '**/templates/**', + '**/__testfixtures__/**', '**/.next/**', // These files use assert { type: 'json' } syntax that breaks eslint and must be fully ignored '**/getAffectedRoutes.mjs', @@ -45,10 +46,22 @@ const ignores = [ 'libs/docusaurus-plugin-docgen/module-declarations.d.ts', ]; +// TODO (CDS-1412): Fix these react-hooks rule violations and re-enable them +const disabledNewReactHooksRules = { + 'react-hooks/immutability': 'off', + 'react-hooks/purity': 'off', + 'react-hooks/refs': 'off', + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/set-state-in-render': 'off', + 'react-hooks/static-components': 'off', + 'react-hooks/preserve-manual-memoization': 'off', +}; + // These rules apply to all files const sharedRules = { 'internal/no-object-rest-spread-in-worklet': 'error', 'internal/deprecated-jsdoc-has-removal-version': 'error', + 'internal/spread-props-last': 'warn', 'import/default': 'off', 'import/extensions': 'off', 'import/named': 'off', @@ -67,6 +80,16 @@ const sharedRules = { message: 'Do not import `cx` from Linaria. Use the `cx` function from @coinbase/cds-web instead.', }, + { + name: 'react-popper', + message: + 'Do not import `react-popper` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + { + name: '@popperjs/core', + message: + 'Do not import `@popperjs/core` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, ], patterns: [ { @@ -136,6 +159,96 @@ const sharedRules = { ], 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'off', + ...disabledNewReactHooksRules, +}; + +// React 19 introduced new APIs that do not exist in React 18. +// CDS must remain compatible with React 18 consumers, so we restrict these imports +// in all publishable packages. The `no-restricted-imports` rule in this object +// is a superset of the one in `sharedRules` to avoid flat-config override issues. +const react19CompatibilityRules = { + 'no-restricted-imports': [ + 'error', + { + paths: [ + // Existing restrictions (duplicated because flat config replaces, not merges) + { + name: '@linaria/core', + importNames: ['cx'], + message: + 'Do not import `cx` from Linaria. Use the `cx` function from @coinbase/cds-web instead.', + }, + { + name: 'react-popper', + message: + 'Do not import `react-popper` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + { + name: '@popperjs/core', + message: + 'Do not import `@popperjs/core` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + // React 19-only runtime APIs + { + name: 'react', + importNames: ['cache', 'captureOwnerStack', 'use', 'useActionState', 'useOptimistic'], + message: + 'This is a React 19-only API. CDS must remain compatible with React 18 consumers.', + }, + // React 19-only types (would break .d.ts output for React 18 consumers) + { + name: 'react', + importNames: ['ActionDispatch', 'AnyActionArg', 'AwaitedReactNode', 'Usable'], + message: + 'This is a React 19-only type. CDS must remain compatible with React 18 consumers.', + }, + // React DOM 19-only runtime APIs + { + name: 'react-dom', + importNames: [ + 'preconnect', + 'prefetchDNS', + 'preinit', + 'preinitModule', + 'preload', + 'preloadModule', + 'requestFormReset', + 'useFormState', + 'useFormStatus', + ], + message: + 'This is a React 19-only API. CDS must remain compatible with React 18 consumers.', + }, + // React DOM 19-only types + { + name: 'react-dom', + importNames: [ + 'FormStatus', + 'FormStatusNotPending', + 'FormStatusPending', + 'PreconnectOptions', + 'PreinitAs', + 'PreinitModuleAs', + 'PreinitModuleOptions', + 'PreinitOptions', + 'PreloadAs', + 'PreloadModuleAs', + 'PreloadModuleOptions', + 'PreloadOptions', + ], + message: + 'This is a React 19-only type. CDS must remain compatible with React 18 consumers.', + }, + ], + patterns: [ + { + group: ['*/booleanStyles', '*/responsive/*'], + message: + 'Do not import these styles directly, as it will cause non-deterministic CSS generation. Use the `getStyles` function from @coinbase/cds-web/styles/styleProps.ts or the component StyleProps API instead.', + }, + ], + }, + ], }; // These rules only apply to TS/TSX files in packages/**, and do not apply to stories or tests @@ -169,6 +282,7 @@ const typescriptRules = { // These rules only apply to test files const testRules = { + 'internal/spread-props-last': 'off', 'jest/no-mocks-import': 'off', 'testing-library/await-async-events': 'off', 'testing-library/await-async-queries': 'off', @@ -201,7 +315,7 @@ const sharedExtends = [ eslintJs.configs.recommended, eslintImport.flatConfigs.recommended, eslintReact.configs.flat.recommended, - eslintReactHooks.configs['recommended-latest'], + eslintReactHooks.configs.flat['recommended-latest'], eslintReactPerf.configs.flat.recommended, eslintJsxA11y.flatConfigs.recommended, ]; @@ -302,6 +416,25 @@ export default tseslint.config( ...typescriptRules, }, }, + // Restrict React 19-only APIs in publishable packages to maintain React 18 compatibility + { + files: [ + 'packages/web/**/*.{ts,tsx}', + 'packages/common/**/*.{ts,tsx}', + 'packages/mobile/**/*.{ts,tsx}', + 'packages/web-visualization/**/*.{ts,tsx}', + 'packages/mobile-visualization/**/*.{ts,tsx}', + ], + rules: { + ...react19CompatibilityRules, + }, + }, + { + files: ['**/*.stories.{js,jsx,ts,tsx}', '**/__stories__/**'], + rules: { + 'internal/spread-props-last': 'off', + }, + }, // Rules specific to mobile story files { files: ['packages/mobile/**/*.stories.tsx'], @@ -311,13 +444,16 @@ export default tseslint.config( { files: ['**/*.figma.tsx'], extends: [internalPlugin.configs.figmaConnectRules], + rules: { + 'internal/spread-props-last': 'off', + }, }, { files: ['**/*.mdx'], processor: internalPlugin.processors.mdx, }, { - files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/setup.js'], + files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/jest/**/*.js'], settings: sharedSettings, languageOptions: { globals: { diff --git a/jest.preset-mobile.js b/jest.preset-mobile.js index 7e9524b889..b732cfd97e 100644 --- a/jest.preset-mobile.js +++ b/jest.preset-mobile.js @@ -18,7 +18,7 @@ const config = { '\\.(jpg|jpeg|png|gif)$': 'identity-obj-proxy', }, setupFiles: [...reactNativePreset.setupFiles], - setupFilesAfterEnv: ['jest-extended', '@testing-library/jest-native/extend-expect'], + setupFilesAfterEnv: ['jest-extended'], testMatch: ['**/*.test.[jt]s?(x)'], testPathIgnorePatterns: [ '/node_modules/', diff --git a/libs/codegen/package.json b/libs/codegen/package.json index e936f26d08..a1e43fe203 100644 --- a/libs/codegen/package.json +++ b/libs/codegen/package.json @@ -46,7 +46,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/libs/codegen/src/playground/prepareRoutes.ts b/libs/codegen/src/playground/prepareRoutes.ts index c78803858e..3eb965402d 100644 --- a/libs/codegen/src/playground/prepareRoutes.ts +++ b/libs/codegen/src/playground/prepareRoutes.ts @@ -119,6 +119,13 @@ export async function prepare() { template: 'mobileRoutes.ejs', dest: `apps/mobile-app/scripts/utils/routes.mjs`, }); + + // Write to test-expo for Expo demo app + await writeFile({ + data: { routes: consumerRoutes }, + template: 'mobileRoutes.ejs', + dest: `apps/test-expo/src/routes.ts`, + }); } catch (err) { if (err instanceof Error) { console.log(err.message); diff --git a/libs/docusaurus-plugin-docgen/package.json b/libs/docusaurus-plugin-docgen/package.json index 5e988e810a..89d5e0da1d 100644 --- a/libs/docusaurus-plugin-docgen/package.json +++ b/libs/docusaurus-plugin-docgen/package.json @@ -46,10 +46,14 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/types": "~3.7.0", "@types/ejs": "^3.1.0", - "@types/lodash": "^4.14.178" + "@types/lodash": "^4.14.178", + "fast-glob": "^3.2.11", + "lodash": "^4.17.21", + "prettier": "^3.6.2", + "type-fest": "^2.19.0" } } diff --git a/libs/docusaurus-plugin-kbar/package.json b/libs/docusaurus-plugin-kbar/package.json index 6d32eeef4a..aa9487fbaa 100644 --- a/libs/docusaurus-plugin-kbar/package.json +++ b/libs/docusaurus-plugin-kbar/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/libs/docusaurus-plugin-llm-dev-server/package.json b/libs/docusaurus-plugin-llm-dev-server/package.json index a5140a1d2e..84bc994fb1 100644 --- a/libs/docusaurus-plugin-llm-dev-server/package.json +++ b/libs/docusaurus-plugin-llm-dev-server/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/types": "~3.7.0", "@types/express": "^4.17.21" diff --git a/libs/eslint-plugin-internal/README.md b/libs/eslint-plugin-internal/README.md index 9fdc7a32a8..553a2375a8 100644 --- a/libs/eslint-plugin-internal/README.md +++ b/libs/eslint-plugin-internal/README.md @@ -100,6 +100,16 @@ We have encountered situations where developers accidentally forgot to destructu At this time this rule is intended to only be used within this repo in the cds-web and cds-mobile packages. However, after a trial period we may consider opening it up to a wider audience. +## spread-props-last + +Requires JSX spread props that come from a component's own `props` parameter to appear after all explicit JSX props in an element. + +This helps avoid accidental prop overrides and keeps prop ordering predictable: + +- Good: ` + +
+ ); +} diff --git a/packages/migrator/src/transforms/__testfixtures__/button-variant-values/dynamic-expression.output.tsx b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/dynamic-expression.output.tsx new file mode 100644 index 0000000000..a4c66b03ec --- /dev/null +++ b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/dynamic-expression.output.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Button } from '@coinbase/cds-web'; + +type Props = { + variant: 'tertiary' | 'primary'; + isActive: boolean; +}; + +export function DynamicButton({ variant, isActive }: Props) { + return ( +
+ // TODO(cds-migration): Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. + + // TODO(cds-migration): Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. + +
+ ); +} diff --git a/packages/migrator/src/transforms/__testfixtures__/button-variant-values/mixed-variants.input.tsx b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/mixed-variants.input.tsx new file mode 100644 index 0000000000..03e235ebdd --- /dev/null +++ b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/mixed-variants.input.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Button, IconButton } from '@coinbase/cds-web'; + +export function Toolbar() { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/packages/migrator/src/transforms/__testfixtures__/button-variant-values/mixed-variants.output.tsx b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/mixed-variants.output.tsx new file mode 100644 index 0000000000..50bc138d19 --- /dev/null +++ b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/mixed-variants.output.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Button, IconButton } from '@coinbase/cds-web'; + +export function Toolbar() { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/packages/migrator/src/transforms/__testfixtures__/button-variant-values/no-cds-imports.input.tsx b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/no-cds-imports.input.tsx new file mode 100644 index 0000000000..790d62f424 --- /dev/null +++ b/packages/migrator/src/transforms/__testfixtures__/button-variant-values/no-cds-imports.input.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Button } from './components/Button'; + +export function CustomToolbar() { + return ( +
+ + +
+ ); +} diff --git a/packages/migrator/src/transforms/__tests__/button-variant-values.test.ts b/packages/migrator/src/transforms/__tests__/button-variant-values.test.ts new file mode 100644 index 0000000000..70115a5b4b --- /dev/null +++ b/packages/migrator/src/transforms/__tests__/button-variant-values.test.ts @@ -0,0 +1,186 @@ +import fs from 'fs'; +import path from 'path'; + +const { applyTransform } = require('jscodeshift/src/testUtils'); + +const transform = require('../button-variant-values'); + +const PARSER_OPTIONS = { parser: 'tsx' }; + +function applyButtonVariantTransform(source: string) { + return applyTransform(transform, {}, { source }, PARSER_OPTIONS); +} + +function readFixture(name: string) { + return fs.readFileSync( + path.join(__dirname, '..', '__testfixtures__', 'button-variant-values', `${name}.tsx`), + 'utf8', + ); +} + +describe('button-variant-values', () => { + describe('string literal rewrites', () => { + it('rewrites variant="tertiary" to variant="inverse" on Button from @coinbase/cds-web', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="inverse"'); + expect(output).not.toContain('variant="tertiary"'); + }); + + it('rewrites variant="foregroundMuted" to variant="secondary" on Button from @coinbase/cds-web', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="secondary"'); + expect(output).not.toContain('variant="foregroundMuted"'); + }); + + it('rewrites variant="tertiary" to variant="inverse" on IconButton from @coinbase/cds-mobile', () => { + const input = ` +import { IconButton } from '@coinbase/cds-mobile'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="inverse"'); + expect(output).not.toContain('variant="tertiary"'); + }); + + it('rewrites variant="foregroundMuted" to variant="secondary" on IconButton from @coinbase/cds-mobile', () => { + const input = ` +import { IconButton } from '@coinbase/cds-mobile'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="secondary"'); + expect(output).not.toContain('variant="foregroundMuted"'); + }); + }); + + describe('dynamic expressions', () => { + it('adds a TODO comment for dynamic variant expressions', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = ({ v }) => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('TODO(cds-migration)'); + expect(output).toContain('variant values changed in v9'); + }); + + it('does not add duplicate TODO if already present', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = ({ v }) => + // TODO(cds-migration): Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. + ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + }); + + describe('skipped cases', () => { + it('returns empty string for files with no CDS imports', () => { + const input = ` +import { Button } from './MyButton'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + + it('does not modify non-CDS Button components', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +import { Button as ThirdPartyButton } from 'third-party-lib'; +const App = () => ( + <> + + Other + +); +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain(' + + + +); +`; + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + }); + + describe('aliased imports', () => { + it('transforms aliased CDS Button imports', () => { + const input = ` +import { Button as CdsButton } from '@coinbase/cds-web'; +const App = () => Click; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="inverse"'); + expect(output).not.toContain('variant="tertiary"'); + }); + }); + + describe('idempotency', () => { + it('produces the same result when run twice', () => { + const input = ` +import { Button, IconButton } from '@coinbase/cds-web'; +const App = () => ( + <> + + + +); +`; + const firstPass = applyButtonVariantTransform(input); + const secondPass = applyButtonVariantTransform(firstPass); + expect(secondPass).toBe(''); + }); + }); + + describe('e2e fixtures', () => { + it('transforms mixed variants correctly', () => { + const input = readFixture('mixed-variants.input'); + const expected = readFixture('mixed-variants.output'); + const output = applyButtonVariantTransform(input); + expect(output.trim()).toEqual(expected.trim()); + }); + + it('adds TODO for dynamic expressions', () => { + const input = readFixture('dynamic-expression.input'); + const expected = readFixture('dynamic-expression.output'); + const output = applyButtonVariantTransform(input); + expect(output.trim()).toEqual(expected.trim()); + }); + + it('skips files with no CDS imports', () => { + const input = readFixture('no-cds-imports.input'); + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + + it('transforms aliased imports correctly', () => { + const input = readFixture('aliased-imports.input'); + const expected = readFixture('aliased-imports.output'); + const output = applyButtonVariantTransform(input); + expect(output.trim()).toEqual(expected.trim()); + }); + }); +}); diff --git a/packages/migrator/src/transforms/button-variant-values.ts b/packages/migrator/src/transforms/button-variant-values.ts new file mode 100644 index 0000000000..3ee770b7eb --- /dev/null +++ b/packages/migrator/src/transforms/button-variant-values.ts @@ -0,0 +1,105 @@ +/** + * Button Variant Values Transform (v8 → v9) + * + * Remaps Button/IconButton `variant` prop values to reflect v9 naming: + * - "tertiary" → "inverse" (old tertiary used bgInverse; v9 gives tertiary new semantics) + * - "foregroundMuted" → "secondary" (foregroundMuted deprecated per design) + * + * Only targets components imported from @coinbase/cds-web or @coinbase/cds-mobile. + * Adds TODO comments for dynamic variant expressions that need manual review. + */ +import type { API, FileInfo } from 'jscodeshift'; + +import { addTodoComment, hasMigrationTodo, transformLogger } from '../utils/transform-utils'; + +const VARIANT_MAP: Record = { + tertiary: 'inverse', + foregroundMuted: 'secondary', +}; + +const CDS_PACKAGES = ['@coinbase/cds-web', '@coinbase/cds-mobile']; +const TARGET_COMPONENTS = ['Button', 'IconButton']; + +// eslint-disable-next-line no-restricted-exports -- jscodeshift requires default export +export default function transformer(file: FileInfo, api: API) { + const j = api.jscodeshift; + const root = j(file.source); + + const cdsComponentLocalNames = new Set(); + + root + .find(j.ImportDeclaration) + .filter((path) => CDS_PACKAGES.includes(path.value.source.value as string)) + .forEach((path) => { + path.value.specifiers?.forEach((specifier) => { + if ( + j.ImportSpecifier.check(specifier) && + TARGET_COMPONENTS.includes(specifier.imported.name) + ) { + cdsComponentLocalNames.add(specifier.local?.name ?? specifier.imported.name); + } + }); + }); + + if (cdsComponentLocalNames.size === 0) { + return null; + } + + let hasChanges = false; + + root + .find(j.JSXElement) + .filter((path) => { + const name = path.value.openingElement.name; + return j.JSXIdentifier.check(name) && cdsComponentLocalNames.has(name.name); + }) + .forEach((path) => { + const variantAttr = path.value.openingElement.attributes?.find( + (attr) => + j.JSXAttribute.check(attr) && + j.JSXIdentifier.check(attr.name) && + attr.name.name === 'variant', + ); + + if (!variantAttr || !j.JSXAttribute.check(variantAttr)) return; + + const value = variantAttr.value; + + if (j.StringLiteral.check(value)) { + const oldVariant = value.value; + const newVariant = VARIANT_MAP[oldVariant]; + if (newVariant) { + value.value = newVariant; + hasChanges = true; + + transformLogger.success( + `Updated variant: ${oldVariant} → ${newVariant}`, + file.path, + path.value.loc?.start.line, + ); + } + } else if (j.JSXExpressionContainer.check(value)) { + if (!hasMigrationTodo(path)) { + addTodoComment( + j, + path, + 'Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating.', + ); + + transformLogger.warn( + 'Dynamic variant expression requires manual review', + file.path, + path.value.loc?.start.line, + ); + + hasChanges = true; + } + } + }); + + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/example-transform.ts b/packages/migrator/src/transforms/example-transform.ts new file mode 100644 index 0000000000..0adb22cf81 --- /dev/null +++ b/packages/migrator/src/transforms/example-transform.ts @@ -0,0 +1,97 @@ +/** + * Example Transform + * + * This is a template/example transform showing how to write codemods for CDS migrations. + * + * NOTE: Transforms must be standalone - they cannot import from ../utils + * due to jscodeshift loading transforms in CommonJS worker processes. + */ + +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { addTodoComment, hasMigrationTodo, transformLogger } from '../utils/transform-utils'; + +// eslint-disable-next-line no-restricted-exports -- jscodeshift requires default export +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + let hasChanges = false; + + // Example 1: Rename a component + root + .find(j.JSXElement) + .filter((path) => { + const openingElement = path.value.openingElement; + return j.JSXIdentifier.check(openingElement.name) && openingElement.name.name === 'OldButton'; + }) + .forEach((path) => { + // Rename component + const openingElement = path.value.openingElement; + const closingElement = path.value.closingElement; + + if (j.JSXIdentifier.check(openingElement.name)) { + openingElement.name.name = 'Button'; + hasChanges = true; + + transformLogger.success( + 'Renamed OldButton to Button', + file.path, + path.value.loc?.start.line, + ); + } + + if (closingElement && j.JSXIdentifier.check(closingElement.name)) { + closingElement.name.name = 'Button'; + } + }); + + // Example 2: Add TODO for complex migrations + root.find(j.JSXAttribute, { name: { name: 'deprecatedProp' } }).forEach((path) => { + if (!hasMigrationTodo(path.parent)) { + addTodoComment( + j, + path.parent, + "The 'deprecatedProp' has been removed in v9", + 'Please migrate to the new API. See: https://docs.coinbase.com/cds/...', + ); + + transformLogger.warn( + 'Manual migration required for deprecatedProp', + file.path, + path.value.loc?.start.line, + ); + + hasChanges = true; + } + }); + + // Example 3: Update import statements + root + .find(j.ImportDeclaration) + .filter((path) => path.value.source.value === '@coinbase/cds-web') + .forEach((path) => { + path.value.specifiers?.forEach((specifier) => { + if (j.ImportSpecifier.check(specifier) && specifier.imported.name === 'OldButton') { + specifier.imported.name = 'Button'; + if (specifier.local) { + specifier.local.name = 'Button'; + } + hasChanges = true; + + transformLogger.success( + 'Updated import: OldButton → Button', + file.path, + path.value.loc?.start.line, + ); + } + }); + }); + + // Return null if no changes to skip writing the file + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/types.ts b/packages/migrator/src/types.ts new file mode 100644 index 0000000000..75cb79ede5 --- /dev/null +++ b/packages/migrator/src/types.ts @@ -0,0 +1,55 @@ +/** + * Types for CDS migration tools + */ + +export type Transform = { + /** + * Name of the transform + */ + name: string; + /** + * Description of what the transform does + */ + description: string; + /** + * Path to the transform file (relative to transforms directory) + */ + file: string; + /** + * File extensions to process (comma-separated) + * @default "tsx,ts,jsx,js" + */ + extensions?: string; +}; + +/** + * Preset manifest structure + */ +export type PresetManifest = { + /** + * Preset identifier (e.g., "v8-to-v9") + */ + preset: string; + /** + * Overall description of the migration + */ + description: string; + /** + * List of transforms in this preset + */ + transforms: Transform[]; +}; + +/** + * Selection for what to migrate + */ +export type MigrationSelection = { + /** + * If true, migrate everything + */ + all?: boolean; + /** + * Specific transforms to migrate (by name) + */ + transforms?: string[]; +}; diff --git a/packages/migrator/src/utils/config-loader.ts b/packages/migrator/src/utils/config-loader.ts new file mode 100644 index 0000000000..9510c627f4 --- /dev/null +++ b/packages/migrator/src/utils/config-loader.ts @@ -0,0 +1,74 @@ +/** + * Configuration loader utilities + */ + +import fs from 'fs'; +import path from 'path'; + +import type { MigrationSelection, PresetManifest, Transform } from '../types'; + +/** + * Load preset manifest from manifest.json + */ +export function loadMigrationManifest(presetDir: string): PresetManifest { + const manifestPath = path.join(presetDir, 'manifest.json'); + + if (!fs.existsSync(manifestPath)) { + throw new Error(`Preset manifest not found: ${manifestPath}`); + } + + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); + return JSON.parse(manifestContent) as PresetManifest; +} + +/** + * Get all transforms from manifest based on selection + */ +export function getSelectedTransforms( + manifest: PresetManifest, + selection: MigrationSelection, +): Transform[] { + // If migrate all, return all transforms + if (selection.all) { + return manifest.transforms; + } + + // Collect specific transforms by name + if (selection.transforms && selection.transforms.length > 0) { + const selectedTransforms: Transform[] = []; + for (const transformName of selection.transforms) { + const transform = manifest.transforms.find((t) => t.name === transformName); + if (transform) { + selectedTransforms.push(transform); + } + } + return selectedTransforms; + } + + return []; +} + +/** + * Build a summary of what will be migrated + */ +export function buildMigrationSummary( + manifest: PresetManifest, + selection: MigrationSelection, +): string { + const transforms = getSelectedTransforms(manifest, selection); + + let summary = '\nMigration Plan:\n'; + summary += '================\n\n'; + + if (transforms.length === 0) { + summary += 'No transforms selected.\n'; + } else { + for (const transform of transforms) { + summary += ` • ${transform.name} - ${transform.description}\n`; + } + } + + summary += `\nTotal transforms: ${transforms.length}\n`; + + return summary; +} diff --git a/packages/migrator/src/utils/constants.ts b/packages/migrator/src/utils/constants.ts new file mode 100644 index 0000000000..9a3ecd411e --- /dev/null +++ b/packages/migrator/src/utils/constants.ts @@ -0,0 +1,6 @@ +/** + * Constants used across migration utilities + */ + +export const TODO_PREFIX = 'TODO(cds-migration)'; +export const LOG_FILE_NAME = 'migration.log'; diff --git a/packages/migrator/src/utils/index.ts b/packages/migrator/src/utils/index.ts new file mode 100644 index 0000000000..ee8ca90714 --- /dev/null +++ b/packages/migrator/src/utils/index.ts @@ -0,0 +1,8 @@ +/** + * Shared utility functions for CDS migrations + */ + +export * from './config-loader'; +export * from './constants'; +export * from './logger'; +export * from './migration-history'; diff --git a/packages/migrator/src/utils/logger.ts b/packages/migrator/src/utils/logger.ts new file mode 100644 index 0000000000..15dc5d0746 --- /dev/null +++ b/packages/migrator/src/utils/logger.ts @@ -0,0 +1,201 @@ +/** + * Logging utilities for tracking migration progress and issues + */ + +import fs from 'fs'; +import path from 'path'; + +import { LOG_FILE_NAME } from './constants'; + +export const LogLevel = { + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', + SUCCESS: 'SUCCESS', + TODO: 'TODO', +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; + +export type LogEntry = { + timestamp: string; + level: LogLevel; + file?: string; + line?: number; + message: string; + details?: string; +}; + +/** + * Migration logger for tracking changes and issues + */ +export class MigrationLogger { + private logPath: string; + private entries: LogEntry[] = []; + + constructor(outputDir: string = process.cwd()) { + this.logPath = path.join(outputDir, LOG_FILE_NAME); + this.initializeLog(); + } + + private initializeLog(): void { + const header = `CDS Migration Log +Generated: ${new Date().toISOString()} +========================================\n\n`; + + fs.writeFileSync(this.logPath, header, 'utf-8'); + } + + /** + * Add a log entry + */ + private addEntry(entry: LogEntry): void { + this.entries.push(entry); + this.writeEntry(entry); + } + + private writeEntry(entry: LogEntry): void { + const location = entry.file ? ` [${entry.file}${entry.line ? `:${entry.line}` : ''}]` : ''; + const details = entry.details ? `\n ${entry.details}` : ''; + + const line = `[${entry.timestamp}] ${entry.level}${location}: ${entry.message}${details}\n`; + fs.appendFileSync(this.logPath, line, 'utf-8'); + } + + /** + * Log an informational message + */ + info(message: string, file?: string, line?: number): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.INFO, + file, + line, + message, + }); + } + + /** + * Log a warning + */ + warn(message: string, file?: string, line?: number, details?: string): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.WARN, + file, + line, + message, + details, + }); + } + + /** + * Log an error + */ + error(message: string, file?: string, line?: number, details?: string): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.ERROR, + file, + line, + message, + details, + }); + } + + /** + * Log a successful transformation + */ + success(message: string, file?: string, line?: number): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.SUCCESS, + file, + line, + message, + }); + } + + /** + * Log a TODO item (manual migration required) + */ + todo(message: string, file?: string, line?: number, details?: string): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.TODO, + file, + line, + message, + details, + }); + } + + /** + * Generate a summary of the migration + */ + writeSummary(): void { + const summary = ` +======================================== +Migration Summary +======================================== + +Total Entries: ${this.entries.length} +- INFO: ${this.entries.filter((e) => e.level === LogLevel.INFO).length} +- SUCCESS: ${this.entries.filter((e) => e.level === LogLevel.SUCCESS).length} +- WARN: ${this.entries.filter((e) => e.level === LogLevel.WARN).length} +- ERROR: ${this.entries.filter((e) => e.level === LogLevel.ERROR).length} +- TODO: ${this.entries.filter((e) => e.level === LogLevel.TODO).length} + +`; + + fs.appendFileSync(this.logPath, summary, 'utf-8'); + + // List all TODO items + const todos = this.entries.filter((e) => e.level === LogLevel.TODO); + if (todos.length > 0) { + const todoList = ` +Manual Migration Required (${todos.length} items): +${todos.map((t) => ` - ${t.file || 'Unknown file'}: ${t.message}`).join('\n')} + +`; + fs.appendFileSync(this.logPath, todoList, 'utf-8'); + } + + console.log(`\n📝 Migration log written to: ${this.logPath}`); + } + + /** + * Get the log file path + */ + getLogPath(): string { + return this.logPath; + } + + /** + * Get all log entries + */ + getEntries(): LogEntry[] { + return [...this.entries]; + } + + /** + * Get entries by level + */ + getEntriesByLevel(level: LogLevel): LogEntry[] { + return this.entries.filter((e) => e.level === level); + } +} + +/** + * Create a global logger instance + */ +let globalLogger: MigrationLogger | null = null; + +export function createLogger(outputDir?: string): MigrationLogger { + globalLogger = new MigrationLogger(outputDir); + return globalLogger; +} + +export function getLogger(): MigrationLogger | null { + return globalLogger; +} diff --git a/packages/migrator/src/utils/migration-history.ts b/packages/migrator/src/utils/migration-history.ts new file mode 100644 index 0000000000..67c9c2be98 --- /dev/null +++ b/packages/migrator/src/utils/migration-history.ts @@ -0,0 +1,171 @@ +/** + * Migration history tracking + * + * Keeps track of which transforms have been run on a given path + */ + +import fs from 'fs'; +import path from 'path'; + +import type { Transform } from '../types'; + +const HISTORY_FILE_NAME = '.cds-migration-history.json'; + +export type MigrationHistoryEntry = { + /** + * Transform file path (e.g., "button-variant" or "components/button-variant") + */ + transform: string; + /** + * When the transform was run + */ + timestamp: string; +}; + +export type MigrationHistory = { + /** + * List of all transform runs + */ + entries: MigrationHistoryEntry[]; + /** + * Last update timestamp + */ + lastUpdated: string; +}; + +/** + * Get the history file path for a target directory + */ +function getHistoryFilePath(targetPath: string): string { + // If targetPath is a file, use its directory + const stats = fs.existsSync(targetPath) ? fs.statSync(targetPath) : null; + const dir = stats?.isDirectory() ? targetPath : path.dirname(targetPath); + + return path.join(dir, HISTORY_FILE_NAME); +} + +/** + * Load migration history for a target path + */ +export function loadMigrationHistory(targetPath: string): MigrationHistory | null { + const historyPath = getHistoryFilePath(targetPath); + + if (!fs.existsSync(historyPath)) { + return null; + } + + try { + const content = fs.readFileSync(historyPath, 'utf-8'); + return JSON.parse(content) as MigrationHistory; + } catch (error) { + console.warn(`Warning: Could not load migration history from ${historyPath}`); + return null; + } +} + +/** + * Save migration history for a target path + */ +export function saveMigrationHistory(targetPath: string, history: MigrationHistory): void { + const historyPath = getHistoryFilePath(targetPath); + + try { + fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8'); + } catch (error) { + console.warn(`Warning: Could not save migration history to ${historyPath}`); + } +} + +/** + * Record a transform run in the history + */ +export function recordTransformRun( + targetPath: string, + transformPath: string, + dryRun: boolean, +): void { + // Don't record history for dry runs (they don't modify files) + if (dryRun) { + return; + } + + let history = loadMigrationHistory(targetPath); + + if (!history) { + history = { + entries: [], + lastUpdated: new Date().toISOString(), + }; + } + + // Add new entry + history.entries.push({ + transform: transformPath, + timestamp: new Date().toISOString(), + }); + + history.lastUpdated = new Date().toISOString(); + saveMigrationHistory(targetPath, history); +} + +/** + * Check if a transform has already been run + */ +export function hasTransformBeenRun(targetPath: string, transformPath: string): boolean { + const history = loadMigrationHistory(targetPath); + + if (!history) { + return false; + } + + return history.entries.some((entry) => entry.transform === transformPath); +} + +/** + * Get list of transforms that have already been run + */ +export function getAlreadyRunTransforms(targetPath: string, transformPaths: string[]): string[] { + const history = loadMigrationHistory(targetPath); + + if (!history) { + return []; + } + + const runTransforms = new Set(history.entries.map((entry) => entry.transform)); + + return transformPaths.filter((path) => runTransforms.has(path)); +} + +/** + * Build a summary of migration history + */ +export function buildHistorySummary(targetPath: string): string { + const history = loadMigrationHistory(targetPath); + + if (!history || history.entries.length === 0) { + return 'No migration history found for this path.'; + } + + let summary = '\n📜 Migration History\n'; + summary += '==================\n\n'; + + for (const entry of history.entries) { + const date = new Date(entry.timestamp).toLocaleDateString(); + summary += ` • ${entry.transform} (${date})\n`; + } + + summary += `\nLast updated: ${new Date(history.lastUpdated).toLocaleString()}\n`; + + return summary; +} + +/** + * Clear migration history for a target path + */ +export function clearMigrationHistory(targetPath: string): void { + const historyPath = getHistoryFilePath(targetPath); + + if (fs.existsSync(historyPath)) { + fs.unlinkSync(historyPath); + } +} diff --git a/packages/migrator/src/utils/transform-utils.ts b/packages/migrator/src/utils/transform-utils.ts new file mode 100644 index 0000000000..cb0f07934c --- /dev/null +++ b/packages/migrator/src/utils/transform-utils.ts @@ -0,0 +1,54 @@ +/** + * Utilities for transforms (CommonJS-compatible) + * + * This module re-exports utilities in a way that jscodeshift transforms can use. + * It avoids complex dependencies and ES module issues. + */ + +// Re-export just the essential functions transforms need +export { TODO_PREFIX } from './constants'; + +/** + * Simple logger for transforms + * Note: This is a simplified version that just console.logs since + * the full logger isn't available in jscodeshift workers + */ +export const transformLogger = { + success: (message: string, file?: string, line?: number) => { + const location = file ? ` [${file}${line ? `:${line}` : ''}]` : ''; + console.log(`✓ ${message}${location}`); + }, + warn: (message: string, file?: string, line?: number) => { + const location = file ? ` [${file}${line ? `:${line}` : ''}]` : ''; + console.warn(`⚠ ${message}${location}`); + }, + info: (message: string, file?: string, line?: number) => { + const location = file ? ` [${file}${line ? `:${line}` : ''}]` : ''; + console.log(`ℹ ${message}${location}`); + }, +}; + +/** + * Add TODO comment to JSX attribute + */ +export function addTodoComment(j: any, path: any, message: string, context?: string): void { + const comment = j.commentLine(` TODO(cds-migration): ${message}`, true, false); + const comments = [comment]; + + if (context) { + const contextComment = j.commentLine(` ${context}`, true, false); + comments.push(contextComment); + } + + path.value.comments = [...comments, ...(path.value.comments || [])]; +} + +/** + * Check if node has migration TODO + */ +export function hasMigrationTodo(path: any): boolean { + const comments = path.value.comments || []; + return comments.some( + (comment: any) => comment.value && comment.value.includes('TODO(cds-migration)'), + ); +} diff --git a/packages/migrator/tsconfig.build.json b/packages/migrator/tsconfig.build.json new file mode 100644 index 0000000000..a227a333b1 --- /dev/null +++ b/packages/migrator/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/__stories__/**", + "**/__tests__/**", + "**/__mocks__/**", + "**/__fixtures__/**", + "**/*.stories.*", + "**/*.test.*", + "**/*.spec.*" + ], + "references": [] +} diff --git a/packages/migrator/tsconfig.json b/packages/migrator/tsconfig.json new file mode 100644 index 0000000000..dcb63b06d2 --- /dev/null +++ b/packages/migrator/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.project.json", + "compilerOptions": { + "declarationDir": "dts", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/__testfixtures__/**" + ], + "references": [] +} diff --git a/packages/mobile-visreg/flows/capture-all.yaml b/packages/mobile-visreg/flows/capture-all.yaml new file mode 100644 index 0000000000..4bd850c47b --- /dev/null +++ b/packages/mobile-visreg/flows/capture-all.yaml @@ -0,0 +1,99 @@ +# AUTO-GENERATED — do not edit +# Run: node src/generate-flows.mjs +appId: ${APP_ID} +--- +- launchApp: + appId: ${APP_ID} +- assertVisible: + text: CDS +- waitForAnimationToEnd + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: Accordion' + env: + ROUTE_NAME: Accordion + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: AlertBasic' + env: + ROUTE_NAME: AlertBasic + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: ListCell' + env: + ROUTE_NAME: ListCell + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: Pictogram' + env: + ROUTE_NAME: Pictogram + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: Pressable' + env: + ROUTE_NAME: Pressable + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: RadioCell' + env: + ROUTE_NAME: RadioCell + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: SelectChip' + env: + ROUTE_NAME: SelectChip + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: SlideButton' + env: + ROUTE_NAME: SlideButton + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: StepperHorizontal' + env: + ROUTE_NAME: StepperHorizontal + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: StepperVertical' + env: + ROUTE_NAME: StepperVertical + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: StickyFooter' + env: + ROUTE_NAME: StickyFooter + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: Switch' + env: + ROUTE_NAME: Switch + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: Tabs' + env: + ROUTE_NAME: Tabs + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: Tag' + env: + ROUTE_NAME: Tag + +- runFlow: + file: ./capture-route-steps.yaml + label: 'Route: Text' + env: + ROUTE_NAME: Text diff --git a/packages/mobile-visreg/visreg-screenshots/Accordion_ios.png b/packages/mobile-visreg/visreg-screenshots/Accordion_ios.png new file mode 100644 index 0000000000..4dc10efe12 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Accordion_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlertBasic_ios.png b/packages/mobile-visreg/visreg-screenshots/AlertBasic_ios.png new file mode 100644 index 0000000000..d6d16200c7 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlertBasic_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlertLongTitle_ios.png b/packages/mobile-visreg/visreg-screenshots/AlertLongTitle_ios.png new file mode 100644 index 0000000000..3dde3de05d Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlertLongTitle_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlertPortal_ios.png b/packages/mobile-visreg/visreg-screenshots/AlertPortal_ios.png new file mode 100644 index 0000000000..7bfd0bb6d9 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlertPortal_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlertSingleAction_ios.png b/packages/mobile-visreg/visreg-screenshots/AlertSingleAction_ios.png new file mode 100644 index 0000000000..92168fd76b Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlertSingleAction_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlertVerticalActions_ios.png b/packages/mobile-visreg/visreg-screenshots/AlertVerticalActions_ios.png new file mode 100644 index 0000000000..c0fb81a707 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlertVerticalActions_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlphaSelectChip_ios.png b/packages/mobile-visreg/visreg-screenshots/AlphaSelectChip_ios.png new file mode 100644 index 0000000000..ba7f537031 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlphaSelectChip_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlphaSelect_ios.png b/packages/mobile-visreg/visreg-screenshots/AlphaSelect_ios.png new file mode 100644 index 0000000000..a1e0d702fe Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlphaSelect_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AlphaTabbedChips_ios.png b/packages/mobile-visreg/visreg-screenshots/AlphaTabbedChips_ios.png new file mode 100644 index 0000000000..404d8e86d3 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AlphaTabbedChips_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AreaChart_ios.png b/packages/mobile-visreg/visreg-screenshots/AreaChart_ios.png new file mode 100644 index 0000000000..f3660bc9c5 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AreaChart_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/AvatarButton_ios.png b/packages/mobile-visreg/visreg-screenshots/AvatarButton_ios.png new file mode 100644 index 0000000000..3e9759a959 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/AvatarButton_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Avatar_ios.png b/packages/mobile-visreg/visreg-screenshots/Avatar_ios.png new file mode 100644 index 0000000000..f1d12b5b9b Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Avatar_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Axis_ios.png b/packages/mobile-visreg/visreg-screenshots/Axis_ios.png new file mode 100644 index 0000000000..d329e7b3e2 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Axis_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/BannerActions_ios.png b/packages/mobile-visreg/visreg-screenshots/BannerActions_ios.png new file mode 100644 index 0000000000..22412ff212 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/BannerActions_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/BannerLayout_ios.png b/packages/mobile-visreg/visreg-screenshots/BannerLayout_ios.png new file mode 100644 index 0000000000..de7af52046 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/BannerLayout_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Banner_ios.png b/packages/mobile-visreg/visreg-screenshots/Banner_ios.png new file mode 100644 index 0000000000..86bed1dc90 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Banner_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/BarChart_ios.png b/packages/mobile-visreg/visreg-screenshots/BarChart_ios.png new file mode 100644 index 0000000000..f535c315f8 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/BarChart_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Box_ios.png b/packages/mobile-visreg/visreg-screenshots/Box_ios.png new file mode 100644 index 0000000000..3410a07b18 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Box_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/BrowserBarSearchInput_ios.png b/packages/mobile-visreg/visreg-screenshots/BrowserBarSearchInput_ios.png new file mode 100644 index 0000000000..efbaa1d68b Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/BrowserBarSearchInput_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/BrowserBar_ios.png b/packages/mobile-visreg/visreg-screenshots/BrowserBar_ios.png new file mode 100644 index 0000000000..4ba1b5b5bd Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/BrowserBar_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/ButtonGroup_ios.png b/packages/mobile-visreg/visreg-screenshots/ButtonGroup_ios.png new file mode 100644 index 0000000000..753238b937 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/ButtonGroup_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Button_ios.png b/packages/mobile-visreg/visreg-screenshots/Button_ios.png new file mode 100644 index 0000000000..a23d259d9c Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Button_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Calendar_ios.png b/packages/mobile-visreg/visreg-screenshots/Calendar_ios.png new file mode 100644 index 0000000000..05173792e1 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Calendar_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Card_ios.png b/packages/mobile-visreg/visreg-screenshots/Card_ios.png new file mode 100644 index 0000000000..a87c388086 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Card_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/CarouselMedia_ios.png b/packages/mobile-visreg/visreg-screenshots/CarouselMedia_ios.png new file mode 100644 index 0000000000..c376e4dccd Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/CarouselMedia_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Carousel_ios.png b/packages/mobile-visreg/visreg-screenshots/Carousel_ios.png new file mode 100644 index 0000000000..7b12d4cd28 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Carousel_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/CartesianChart_ios.png b/packages/mobile-visreg/visreg-screenshots/CartesianChart_ios.png new file mode 100644 index 0000000000..32546dcdd8 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/CartesianChart_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/ChartAccessibility_ios.png b/packages/mobile-visreg/visreg-screenshots/ChartAccessibility_ios.png new file mode 100644 index 0000000000..8a7ead16be Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/ChartAccessibility_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/CheckboxCell_ios.png b/packages/mobile-visreg/visreg-screenshots/CheckboxCell_ios.png new file mode 100644 index 0000000000..0c55dc9318 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/CheckboxCell_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Checkbox_ios.png b/packages/mobile-visreg/visreg-screenshots/Checkbox_ios.png new file mode 100644 index 0000000000..4bb3300d4c Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Checkbox_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Chip_ios.png b/packages/mobile-visreg/visreg-screenshots/Chip_ios.png new file mode 100644 index 0000000000..5a4147ed11 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Chip_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Coachmark_ios.png b/packages/mobile-visreg/visreg-screenshots/Coachmark_ios.png new file mode 100644 index 0000000000..bbc307d0f2 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Coachmark_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Collapsible_ios.png b/packages/mobile-visreg/visreg-screenshots/Collapsible_ios.png new file mode 100644 index 0000000000..ab72e2e21e Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Collapsible_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Combobox_ios.png b/packages/mobile-visreg/visreg-screenshots/Combobox_ios.png new file mode 100644 index 0000000000..8fe84e9f2a Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Combobox_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/ContainedAssetCard_ios.png b/packages/mobile-visreg/visreg-screenshots/ContainedAssetCard_ios.png new file mode 100644 index 0000000000..0dc70aa874 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/ContainedAssetCard_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/ContentCard_ios.png b/packages/mobile-visreg/visreg-screenshots/ContentCard_ios.png new file mode 100644 index 0000000000..c841406dd1 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/ContentCard_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/ContentCellFallback_ios.png b/packages/mobile-visreg/visreg-screenshots/ContentCellFallback_ios.png new file mode 100644 index 0000000000..524add42d7 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/ContentCellFallback_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/ContentCell_ios.png b/packages/mobile-visreg/visreg-screenshots/ContentCell_ios.png new file mode 100644 index 0000000000..98f2891e22 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/ContentCell_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/ControlGroup_ios.png b/packages/mobile-visreg/visreg-screenshots/ControlGroup_ios.png new file mode 100644 index 0000000000..172cf37c09 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/ControlGroup_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/DataCard_ios.png b/packages/mobile-visreg/visreg-screenshots/DataCard_ios.png new file mode 100644 index 0000000000..47b715cabb Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/DataCard_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/DateInput_ios.png b/packages/mobile-visreg/visreg-screenshots/DateInput_ios.png new file mode 100644 index 0000000000..2d8c902cbc Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/DateInput_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/DatePicker_ios.png b/packages/mobile-visreg/visreg-screenshots/DatePicker_ios.png new file mode 100644 index 0000000000..165e028435 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/DatePicker_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Divider_ios.png b/packages/mobile-visreg/visreg-screenshots/Divider_ios.png new file mode 100644 index 0000000000..ac4dd8c9b5 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Divider_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/Dot_ios.png b/packages/mobile-visreg/visreg-screenshots/Dot_ios.png new file mode 100644 index 0000000000..bd4fd00772 Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/Dot_ios.png differ diff --git a/packages/mobile-visreg/visreg-screenshots/DrawerBottom_ios.png b/packages/mobile-visreg/visreg-screenshots/DrawerBottom_ios.png new file mode 100644 index 0000000000..1e4f527a5b Binary files /dev/null and b/packages/mobile-visreg/visreg-screenshots/DrawerBottom_ios.png differ diff --git a/packages/mobile-visualization/babel.config.cjs b/packages/mobile-visualization/babel.config.cjs index 4d232b0f21..73280a634b 100644 --- a/packages/mobile-visualization/babel.config.cjs +++ b/packages/mobile-visualization/babel.config.cjs @@ -8,9 +8,12 @@ module.exports = { ['@babel/preset-env', { modules: isTestEnv ? 'commonjs' : false, loose: true }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', - ...(isTestEnv || isDetoxEnv ? ['module:metro-react-native-babel-preset'] : []), + // Use babel-preset-expo for test/detox environments. This preset wraps @react-native/babel-preset + // which includes babel-plugin-syntax-hermes-parser for parsing Flow files with 'as' syntax. + // See: https://docs.expo.dev/versions/latest/config/babel/ + ...(isTestEnv || isDetoxEnv ? ['babel-preset-expo'] : []), ], - plugins: isTestEnv || isDetoxEnv ? ['react-native-reanimated/plugin'] : [], + plugins: isTestEnv || isDetoxEnv ? ['react-native-worklets/plugin'] : [], ignore: isTestEnv || isDetoxEnv ? [] diff --git a/packages/mobile-visualization/jest.config.js b/packages/mobile-visualization/jest.config.js index 3840f7f31f..ff453979f9 100644 --- a/packages/mobile-visualization/jest.config.js +++ b/packages/mobile-visualization/jest.config.js @@ -10,13 +10,18 @@ const native = [ const esModules = ['@coinbase', ...native, ...d3]; +/** @type {import('jest').Config} */ export default { coveragePathIgnorePatterns: ['/src/illustrations/images', '.stories.tsx', '__stories__'], coverageReporters: ['json', 'text-summary', 'text', 'json-summary'], displayName: 'mobile-visualization', preset: '../../jest.preset-mobile.js', // https://docs.swmansion.com/react-native-gesture-handler/docs/guides/testing - setupFiles: ['/../../node_modules/react-native-gesture-handler/jestSetup.js'], + // https://docs.swmansion.com/react-native-worklets/docs/guides/testing/ + setupFiles: [ + '/../../node_modules/react-native-gesture-handler/jestSetup.js', + '/jest/setupWorkletsMock.js', + ], testMatch: ['**//**/*.test.(ts|tsx)'], setupFilesAfterEnv: ['/jest/setup.js'], // https://github.com/facebook/jest/blob/main/docs/Configuration.md#faketimers-object diff --git a/packages/mobile-visualization/jest/setup.js b/packages/mobile-visualization/jest/setup.js index e46ec43c3e..c3855fd8f2 100644 --- a/packages/mobile-visualization/jest/setup.js +++ b/packages/mobile-visualization/jest/setup.js @@ -1,6 +1,20 @@ -jest.mock('react-native-reanimated', () => { - const Reanimated = require('react-native-reanimated/mock'); - Reanimated.makeMutable = Reanimated.useSharedValue; +// https://docs.swmansion.com/react-native-reanimated/docs/guides/testing/ +const { + setUpTests, + configureReanimatedLogger, + ReanimatedLogLevel, +} = require('react-native-reanimated'); - return Reanimated; +/* + React Reanimated 4.x setup: +*/ + +// Disable strict mode to prevent warnings about writing to shared values during render +// This is needed because some components (e.g., TabsActiveIndicator) use patterns that +// trigger warnings in reanimated 4.x strict mode but still work correctly +configureReanimatedLogger({ + level: ReanimatedLogLevel.warn, + strict: false, }); + +setUpTests(); diff --git a/packages/mobile-visualization/jest/setupWorkletsMock.js b/packages/mobile-visualization/jest/setupWorkletsMock.js new file mode 100644 index 0000000000..681bc6223c --- /dev/null +++ b/packages/mobile-visualization/jest/setupWorkletsMock.js @@ -0,0 +1,3 @@ +// Mock react-native-worklets before any reanimated imports +// The built-in mock at lib/module/mock is not available until later versions: 0.7.X +jest.mock('react-native-worklets', () => require('./workletsMock')); diff --git a/packages/mobile-visualization/jest/workletsMock.js b/packages/mobile-visualization/jest/workletsMock.js new file mode 100644 index 0000000000..791d413330 --- /dev/null +++ b/packages/mobile-visualization/jest/workletsMock.js @@ -0,0 +1,111 @@ +/** + * Mock for react-native-worklets 0.5.2 + * The built-in mock at lib/module/mock is not available until later versions: 0.7.X, + * Following CMR's version recommendation on versions we are staying with 0.5.2 and reanimated 4.1.1 for now + * This mock is based on the official mock from: + * https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-worklets/src/mock.ts + */ + +'use strict'; + +const NOOP = () => {}; +const NOOP_FACTORY = () => NOOP; +const IDENTITY = (value) => value; +const IMMEDIATE_CALLBACK_INVOCATION = (callback) => callback(); + +const RuntimeKind = { + ReactNative: 'RN', + UI: 'UI', + Worklet: 'Worklet', +}; + +// Mocked requestAnimationFrame that uses setTimeout and passes timestamp +// This fixes Jest's React Native setup which doesn't pass timestamps to callbacks +// See: https://github.com/facebook/react-native/blob/main/packages/react-native/jest/setup.js#L28 +const mockedRequestAnimationFrame = (callback) => { + return setTimeout(() => callback(performance.now()), 0); +}; + +// Set up global properties that reanimated expects from the native runtime +global._WORKLET = false; +global.__RUNTIME_KIND = RuntimeKind.ReactNative; +global._log = console.log; +global._getAnimationTimestamp = () => performance.now(); +global.__flushAnimationFrame = NOOP; +global.requestAnimationFrame = mockedRequestAnimationFrame; + +const WorkletAPI = { + isShareableRef: () => true, + makeShareable: IDENTITY, + makeShareableCloneOnUIRecursive: IDENTITY, + makeShareableCloneRecursive: IDENTITY, + shareableMappingCache: new Map(), + + getStaticFeatureFlag: () => false, + setDynamicFeatureFlag: NOOP, + + isSynchronizable: () => false, + + getRuntimeKind: () => RuntimeKind.ReactNative, + RuntimeKind, + + createWorkletRuntime: NOOP_FACTORY, + runOnRuntime: IDENTITY, + runOnRuntimeAsync(workletRuntime, worklet, ...args) { + return WorkletAPI.runOnUIAsync(worklet, ...args); + }, + scheduleOnRuntime: IMMEDIATE_CALLBACK_INVOCATION, + + createSerializable: IDENTITY, + isSerializableRef: IDENTITY, + serializableMappingCache: new Map(), + + createSynchronizable: IDENTITY, + + callMicrotasks: NOOP, + executeOnUIRuntimeSync: IDENTITY, + + runOnJS(fun) { + return (...args) => queueMicrotask(args.length ? () => fun(...args) : fun); + }, + + runOnUI(worklet) { + return (...args) => { + // In Jest environment we schedule work via mockedRequestAnimationFrame + // to ensure it runs when timers are advanced + mockedRequestAnimationFrame(() => { + worklet(...args); + }); + }; + }, + + runOnUIAsync(worklet, ...args) { + return new Promise((resolve) => { + mockedRequestAnimationFrame(() => { + const result = worklet(...args); + resolve(result); + }); + }); + }, + + runOnUISync: IMMEDIATE_CALLBACK_INVOCATION, + + scheduleOnRN(fun, ...args) { + WorkletAPI.runOnJS(fun)(...args); + }, + + scheduleOnUI(worklet, ...args) { + WorkletAPI.runOnUI(worklet)(...args); + }, + + unstable_eventLoopTask: NOOP_FACTORY, + + isWorkletFunction: () => false, + + WorkletsModule: {}, +}; + +module.exports = { + __esModule: true, + ...WorkletAPI, +}; diff --git a/packages/mobile-visualization/package.json b/packages/mobile-visualization/package.json index 6216f4359f..841e54a1ac 100644 --- a/packages/mobile-visualization/package.json +++ b/packages/mobile-visualization/package.json @@ -40,13 +40,14 @@ "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-mobile": "workspace:^", "@coinbase/cds-utils": "workspace:^", - "@shopify/react-native-skia": "^1.12.4 || ^2.0.0", - "react": "^18.3.1", - "react-native": "^0.74.5", - "react-native-gesture-handler": "^2.16.2", - "react-native-reanimated": "^3.14.0", - "react-native-safe-area-context": "^4.10.5", - "react-native-svg": "^14.1.0" + "@shopify/react-native-skia": "2.2.12", + "react": "~19.1.2", + "react-native": "~0.81.5", + "react-native-gesture-handler": "2.28.0", + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" }, "dependencies": { "d3-interpolate-path": "^2.3.0", @@ -55,18 +56,22 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@coinbase/cds-common": "workspace:^", "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-mobile": "workspace:^", "@coinbase/cds-utils": "workspace:^", - "@shopify/react-native-skia": "1.12.4", - "@types/react": "^18.3.12", - "react-native-gesture-handler": "2.16.2", - "react-native-reanimated": "3.14.0", - "react-native-safe-area-context": "4.10.5", - "react-native-svg": "14.1.0", - "react-test-renderer": "^18.3.1" + "@shopify/react-native-skia": "2.2.12", + "@testing-library/react-native": "^13.3.3", + "@types/react": "19.1.2", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-gesture-handler": "2.28.0", + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2", + "react-test-renderer": "19.1.2" } } diff --git a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx index 89db490bf8..c07792f717 100644 --- a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx +++ b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx @@ -4,7 +4,18 @@ * https://github.com/pmndrs/its-fine/blob/598b81f02778c22ed21121c2b1a786bdefb14e23/src/index.tsx */ -import * as React from 'react'; +import { + Component, + type Context, + createContext, + type FC, + type PropsWithChildren, + type ReactNode, + useContext, + useId, + useMemo, + useState, +} from 'react'; import type ReactReconciler from 'react-reconciler'; import { ThemeContext } from '@coinbase/cds-mobile/system/ThemeProvider'; @@ -16,11 +27,7 @@ import { CartesianChartContext } from './ChartProvider'; * Only these contexts will be made available inside the chart's Skia tree. * This improves performance by avoiding the overhead of rendering every bridged context. */ -const BRIDGED_CONTEXTS: React.Context[] = [ - ThemeContext, - CartesianChartContext, - ScrubberContext, -]; +const BRIDGED_CONTEXTS: Context[] = [ThemeContext, CartesianChartContext, ScrubberContext]; /** * Represents a react-internal tree node. @@ -55,7 +62,7 @@ function traverseTreeNode( /** * Wraps context to hide React development warnings about using contexts between renderers. */ -function wrapContext(context: React.Context): React.Context { +function wrapContext(context: Context): Context { try { return Object.defineProperties(context, { _currentRenderer: { @@ -89,12 +96,12 @@ console.error = function (...args: any[]) { return error.apply(this, args); }; -const TreeNodeContext = wrapContext(React.createContext(null!)); +const TreeNodeContext = wrapContext(createContext(null!)); /** * A react-internal tree node provider that binds React children to the React tree for chart context bridging. */ -export class ChartBridgeProvider extends React.Component<{ children?: React.ReactNode }> { +export class ChartBridgeProvider extends Component<{ children?: ReactNode }> { private _reactInternals!: TreeNode; render() { @@ -110,12 +117,12 @@ export class ChartBridgeProvider extends React.Component<{ children?: React.Reac * Returns the current react-internal tree node. */ function useTreeNode(): TreeNode | undefined { - const root = React.useContext(TreeNodeContext); + const root = useContext(TreeNodeContext); if (root === null) throw new Error('useTreeNode must be called within a !'); - const id = React.useId(); - const treeNode = React.useMemo(() => { + const id = useId(); + const treeNode = useMemo(() => { for (const maybeNode of [root, root?.alternate]) { if (!maybeNode) continue; const node = traverseTreeNode(maybeNode, false, (node) => { @@ -132,8 +139,8 @@ function useTreeNode(): TreeNode | undefined { return treeNode; } -export type ContextMap = Map, any> & { - get(context: React.Context): T | undefined; +export type ContextMap = Map, any> & { + get(context: Context): T | undefined; }; /** @@ -141,7 +148,7 @@ export type ContextMap = Map, any> & { */ function useContextMap(): ContextMap { const treeNode = useTreeNode(); - const [contextMap] = React.useState(() => new Map, any>()); + const [contextMap] = useState(() => new Map, any>()); // Collect live context contextMap.clear(); @@ -159,7 +166,7 @@ function useContextMap(): ContextMap { !contextMap.has(context) ) { // eslint-disable-next-line react-hooks/rules-of-hooks - contextMap.set(context, React.useContext(wrapContext(context))); + contextMap.set(context, useContext(wrapContext(context))); } } @@ -172,7 +179,7 @@ function useContextMap(): ContextMap { /** * Represents a chart context bridge provider component. */ -export type ChartContextBridge = React.FC>; +export type ChartContextBridge = FC>; /** * Returns a ChartContextBridge of live context providers to pierce Context across renderers. @@ -182,7 +189,7 @@ export function useChartContextBridge(): ChartContextBridge { const contextMap = useContextMap(); // Flatten context and their memoized values into a `ChartContextBridge` provider - return React.useMemo( + return useMemo( () => Array.from(contextMap.keys()).reduce( (Prev, context) => (props) => ( diff --git a/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx index 2cfb07603c..ced74cf99f 100644 --- a/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx +++ b/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx @@ -56,7 +56,7 @@ const MinWidthPeriodSelectorExample = () => { gap={0.5} onChange={(tab) => setActiveTab(tab)} tabs={tabs} - width="fit-content" + width={null} /> ); }; @@ -75,7 +75,7 @@ const PaddedPeriodSelectorExample = () => { onChange={(tab) => setActiveTab(tab)} padding={3} tabs={tabs} - width="fit-content" + width={null} /> ); }; @@ -148,7 +148,7 @@ const TooManyPeriodsSelectorExample = () => { justifyContent="flex-start" onChange={setActiveTab} tabs={tabs} - width="fit-content" + width={null} /> + {label} ) : ( @@ -341,7 +341,7 @@ function IconsPeriodSelectorExample() { activeIndicator: { borderRadius: theme.borderRadius[200] }, }} tabs={tabs} - width="fit-content" + width={null} /> ); } diff --git a/packages/mobile-visualization/src/chart/area/AreaChart.tsx b/packages/mobile-visualization/src/chart/area/AreaChart.tsx index 8ba6175219..cd9d692f62 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile-visualization/src/chart/area/AreaChart.tsx @@ -205,12 +205,12 @@ export const AreaChart = memo( return ( {showXAxis && } {showYAxis && } diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx index e178468dc4..02c1229509 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -207,12 +207,12 @@ export const BarChart = memo( return ( {showXAxis && } {showYAxis && } diff --git a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx index 5579c6e1fe..d3107c626b 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx @@ -118,7 +118,6 @@ export const BarStackGroup = memo( return orderedConfigs.map(({ categoryIndex, indexPos, thickness }) => ( ( valueScale={valueScaleComputed} xAxisId={xAxisId} yAxisId={yAxisId} + {...props} /> )); }, diff --git a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx index 5890ad80b5..a19e1eccd0 100644 --- a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx +++ b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -4,7 +4,6 @@ import { Chip } from '@coinbase/cds-mobile/chips'; import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { TextLabel1, TextLabel2 } from '@coinbase/cds-mobile/typography'; import { Text } from '@coinbase/cds-mobile/typography/Text'; import { Canvas, Group, Path as SkiaPath, Skia } from '@shopify/react-native-skia'; @@ -274,8 +273,8 @@ const DynamicData = () => { return ( - {label} - {formattedValue} + {label} + {formattedValue} ); }, @@ -377,7 +376,9 @@ const Interactive = () => { > - {label} + + {label} + ); diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile-visualization/src/chart/line/LineChart.tsx index bf578f7e48..eab8704648 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile-visualization/src/chart/line/LineChart.tsx @@ -198,7 +198,6 @@ export const LineChart = memo( return ( {/* Render axes first for grid lines to appear behind everything else */} {showXAxis && } diff --git a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx index f66f1b7697..ccb387209b 100644 --- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -1862,7 +1862,7 @@ function DataCardWithLineChart() { thumbnail={exampleThumbnail} title="Line Chart with Tag" titleAccessory={ - + ↗ 25.25% } @@ -1885,7 +1885,7 @@ function DataCardWithLineChart() { thumbnail={exampleThumbnail} title="Actionable Line Chart" titleAccessory={ - + ↗ 8.5% } @@ -1915,7 +1915,7 @@ function DataCardWithLineChart() { } title="Card with Line Chart" titleAccessory={ - + ↗ 25.25% } diff --git a/packages/mobile-visualization/src/chart/point/DefaultPointLabel.tsx b/packages/mobile-visualization/src/chart/point/DefaultPointLabel.tsx index e978f7524b..c93c3e4488 100644 --- a/packages/mobile-visualization/src/chart/point/DefaultPointLabel.tsx +++ b/packages/mobile-visualization/src/chart/point/DefaultPointLabel.tsx @@ -26,11 +26,11 @@ export const DefaultPointLabel = memo( return ( {children} diff --git a/packages/mobile-visualization/src/chart/text/ChartText.tsx b/packages/mobile-visualization/src/chart/text/ChartText.tsx index 4d556750ca..48623327cb 100644 --- a/packages/mobile-visualization/src/chart/text/ChartText.tsx +++ b/packages/mobile-visualization/src/chart/text/ChartText.tsx @@ -465,16 +465,16 @@ export const ChartText = memo( switch (paragraphAlignment) { case TextAlign.Center: // For center-aligned text, account for half the width - minOffset = Math.min(...rects.map((rect) => rect.x - rect.width / 2)); + minOffset = Math.min(...rects.map((rect) => rect.left - rect.width / 2)); break; case TextAlign.Right: case TextAlign.End: // For right-aligned text, account for the full width - minOffset = Math.min(...rects.map((rect) => rect.x - rect.width)); + minOffset = Math.min(...rects.map((rect) => rect.left - rect.width)); break; default: // For left-aligned text, use the x position directly - minOffset = Math.min(...rects.map((rect) => rect.x)); + minOffset = Math.min(...rects.map((rect) => rect.left)); break; } diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts index 378a128c60..61a2e1bb51 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { buildTransition, @@ -228,7 +228,7 @@ describe('useInterpolator', () => { // Update value value.value = 0.5; - rerender(); + rerender(undefined); expect(result.current).toBeDefined(); }); @@ -279,7 +279,7 @@ describe('usePathTransition', () => { }); it('should handle path updates', () => { - const { result, rerender } = renderHook( + const { result, rerender } = renderHook, { path: string }>( ({ path }) => usePathTransition({ currentPath: path, @@ -321,7 +321,7 @@ describe('usePathTransition', () => { const nextPath = 'M0,0L30,30'; const { result, rerender } = renderHook( - ({ path }) => + ({ path }: { path: string }) => usePathTransition({ currentPath: path, transitions: { update: null }, @@ -364,7 +364,7 @@ describe('usePathTransition', () => { const path1 = 'M0,0L10,10'; const path2 = 'M0,0L20,20'; - const { result, rerender } = renderHook( + const { result, rerender } = renderHook, { path: string }>( ({ path }) => usePathTransition({ currentPath: path, diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts b/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts index 8d28faf187..2e317e89a4 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts @@ -1,7 +1,7 @@ import type { StyleProp, TextStyle } from 'react-native'; import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useSparklineInteractiveHeaderStyles } from '../useSparklineInteractiveHeaderStyles'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts b/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts index b1f88e1b7f..4eb11b6cce 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts @@ -128,16 +128,15 @@ export function useSparklineInteractiveHeaderStyles() { subHead: ( color: SparklineInteractiveSubHeadIconColor, useFullWidth = true, - ): StyleProp => - [ - typography.label1, - styles.tabularNumbers, - ...(useFullWidth ? [styles.fullWidth] : [{ width: 'auto' }]), - styles.inputReset, - { - color: theme.color[variantColorMap[color]], - }, - ] as TextStyle, + ): StyleProp => [ + typography.label1, + styles.tabularNumbers, + ...(useFullWidth ? [styles.fullWidth] : [{ width: 'auto' as const }]), + styles.inputReset, + { + color: theme.color[variantColorMap[color]], + }, + ], subHeadAccessory: (): StyleProp => [ typography.label2, styles.inputReset, diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx index 0b4720f21c..9faabcd48a 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx @@ -5,7 +5,7 @@ import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useDateLookup } from '@coinbase/cds-common/visualizations/useDateLookup'; import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { TextLabel2 } from '@coinbase/cds-mobile/typography'; +import { Text } from '@coinbase/cds-mobile/typography'; import times from 'lodash/times'; import type { ChartFormatDate, ChartGetMarker } from './SparklineInteractive'; @@ -39,15 +39,16 @@ const SparklineInteractiveMarkerDate: React.FunctionComponent< }, [label.width, label.x]); return ( - {getFormattedDate(x)} - + ); }); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx index 4f11b15def..66d8d31932 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx @@ -4,7 +4,7 @@ import type { LayoutChangeEvent } from 'react-native'; import type { ChartDataPoint, ChartFormatAmount, ChartXFunction } from '@coinbase/cds-common/types'; import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { TextLabel2 } from '@coinbase/cds-mobile/typography'; +import { Text } from '@coinbase/cds-mobile/typography'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; import { useMinMaxTransform } from './useMinMaxTransform'; @@ -68,9 +68,9 @@ const SparklineInteractiveMinMaxContent: React.FunctionComponent< return ( - + {children} - + ); }); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx index d0f4f2e9e6..fecd2e917a 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx @@ -9,7 +9,7 @@ import { Box } from '@coinbase/cds-mobile/layout/Box'; import { HStack } from '@coinbase/cds-mobile/layout/HStack'; import { OverflowGradient } from '@coinbase/cds-mobile/layout/OverflowGradient'; import { Pressable } from '@coinbase/cds-mobile/system/Pressable'; -import { TextLabel1 } from '@coinbase/cds-mobile/typography'; +import { Text } from '@coinbase/cds-mobile/typography'; import { Haptics } from '@coinbase/cds-mobile/utils/haptics'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; @@ -84,9 +84,9 @@ function SparklineInteractivePeriodWithGeneric({ borderRadius={1000} onPress={handleOnPress} > - + {period.label} - +
); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx index 86d4e6236a..eed5afb63b 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx @@ -46,7 +46,7 @@ type SparklineInteractiveContextInterface = { const SparklineInteractiveContext = createContext({ isFallbackVisible: true, markerXPosition: makeMutable(0), - markerGestureState: makeMutable(0), + markerGestureState: makeMutable<0 | 1>(0), showFallback: noop, hideFallback: noop, chartOpacity: new Animated.Value(0), diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts b/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts index 5048b438f1..5a2df42c95 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts @@ -1,6 +1,6 @@ import { Animated } from 'react-native'; import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useMinMaxTransform } from '../useMinMaxTransform'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts b/packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts index 273266cce7..9989cb6ea2 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts @@ -1,5 +1,5 @@ import { Animated } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useInterruptiblePathAnimation } from './useInterruptiblePathAnimation'; diff --git a/packages/mobile/babel.config.cjs b/packages/mobile/babel.config.cjs index 4d232b0f21..73280a634b 100644 --- a/packages/mobile/babel.config.cjs +++ b/packages/mobile/babel.config.cjs @@ -8,9 +8,12 @@ module.exports = { ['@babel/preset-env', { modules: isTestEnv ? 'commonjs' : false, loose: true }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', - ...(isTestEnv || isDetoxEnv ? ['module:metro-react-native-babel-preset'] : []), + // Use babel-preset-expo for test/detox environments. This preset wraps @react-native/babel-preset + // which includes babel-plugin-syntax-hermes-parser for parsing Flow files with 'as' syntax. + // See: https://docs.expo.dev/versions/latest/config/babel/ + ...(isTestEnv || isDetoxEnv ? ['babel-preset-expo'] : []), ], - plugins: isTestEnv || isDetoxEnv ? ['react-native-reanimated/plugin'] : [], + plugins: isTestEnv || isDetoxEnv ? ['react-native-worklets/plugin'] : [], ignore: isTestEnv || isDetoxEnv ? [] diff --git a/packages/mobile/jest.config.js b/packages/mobile/jest.config.js index 9218086799..713b8e70da 100644 --- a/packages/mobile/jest.config.js +++ b/packages/mobile/jest.config.js @@ -27,8 +27,10 @@ const config = { ], coverageReporters: ['json', 'text-summary', 'text', 'json-summary'], // https://docs.swmansion.com/react-native-gesture-handler/docs/guides/testing + // https://docs.swmansion.com/react-native-worklets/docs/guides/testing/ setupFiles: [ '/../../node_modules/react-native-gesture-handler/jestSetup.js', + '/jest/setupWorkletsMock.js', '/jest/jestThrowOnErrorAndWarning.js', ], setupFilesAfterEnv: ['/jest/setup.js'], diff --git a/packages/mobile/jest/accessibility/README.md b/packages/mobile/jest/accessibility/README.md new file mode 100644 index 0000000000..61b39a05c1 --- /dev/null +++ b/packages/mobile/jest/accessibility/README.md @@ -0,0 +1,102 @@ +# Custom Accessibility Engine + +This folder contains a custom accessibility testing engine for React Native components, replacing the unmaintained [`react-native-accessibility-engine`](https://github.com/aryella-lacerda/react-native-accessibility-engine) library. + +## Background + +The original `react-native-accessibility-engine` library provided a `toBeAccessible()` Jest matcher for testing React Native component accessibility. However, the library became incompatible with React 19 due to: + +1. **Deprecated dependency**: The library depends on `react-test-renderer`, which is deprecated in React 19 +2. **Initialization issue**: The library calls `react-test-renderer.create()` at module load time without wrapping it in `act()`, causing test failures in React 19's stricter environment +3. **Unmaintained**: The library has not been updated to address these compatibility issues + +Rather than waiting for an upstream fix, we implemented our own accessibility engine that: + +- Works directly with `@testing-library/react-native` test instances +- Derives types from RNTL exports instead of importing from `react-test-renderer` +- Maintains the same API (`toBeAccessible()` matcher) +- Implements all 10 original accessibility rules + +## Implementation + +The engine checks components against these accessibility rules: + +| Rule ID | Description | +| ------------------------------- | ------------------------------------------------------------ | +| `pressable-role-required` | Pressable components must have an accessibility role | +| `pressable-accessible-required` | Pressable components must not have `accessible={false}` | +| `pressable-label-required` | Pressable components must have a label or text content | +| `disabled-state-required` | Disableable components must expose disabled state | +| `checked-state-required` | Checkbox components must have a checked state | +| `adjustable-role-required` | Slider components must have `accessibilityRole="adjustable"` | +| `adjustable-value-required` | Slider components must have min/max/now values | +| `link-role-required` | Clickable text must have `accessibilityRole="link"` | +| `link-role-misused` | Non-clickable text should not have link role | +| `no-empty-text` | Text components must have content | + +## Intentional Difference from Original Library + +Our implementation includes one intentional improvement over the original library: + +**Extended allowed roles for pressable components** + +The original library's `pressable-role-required` rule only allowed these roles: + +``` +['button', 'link', 'imagebutton', 'radio', 'tab'] +``` + +Our implementation adds `checkbox` and `switch`: + +``` +['button', 'link', 'imagebutton', 'radio', 'tab', 'checkbox', 'switch'] +``` + +**Why this change?** + +The original library's exclusion of `checkbox` appears to be an oversight. The library includes a separate `checked-state-required` rule that specifically targets components with `accessibilityRole="checkbox"`, implying that checkbox is a valid role for pressables. Without `checkbox` in the allowed roles list, a properly implemented checkbox would fail the `pressable-role-required` rule before the `checked-state-required` rule could validate its checked state. + +Similarly, `switch` is a valid React Native accessibility role that semantically represents a toggle control and should be allowed on pressable components. + +## File Structure + +``` +accessibility/ +├── README.md # This file +├── types.ts # Type definitions derived from RNTL +├── helpers.ts # Component type checking utilities +├── rules.ts # Accessibility rule definitions +├── engine.ts # Core accessibility checking logic +├── matchers.ts # Jest matcher implementation +├── index.ts # Module exports and Jest setup +└── __tests__/ + └── rules.test.tsx # Rule validation tests +``` + +## Usage + +The matcher is automatically registered when Jest loads. Use it in tests like: + +```tsx +import { render, screen } from '@testing-library/react-native'; + +it('is accessible', () => { + render(); + expect(screen.getByTestId('test')).toBeAccessible(); +}); +``` + +### Options + +```tsx +// Check only specific rules +expect(element).toBeAccessible({ + rules: ['pressable-role-required', 'pressable-label-required'], +}); + +// Filter violations before assertion +expect(element).toBeAccessible({ + customViolationHandler: (violations) => + violations.filter((v) => !v.problem.includes('some expected issue')), +}); +``` diff --git a/packages/mobile/jest/accessibility/__tests__/rules.test.tsx b/packages/mobile/jest/accessibility/__tests__/rules.test.tsx new file mode 100644 index 0000000000..896aa33e73 --- /dev/null +++ b/packages/mobile/jest/accessibility/__tests__/rules.test.tsx @@ -0,0 +1,318 @@ +// we need to access the custom type definitions for the accessibility matcher +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +import React from 'react'; +import { Pressable, Text, TouchableOpacity, View } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; + +import { checkAccessibility } from '../engine'; + +// Note: When using checkAccessibility, we need to pass a component from the React tree, +// not just the host component. RNTL's screen.getByTestId returns the host component, +// so for violation checking we often need to use a container wrapper. + +describe('Accessibility Rules', () => { + describe('pressable-role-required', () => { + it('fails when pressable has no role', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.length).toBeGreaterThan(0); + expect(violations.some((v) => v.problem.includes("user hasn't been informed"))).toBe(true); + }); + + it('passes when pressable has button role', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes with link role', () => { + render( + + + Link + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('pressable-accessible-required', () => { + it('fails when pressable has accessible=false', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('not accessible'))).toBe(true); + }); + + it('passes when accessible is not set (defaults to true)', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('pressable-label-required', () => { + it('fails when pressable has no label and no text', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('no text content'))).toBe(true); + }); + + it('passes when pressable has accessibilityLabel', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes when pressable has text content', () => { + render( + + + Submit + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('checked-state-required', () => { + it('fails when checkbox has no checked state', () => { + render( + + + Accept + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('checked state'))).toBe(true); + }); + + it('passes when checkbox has checked state', () => { + render( + + + Accept + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes with mixed checked state', () => { + render( + + + Select All + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('link-role-required', () => { + it('fails when clickable text has no link role', () => { + render( + + {}}>Click me + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('clickable'))).toBe(true); + }); + + it('passes when clickable text has link role', () => { + render( + + {}}> + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes when text is not clickable', () => { + render( + + Just text + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('link-role-misused', () => { + it('fails when non-clickable text has link role', () => { + render( + + Not a link + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes("'link' role"))).toBe(true); + }); + + it('passes when text with link role is clickable', () => { + render( + + {}}> + A link + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('no-empty-text', () => { + it('fails when text has no content', () => { + render( + + {''} + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes("doesn't contain text"))).toBe(true); + }); + + it('passes when text has content', () => { + render( + + Hello + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('hidden components', () => { + it('skips hidden components with accessibilityElementsHidden', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + // The button has accessibility issues (no role, no label) but should be skipped because hidden + expect( + violations.filter((v) => v.problem.includes("user hasn't been informed")), + ).toHaveLength(0); + }); + + it('skips components with importantForAccessibility=no-hide-descendants', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect( + violations.filter((v) => v.problem.includes("user hasn't been informed")), + ).toHaveLength(0); + }); + }); + + describe('nested components', () => { + it('checks nested pressables', () => { + render( + + + Button 1 + + + Button 2 + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.length).toBeGreaterThan(0); + expect(violations.some((v) => v.problem.includes("user hasn't been informed"))).toBe(true); + }); + }); + + describe('toBeAccessible matcher', () => { + it('passes for accessible component', () => { + render( + + + Submit + + , + ); + expect(screen.getByTestId('container')).toBeAccessible(); + }); + + it('fails for inaccessible component', () => { + render( + + + + + , + ); + expect(() => { + expect(screen.getByTestId('container')).toBeAccessible(); + }).toThrow(); + }); + }); +}); diff --git a/packages/mobile/jest/accessibility/engine.ts b/packages/mobile/jest/accessibility/engine.ts new file mode 100644 index 0000000000..2043bc4b7b --- /dev/null +++ b/packages/mobile/jest/accessibility/engine.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-restricted-syntax */ +/** + * Accessibility engine that checks React Native components for accessibility violations. + * Works directly with test instances from @testing-library/react-native. + */ +import { getPathToComponent, isHidden } from './helpers'; +import { type Rule, type RuleHelp, rules } from './rules'; +import type { TestInstance } from './types'; + +export interface Violation extends RuleHelp { + pathToComponent: string[]; +} + +export interface EngineOptions { + /** Specific rule IDs to check. If not provided, all rules are checked. */ + rules?: string[]; + /** Custom handler to filter or modify violations before the assertion. */ + customViolationHandler?: (violations: Violation[]) => Violation[]; +} + +/** + * Check a React test instance for accessibility violations. + * + * @param testInstance - The TestInstance to check (from RNTL's screen queries) + * @param options - Optional configuration for which rules to run + * @returns Array of violations found + */ +export function checkAccessibility( + testInstance: TestInstance, + options?: EngineOptions, +): Violation[] { + // Filter rules if specific rule IDs are provided + const rulesToCheck: Rule[] = options?.rules + ? rules.filter((rule) => options.rules?.includes(rule.id)) + : rules; + + const violations: Violation[] = []; + + // For every rule + for (const rule of rulesToCheck) { + // Traverse the component tree below the root to find components that should be tested + const matchedComponents = testInstance.findAll(rule.matcher, { deep: true }); + + // Check if the root of the tree should be tested as well + if (rule.matcher(testInstance)) { + matchedComponents.push(testInstance); + } + + // For all the components that were found + for (const component of matchedComponents) { + let didPassAssertion = false; + + if (isHidden(component)) { + // Skip checks on hidden components + didPassAssertion = true; + } else { + // Check if the component meets the rule's assertion + didPassAssertion = rule.assertion(component); + } + + // If not, add component to violation array + if (!didPassAssertion) { + violations.push({ + pathToComponent: getPathToComponent(component), + ...rule.help, + }); + } + } + } + + return violations; +} diff --git a/packages/mobile/jest/accessibility/helpers.ts b/packages/mobile/jest/accessibility/helpers.ts new file mode 100644 index 0000000000..235d41e4d4 --- /dev/null +++ b/packages/mobile/jest/accessibility/helpers.ts @@ -0,0 +1,198 @@ +/** + * Helper functions for accessibility rules. + * These functions check component types and properties to determine which accessibility rules apply. + */ +import { + Pressable, + Text, + TouchableHighlight, + TouchableNativeFeedback, + TouchableOpacity, + TouchableWithoutFeedback, +} from 'react-native'; + +import type { ComponentType, TestInstance } from './types'; + +// Components to exclude from component name extraction +const COMPONENT_NAME_BLACKLIST = ['String', 'Component', 'Object']; + +// Pressable component type names for string matching +const PRESSABLE_TYPE_NAMES = [ + 'TouchableHighlight', + 'TouchableOpacity', + 'TouchableNativeFeedback', + 'TouchableWithoutFeedback', + 'Pressable', +]; + +/** + * Get the type name from a component type. + * Handles both string types (host components) and function/class types (React components). + */ +function getTypeName(type: ComponentType): string { + if (typeof type === 'string') { + return type; + } + if (typeof type === 'function') { + return (type as { displayName?: string; name?: string }).displayName || type.name || ''; + } + if (typeof type === 'object' && type !== null) { + const objType = type as { displayName?: string; name?: string }; + return objType.displayName || objType.name || ''; + } + return ''; +} + +/** + * Check if a component type is a pressable element. + * Includes TouchableHighlight, TouchableOpacity, TouchableNativeFeedback, + * TouchableWithoutFeedback, and Pressable. + */ +export function isPressable(type: ComponentType): boolean { + // Direct reference comparison for React component instances + if ( + type === TouchableHighlight || + type === TouchableOpacity || + type === TouchableNativeFeedback || + type === TouchableWithoutFeedback || + type === Pressable + ) { + return true; + } + + // String name comparison for host components or named types + const typeName = getTypeName(type); + return PRESSABLE_TYPE_NAMES.some((name) => typeName.includes(name)); +} + +/** + * Check if a component type is a Text element. + */ +export function isText(type: ComponentType): boolean { + // Direct reference comparison + if (type === Text) { + return true; + } + + // String name comparison + const typeName = getTypeName(type); + return typeName === 'Text'; +} + +/** + * Check if a node is an adjustable component (Slider). + * Returns false for wrapper components that contain a Slider. + */ +export function isAdjustable(node: TestInstance): boolean { + const slidersInTree = node.findAll((n) => n.type.toString().includes('Slider')); + // If this node is a Slider BUT more than one slider is found in the tree + // that has this node as root, it means that this node must be a SliderWrapper + // for the actual Slider and should therefore be discarded. + return node.type.toString().includes('Slider') && slidersInTree.length === 1; +} + +/** + * Check if a node is a checkbox (pressable with role="checkbox"). + */ +export function isCheckbox(node: TestInstance): boolean { + return isPressable(node.type) && node.props.accessibilityRole === 'checkbox'; +} + +/** + * Check if a node is hidden from accessibility. + */ +export function isHidden(node: TestInstance): boolean { + return ( + node.props.accessibilityElementsHidden === true || + node.props.importantForAccessibility === 'no-hide-descendants' + ); +} + +/** + * Check if a node can be disabled. + * Returns false for wrapper components that contain disable-able components. + */ +export function canBeDisabled(node: TestInstance): boolean { + const inTree = node.findAll( + (n) => n.props.disabled !== undefined || n.props.enabled !== undefined, + ); + // If this node can be disabled BUT more than one disable-able component + // is found in the tree that has this node as root, it means that this node + // must be a Wrapper for the actual disable-able component and should be discarded. + return ( + (node.props.disabled !== undefined || node.props.enabled !== undefined) && inTree.length === 1 + ); +} + +/** + * Extract the component name from a node's type. + */ +function extractNameFromType(component: TestInstance): string | undefined { + const type = component.type as { displayName?: string; name?: string }; + + if (type.displayName && !COMPONENT_NAME_BLACKLIST.includes(type.displayName)) { + return type.displayName; + } + + if (type.name && !COMPONENT_NAME_BLACKLIST.includes(type.name)) { + return type.name; + } + + return undefined; +} + +/** + * Get the display name of a component. + * Handles wrapped components (Animated, Virtualized) by inspecting children. + */ +export function getComponentName(component: TestInstance): string { + let name = extractNameFromType(component); + + if (!name && component.children.length > 0 && typeof component.children[0] !== 'string') { + // Some components are wrapped in Animated or Virtualized nodes, + // and the main component is the child, not the wrapper, + // so we inspect the child component for name, not the parent. + name = extractNameFromType(component.children[0] as TestInstance); + } + + return name || 'Unknown'; +} + +/** + * Get the path from root to the given component as an array of component names. + */ +export function getPathToComponent(node: TestInstance): string[] { + const path: string[] = []; + let current: TestInstance | null = node; + + while (current) { + const type = current.type; + + // Skip string types and forward refs + const shouldSkip = + typeof type === 'string' || + (typeof type === 'object' && + type !== null && + (type as { $$typeof?: symbol }).$$typeof === Symbol.for('react.forward_ref')); + + if (!shouldSkip) { + path.push(getComponentName(current)); + } + + current = current.parent; + } + + return path.reverse(); +} + +/** + * Find a Text node within a component tree. + * Returns null if no Text node is found. + */ +export function findTextNode(node: TestInstance): TestInstance | null { + try { + return node.findByType(Text); + } catch { + return null; + } +} diff --git a/packages/mobile/jest/accessibility/index.ts b/packages/mobile/jest/accessibility/index.ts new file mode 100644 index 0000000000..ffcf7f7c75 --- /dev/null +++ b/packages/mobile/jest/accessibility/index.ts @@ -0,0 +1,25 @@ +/** + * Custom accessibility testing module for React Native. + * Replaces react-native-accessibility-engine with a React 19-compatible implementation. + * + * Usage: + * Import this module in your Jest setup file to add the toBeAccessible() matcher. + * + * @example + * // In jest/setup.js + * import './accessibility'; + * + * // In tests + * expect(screen.getByTestId('my-button')).toBeAccessible(); + */ +import { toBeAccessible } from './matchers'; + +// Extend Jest's expect with the toBeAccessible matcher +expect.extend({ toBeAccessible }); + +// Export for direct use if needed +export { toBeAccessible }; +export type { EngineOptions, Violation } from './engine'; +export { checkAccessibility } from './engine'; +export type { Rule, RuleHelp } from './rules'; +export { rules } from './rules'; diff --git a/packages/mobile/jest/accessibility/matchers.ts b/packages/mobile/jest/accessibility/matchers.ts new file mode 100644 index 0000000000..293dd900bc --- /dev/null +++ b/packages/mobile/jest/accessibility/matchers.ts @@ -0,0 +1,89 @@ +/** + * Custom Jest matchers for accessibility testing. + */ +import { getLabelPrinter, matcherHint, printExpected, printReceived } from 'jest-matcher-utils'; + +import { checkAccessibility, type EngineOptions, type Violation } from './engine'; +import type { TestInstance } from './types'; + +const LABEL_PROBLEM = 'Problem'; +const LABEL_SOLUTION = 'Solution'; + +/** + * Group violations by their path to component. + */ +function groupViolationsByPath(violations: Violation[]): Record { + const grouped: Record = {}; + for (const violation of violations) { + const key = violation.pathToComponent.join(','); + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push(violation); + } + return grouped; +} + +/** + * Generate a formatted error message for accessibility violations. + */ +function generateErrorMessage(violations: Violation[], isNot: boolean): string { + let errorString = ''; + const matcherName = (isNot ? '.not' : '') + '.toBeAccessible'; + const hint = matcherHint(matcherName, 'component', '') + '\n\n'; + errorString += hint; + + const printLabel = getLabelPrinter(LABEL_PROBLEM, LABEL_SOLUTION); + const violationsGroupedByPath = groupViolationsByPath(violations); + + for (const path in violationsGroupedByPath) { + // Prettify path to component + errorString += path.split(',').join(' > ') + '\n\n'; + + for (const violation of violationsGroupedByPath[path]) { + const violationString = + printLabel(LABEL_PROBLEM) + + printReceived(violation.problem) + + '\n' + + printLabel(LABEL_SOLUTION) + + printExpected(violation.solution) + + '\n\n'; + errorString += violationString; + } + } + + return errorString; +} + +/** + * Jest matcher to check if a component is accessible. + * + * @example + * expect(screen.getByTestId('my-button')).toBeAccessible(); + */ +export function toBeAccessible( + this: jest.MatcherContext, + received: TestInstance, + options?: EngineOptions, +): jest.CustomMatcherResult { + let violations = checkAccessibility(received, options); + + // Apply custom violation handler if provided + if (options?.customViolationHandler) { + violations = options.customViolationHandler(violations); + } + + if (violations.length) { + const message = generateErrorMessage(violations, this.isNot); + return { + pass: false, + message: () => message, + }; + } + + return { + pass: true, + message: () => + 'Component is accessible.\nDoes it make sense to test a component for NOT being accessible?', + }; +} diff --git a/packages/mobile/jest/accessibility/rules.ts b/packages/mobile/jest/accessibility/rules.ts new file mode 100644 index 0000000000..bf5e372c89 --- /dev/null +++ b/packages/mobile/jest/accessibility/rules.ts @@ -0,0 +1,229 @@ +/** + * Accessibility rules for React Native components. + * Each rule has: + * - id: Unique identifier for the rule + * - matcher: Function that determines if a component should be checked + * - assertion: Function that returns true if the component passes the rule + * - help: Object with problem description and solution + */ +import { + canBeDisabled, + findTextNode, + isAdjustable, + isCheckbox, + isPressable, + isText, +} from './helpers'; +import type { TestInstance } from './types'; + +export type RuleHelp = { + problem: string; + solution: string; + link: string; +}; + +export type Rule = { + id: string; + matcher: (node: TestInstance) => boolean; + assertion: (node: TestInstance) => boolean; + help: RuleHelp; +}; + +const ALLOWED_PRESSABLE_ROLES = [ + 'button', + 'link', + 'imagebutton', + 'radio', + 'tab', + 'checkbox', + 'switch', +]; +const ALLOWED_PRESSABLE_ROLES_MESSAGE = ALLOWED_PRESSABLE_ROLES.join(' or '); + +const ALLOWED_CHECKED_VALUES = [true, false, 'mixed']; +const ALLOWED_CHECKED_VALUES_MESSAGE = ALLOWED_CHECKED_VALUES.join(' or '); + +/** + * Pressable components must have an accessibility role. + */ +const pressableRoleRequired: Rule = { + id: 'pressable-role-required', + matcher: (node) => isPressable(node.type), + assertion: (node) => ALLOWED_PRESSABLE_ROLES.includes(node.props.accessibilityRole), + help: { + problem: + "This component is pressable but the user hasn't been informed that it behaves like a button/link/radio", + solution: `Set the 'accessibilityRole' prop to ${ALLOWED_PRESSABLE_ROLES_MESSAGE}`, + link: '', + }, +}; + +/** + * Pressable components must be accessible (not have accessible=false). + */ +const pressableAccessibleRequired: Rule = { + id: 'pressable-accessible-required', + matcher: (node) => isPressable(node.type), + assertion: (node) => node.props.accessible !== false, + help: { + problem: 'This button is not accessible (selectable) to the user', + solution: + "Set the 'accessible' prop to 'true' or remove it (pressables are accessible by default)", + link: '', + }, +}; + +/** + * Pressable components must have a label (either from text content or accessibilityLabel). + */ +const pressableLabelRequired: Rule = { + id: 'pressable-label-required', + matcher: (node) => isPressable(node.type), + assertion: (node) => { + const textNode = findTextNode(node); + const textContent = textNode?.props?.children; + const accessibilityLabel = node.props.accessibilityLabel; + + if (!accessibilityLabel && !textContent) { + return false; + } + return true; + }, + help: { + problem: + "This pressable has no text content, so an accessibility label can't be automatically inferred", + solution: "Place a text component in the button or define an 'accessibilityLabel' prop", + link: '', + }, +}; + +/** + * Components with disabled/enabled props must expose disabled state. + */ +const disabledStateRequired: Rule = { + id: 'disabled-state-required', + matcher: (node) => canBeDisabled(node), + assertion: (node) => node.props.accessibilityState?.disabled !== undefined, + help: { + problem: "This component has a disabled state but it isn't exposed to the user", + solution: "Set the 'accessibilityState' prop to an object containing a boolean 'disabled' key", + link: '', + }, +}; + +/** + * Checkbox components must have a checked state. + */ +const checkedStateRequired: Rule = { + id: 'checked-state-required', + matcher: (node) => isCheckbox(node), + assertion: (node) => ALLOWED_CHECKED_VALUES.includes(node.props.accessibilityState?.checked), + help: { + problem: + "This component has an accessibility role of 'checkbox' but doesn't have a checked state", + solution: `Set the 'accessibilityState' prop to an object like this: { checked: ${ALLOWED_CHECKED_VALUES_MESSAGE} }`, + link: 'https://www.w3.org/WAI/ARIA/apg/example-index/checkbox/checkbox.html', + }, +}; + +/** + * Adjustable components (Slider) must have accessibilityRole="adjustable". + */ +const adjustableRoleRequired: Rule = { + id: 'adjustable-role-required', + matcher: (node) => isAdjustable(node), + assertion: (node) => node.props.accessibilityRole === 'adjustable', + help: { + problem: "This component has an adjustable value but the user wasn't informed of this", + solution: "Set the 'accessibilityRole' prop to 'adjustable'", + link: '', + }, +}; + +/** + * Adjustable components must have accessibilityValue with min, max, and now. + */ +const adjustableValueRequired: Rule = { + id: 'adjustable-value-required', + matcher: (node) => isAdjustable(node), + assertion: (node) => { + const value = node.props.accessibilityValue; + return value?.now !== undefined && value?.min !== undefined && value?.max !== undefined; + }, + help: { + problem: + "This component has an adjustable value but the user wasn't informed of its min, max, and current value", + solution: "Set the 'accessibilityValue' prop to an object: { min: ?, max: ?, now: ?}", + link: '', + }, +}; + +/** + * Clickable text must have accessibilityRole="link". + */ +const linkRoleRequired: Rule = { + id: 'link-role-required', + matcher: (node) => isText(node.type), + assertion: (node) => { + const { onPress, accessibilityRole } = node.props; + if (onPress) { + return accessibilityRole === 'link'; + } + return true; + }, + help: { + problem: "The text is clickable, but the user wasn't informed that it behaves like a link", + solution: "Set the 'accessibilityRole' prop to 'link' or remove the 'onPress' prop", + link: '', + }, +}; + +/** + * Non-clickable text should not have accessibilityRole="link". + */ +const linkRoleMisused: Rule = { + id: 'link-role-misused', + matcher: (node) => isText(node.type), + assertion: (node) => { + const { onPress, accessibilityRole } = node.props; + if (!onPress) { + return accessibilityRole !== 'link'; + } + return true; + }, + help: { + problem: "The 'link' role has been used but the text isn't clickable", + solution: "Set the 'accessibilityRole' prop to 'text' or add an 'onPress' prop", + link: '', + }, +}; + +/** + * Text components must have text content. + */ +const noEmptyText: Rule = { + id: 'no-empty-text', + matcher: (node) => isText(node.type), + assertion: (node) => !!node.props?.children, + help: { + problem: "This text node doesn't contain text and so no accessibility label can be inferred", + solution: 'Add text content or prevent this component from rendering if it has no content', + link: '', + }, +}; + +/** + * All accessibility rules in the order they should be applied. + */ +export const rules: Rule[] = [ + pressableRoleRequired, + pressableAccessibleRequired, + disabledStateRequired, + checkedStateRequired, + pressableLabelRequired, + adjustableRoleRequired, + adjustableValueRequired, + linkRoleRequired, + linkRoleMisused, + noEmptyText, +]; diff --git a/packages/mobile/jest/accessibility/types.ts b/packages/mobile/jest/accessibility/types.ts new file mode 100644 index 0000000000..b75690fceb --- /dev/null +++ b/packages/mobile/jest/accessibility/types.ts @@ -0,0 +1,18 @@ +/** + * Type definitions for the accessibility engine. + * + * These types are derived from @testing-library/react-native's exports + * to avoid importing from the deprecated react-test-renderer package. + */ +import type { RenderResult } from '@testing-library/react-native'; + +/** + * A node in the React test instance tree. + * Derived from RNTL's RenderResult['root'] type. + */ +export type TestInstance = RenderResult['root']; + +/** + * The type of a React component in the test tree. + */ +export type ComponentType = TestInstance['type']; diff --git a/packages/mobile/jest/setup.js b/packages/mobile/jest/setup.js index c6c68fdb74..c6719766f8 100644 --- a/packages/mobile/jest/setup.js +++ b/packages/mobile/jest/setup.js @@ -2,18 +2,28 @@ * NOTE: If you add imports here that extend Jest, such as extending `expect` with new * functions like `.toBeAccessible()`, you must also update `packages/mobile/src/jest.d.ts` */ -import 'react-native-gesture-handler/jestSetup'; -import 'react-native-accessibility-engine'; -import '@testing-library/jest-native/extend-expect'; +import './accessibility'; -import { setUpTests } from 'react-native-reanimated/src/jestUtils'; +// https://docs.swmansion.com/react-native-reanimated/docs/guides/testing/ +const { + setUpTests, + configureReanimatedLogger, + ReanimatedLogLevel, +} = require('react-native-reanimated'); -import { mockStatusBarHeight } from '../src/hooks/__tests__/constants'; - -jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); +// Must mock NativeEventEmitter at the internal module path not in main RN mock below +jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => { + const MockNativeEventEmitter = class MockNativeEventEmitter { + addListener = jest.fn(() => ({ remove: jest.fn() })); + removeListener = jest.fn(); + removeAllListeners = jest.fn(); + }; + // Export as both default and the class itself for different import styles + MockNativeEventEmitter.default = MockNativeEventEmitter; + return MockNativeEventEmitter; +}); -// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing -jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); +jest.mock('react-native/src/private/animated/NativeAnimatedHelper'); jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); @@ -27,10 +37,6 @@ jest.mock('react-native', () => { RN.PixelRatio.getPixelSizeForLayoutSize = jest.fn((layoutSize) => Math.round(layoutSize * 1)); RN.PixelRatio.startDetecting = jest.fn(); - RN.NativeModules.StatusBarManager = { - getHeight: jest.fn((cb) => cb({ height: mockStatusBarHeight })), - }; - RN.Animated.loop = jest.fn(() => { return { start: jest.fn(), @@ -66,4 +72,16 @@ jest.mock('react-native', () => { return RN; }); +/* + React Reanimated 4.x setup: +*/ + +// Disable strict mode to prevent warnings about writing to shared values during render +// This is needed because some components (e.g., TabsActiveIndicator) use patterns that +// trigger warnings in reanimated 4.x strict mode but still work correctly +configureReanimatedLogger({ + level: ReanimatedLogLevel.warn, + strict: false, +}); + setUpTests(); diff --git a/packages/mobile/jest/setupWorkletsMock.js b/packages/mobile/jest/setupWorkletsMock.js new file mode 100644 index 0000000000..681bc6223c --- /dev/null +++ b/packages/mobile/jest/setupWorkletsMock.js @@ -0,0 +1,3 @@ +// Mock react-native-worklets before any reanimated imports +// The built-in mock at lib/module/mock is not available until later versions: 0.7.X +jest.mock('react-native-worklets', () => require('./workletsMock')); diff --git a/packages/mobile/jest/workletsMock.js b/packages/mobile/jest/workletsMock.js new file mode 100644 index 0000000000..791d413330 --- /dev/null +++ b/packages/mobile/jest/workletsMock.js @@ -0,0 +1,111 @@ +/** + * Mock for react-native-worklets 0.5.2 + * The built-in mock at lib/module/mock is not available until later versions: 0.7.X, + * Following CMR's version recommendation on versions we are staying with 0.5.2 and reanimated 4.1.1 for now + * This mock is based on the official mock from: + * https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-worklets/src/mock.ts + */ + +'use strict'; + +const NOOP = () => {}; +const NOOP_FACTORY = () => NOOP; +const IDENTITY = (value) => value; +const IMMEDIATE_CALLBACK_INVOCATION = (callback) => callback(); + +const RuntimeKind = { + ReactNative: 'RN', + UI: 'UI', + Worklet: 'Worklet', +}; + +// Mocked requestAnimationFrame that uses setTimeout and passes timestamp +// This fixes Jest's React Native setup which doesn't pass timestamps to callbacks +// See: https://github.com/facebook/react-native/blob/main/packages/react-native/jest/setup.js#L28 +const mockedRequestAnimationFrame = (callback) => { + return setTimeout(() => callback(performance.now()), 0); +}; + +// Set up global properties that reanimated expects from the native runtime +global._WORKLET = false; +global.__RUNTIME_KIND = RuntimeKind.ReactNative; +global._log = console.log; +global._getAnimationTimestamp = () => performance.now(); +global.__flushAnimationFrame = NOOP; +global.requestAnimationFrame = mockedRequestAnimationFrame; + +const WorkletAPI = { + isShareableRef: () => true, + makeShareable: IDENTITY, + makeShareableCloneOnUIRecursive: IDENTITY, + makeShareableCloneRecursive: IDENTITY, + shareableMappingCache: new Map(), + + getStaticFeatureFlag: () => false, + setDynamicFeatureFlag: NOOP, + + isSynchronizable: () => false, + + getRuntimeKind: () => RuntimeKind.ReactNative, + RuntimeKind, + + createWorkletRuntime: NOOP_FACTORY, + runOnRuntime: IDENTITY, + runOnRuntimeAsync(workletRuntime, worklet, ...args) { + return WorkletAPI.runOnUIAsync(worklet, ...args); + }, + scheduleOnRuntime: IMMEDIATE_CALLBACK_INVOCATION, + + createSerializable: IDENTITY, + isSerializableRef: IDENTITY, + serializableMappingCache: new Map(), + + createSynchronizable: IDENTITY, + + callMicrotasks: NOOP, + executeOnUIRuntimeSync: IDENTITY, + + runOnJS(fun) { + return (...args) => queueMicrotask(args.length ? () => fun(...args) : fun); + }, + + runOnUI(worklet) { + return (...args) => { + // In Jest environment we schedule work via mockedRequestAnimationFrame + // to ensure it runs when timers are advanced + mockedRequestAnimationFrame(() => { + worklet(...args); + }); + }; + }, + + runOnUIAsync(worklet, ...args) { + return new Promise((resolve) => { + mockedRequestAnimationFrame(() => { + const result = worklet(...args); + resolve(result); + }); + }); + }, + + runOnUISync: IMMEDIATE_CALLBACK_INVOCATION, + + scheduleOnRN(fun, ...args) { + WorkletAPI.runOnJS(fun)(...args); + }, + + scheduleOnUI(worklet, ...args) { + WorkletAPI.runOnUI(worklet)(...args); + }, + + unstable_eventLoopTask: NOOP_FACTORY, + + isWorkletFunction: () => false, + + WorkletsModule: {}, +}; + +module.exports = { + __esModule: true, + ...WorkletAPI, +}; diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 0f1795be25..e16975d3a7 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -181,20 +181,16 @@ "CHANGELOG" ], "peerDependencies": { - "@react-navigation/native": "^6.1.6", - "@react-navigation/native-stack": "^6.9.26", - "@react-navigation/stack": "^6.3.16", - "lottie-react-native": "^6.7.0", - "react": "^18.3.1", - "react-native": "^0.74.5", - "react-native-gesture-handler": "^2.16.2", + "lottie-react-native": "7.3.1", + "react": "~19.1.2", + "react-native": "~0.81.5", + "react-native-gesture-handler": "2.28.0", "react-native-inappbrowser-reborn": "^3.7.0", - "react-native-linear-gradient": "^2.8.3", "react-native-navigation-bar-color": "^2.0.2", - "react-native-reanimated": "^3.14.0", - "react-native-safe-area-context": "^4.10.5", - "react-native-screens": "^3.32.0", - "react-native-svg": "^14.1.0" + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" }, "dependencies": { "@coinbase/cds-common": "workspace:^", @@ -203,7 +199,7 @@ "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-utils": "workspace:^", "@floating-ui/react-native": "^0.10.5", - "@react-spring/native": "^9.7.4", + "@react-spring/native": "^10.0.3", "fuse.js": "^7.1.0", "lodash": "^4.17.21", "type-fest": "^2.19.0" @@ -211,25 +207,22 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@react-native-community/netinfo": "^7.1.7", - "@react-navigation/native-stack": "^6.9.26", - "@testing-library/react-native": "^11.3.0", + "@testing-library/react-native": "^13.3.3", "@types/d3-color": "^3.1.3", - "@types/react": "^18.3.12", - "@types/react-test-renderer": "^18.3.0", - "eslint-plugin-reanimated": "^2.0.1", - "lottie-react-native": "6.7.0", - "react-native-accessibility-engine": "^3.2.0", - "react-native-gesture-handler": "2.16.2", + "@types/react": "19.1.2", + "lottie-react-native": "7.3.1", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-gesture-handler": "2.28.0", "react-native-inappbrowser-reborn": "3.7.0", - "react-native-linear-gradient": "2.8.3", "react-native-navigation-bar-color": "2.0.2", - "react-native-reanimated": "3.14.0", - "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.32.0", - "react-native-svg": "14.1.0", - "react-test-renderer": "^18.3.1" + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2", + "react-test-renderer": "19.1.2" } } diff --git a/packages/mobile/src/accordion/AccordionItem.tsx b/packages/mobile/src/accordion/AccordionItem.tsx index 482b0f0def..8af8b90df5 100644 --- a/packages/mobile/src/accordion/AccordionItem.tsx +++ b/packages/mobile/src/accordion/AccordionItem.tsx @@ -11,8 +11,8 @@ import { AccordionPanel, type AccordionPanelBaseProps } from './AccordionPanel'; export type AccordionItemBaseProps = Pick & Omit & Omit & { - headerRef?: React.RefObject; - panelRef?: React.RefObject; + headerRef?: React.RefObject; + panelRef?: React.RefObject; }; export type AccordionItemProps = AccordionItemBaseProps; diff --git a/packages/mobile/src/alpha/combobox/Combobox.tsx b/packages/mobile/src/alpha/combobox/Combobox.tsx index 74fd9c6968..b2331df59d 100644 --- a/packages/mobile/src/alpha/combobox/Combobox.tsx +++ b/packages/mobile/src/alpha/combobox/Combobox.tsx @@ -13,6 +13,7 @@ import { KeyboardAvoidingView, Platform, type TextInput, View } from 'react-nati import Fuse from 'fuse.js'; import { Button } from '../../buttons/Button'; +import { useSafeBottomPadding } from '../../hooks/useSafeBottomPadding'; import { Box } from '../../layout'; import { StickyFooter } from '../../sticky-footer/StickyFooter'; import { DefaultSelectControl } from '../select/DefaultSelectControl'; @@ -72,7 +73,7 @@ export type ComboboxControlProps< /** Search text change handler */ onSearch: (searchText: string) => void; /** Reference to the search input */ - searchInputRef: React.RefObject; + searchInputRef: React.RefObject; /** Reference to the combobox control for positioning */ controlRef: React.RefObject; /** Custom SelectControlComponent to wrap */ @@ -247,6 +248,7 @@ const ComboboxBase = memo( ); const searchInputRef = useRef(null); + const safeBottomPadding = useSafeBottomPadding(); const handleTrayVisibilityChange = useCallback((visibility: 'visible' | 'hidden') => { if (visibility === 'visible') { searchInputRef.current?.focus(); @@ -277,7 +279,7 @@ const ComboboxBase = memo( footer={({ handleClose }) => ( , { scenario }); }); diff --git a/packages/mobile/src/buttons/__tests__/Button.test.tsx b/packages/mobile/src/buttons/__tests__/Button.test.tsx index a45021136b..73bebbdceb 100644 --- a/packages/mobile/src/buttons/__tests__/Button.test.tsx +++ b/packages/mobile/src/buttons/__tests__/Button.test.tsx @@ -1,4 +1,4 @@ -import { Animated, Pressable } from 'react-native'; +import { Animated } from 'react-native'; import { useEventHandler } from '@coinbase/cds-common/hooks/useEventHandler'; import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -31,16 +31,6 @@ describe('Button', () => { expect(screen.UNSAFE_queryAllByType(Animated.View)).toHaveLength(1); }); - it('renders a pressable', () => { - render( - - - , - ); - - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); - }); - it('renders children text', () => { render( diff --git a/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx b/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx index 455e4d31a9..da82defa6d 100644 --- a/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx +++ b/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx @@ -52,7 +52,7 @@ describe('SlideButton', () => { it('renders correctly', () => { render(); - expect(screen.getByText(uncheckedLabel)).toBeTruthy(); + expect(screen.getByText(uncheckedLabel, { includeHiddenElements: true })).toBeTruthy(); }); it('is accessible', () => { @@ -177,7 +177,7 @@ describe('SlideButton', () => { describe('compact variant', () => { it('renders correctly with compact prop', () => { render(); - expect(screen.getByText(uncheckedLabel)).toBeTruthy(); + expect(screen.getByText(uncheckedLabel, { includeHiddenElements: true })).toBeTruthy(); }); it('applies compact height of 40px', () => { diff --git a/packages/mobile/src/cards/Card.tsx b/packages/mobile/src/cards/Card.tsx index 96678f0013..25b3a2fcbe 100644 --- a/packages/mobile/src/cards/Card.tsx +++ b/packages/mobile/src/cards/Card.tsx @@ -24,6 +24,10 @@ export type CardBaseProps = Pick< pressableProps?: Omit; }; +/** + * @deprecated Use `ContentCard`, `MediaCard`, `MessagingCard`, or `DataCard` based on your use case. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardProps = CardBaseProps & BoxProps; const getBorderRadiusPinStyle = (borderRadius: number) => ({ @@ -58,6 +62,10 @@ const getBorderRadiusPinStyle = (borderRadius: number) => ({ all: {}, }); +/** + * @deprecated Use `ContentCard`, `MediaCard`, `MessagingCard`, or `DataCard` based on your use case. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const Card = memo(function OldCard({ children, background = 'bg', diff --git a/packages/mobile/src/cards/CardMedia.tsx b/packages/mobile/src/cards/CardMedia.tsx index f7bb277370..1d66a519c9 100644 --- a/packages/mobile/src/cards/CardMedia.tsx +++ b/packages/mobile/src/cards/CardMedia.tsx @@ -30,23 +30,9 @@ const imageProps: Record = { export const CardMedia = memo(function CardMedia({ placement = 'end', ...props }: CardMediaProps) { switch (props.type) { case 'spotSquare': - return ( - - ); + return ; case 'pictogram': - return ( - - ); + return ; case 'image': return ( { - const TextEndLabel = progressVariant === 'bar' ? TextLabel2 : TextBody; + const endLabelFont = progressVariant === 'bar' ? 'label2' : 'body'; return ( {!!startLabelProp && ( - {startLabelProp} + + {startLabelProp} + )} {!!endLabelProp && ( - + {endLabelProp} - + )} ); diff --git a/packages/mobile/src/cards/LikeButton.tsx b/packages/mobile/src/cards/LikeButton.tsx index b3f55c0b92..19a3378405 100644 --- a/packages/mobile/src/cards/LikeButton.tsx +++ b/packages/mobile/src/cards/LikeButton.tsx @@ -7,11 +7,11 @@ import { scaleInConfig, scaleOutConfig, } from '@coinbase/cds-common/animation/likeButton'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpacingProps'; import { convertMotionConfig } from '../animation/convertMotionConfig'; +import { useTheme } from '../hooks/useTheme'; import { TextIcon } from '../icons/TextIcon'; import { HStack } from '../layout/HStack'; import type { PressableProps } from '../system/Pressable'; @@ -25,7 +25,7 @@ export type LikeButtonBaseProps = Pick< SharedProps & { liked?: boolean; count?: number; - /** Reduce the inner padding within the button itself. */ + /** Use the compact variant. */ compact?: boolean; /** Ensure the button aligns flush on the left or right. * This prop will translate the entire button left/right, @@ -42,6 +42,7 @@ const scaleOut = convertMotionConfig(scaleOutConfig); export const LikeButton = memo(function LikeButton({ count = 0, compact = true, + padding = compact ? 1.5 : 2, // mirror IconButton's padding flush, liked = false, onPress, @@ -52,7 +53,7 @@ export const LikeButton = memo(function LikeButton({ }: LikeButtonProps) { const iconScale = useRef(new Animated.Value(1)); const iconSize = compact ? 's' : 'm'; - const size = interactableHeight[compact ? 'compact' : 'regular']; + const theme = useTheme(); const { marginStart, marginEnd } = getButtonSpacingProps({ compact, flush }); @@ -79,6 +80,12 @@ export const LikeButton = memo(function LikeButton({ [], ); + // override default line height to match the height of the sibling icon + const countTextStyle = useMemo( + () => ({ lineHeight: theme.iconSize[iconSize] }), + [theme.iconSize, iconSize], + ); + return ( {count > 0 ? ( - + {count} ) : null} diff --git a/packages/mobile/src/cards/NudgeCard.tsx b/packages/mobile/src/cards/NudgeCard.tsx index 41ef356313..1e0759aa15 100644 --- a/packages/mobile/src/cards/NudgeCard.tsx +++ b/packages/mobile/src/cards/NudgeCard.tsx @@ -13,6 +13,7 @@ import type { import { IconButton } from '../buttons'; import { Pictogram } from '../illustrations/Pictogram'; import { Box, HStack, VStack } from '../layout'; +import type { StyleProps } from '../styles/styleProps'; import { Pressable } from '../system/Pressable'; import { Text } from '../typography/Text'; @@ -104,7 +105,11 @@ export const NudgeCard = memo( background = 'bgAlternate', onPress, maxWidth, - ...props + maxHeight, + minHeight, + minWidth, + height, + aspectRatio, }: NudgeCardProps) => { const hasMedia = pictogram || media; const paddingBottom = action ? 1 : 2; @@ -135,11 +140,11 @@ export const NudgeCard = memo( background={background} borderColor="transparent" borderRadius={500} - maxWidth={maxWidth} + maxWidth={maxWidth as StyleProps['maxWidth']} paddingEnd={onDismissPress ? 3 : 0} position="relative" testID={testID} - width={width} + width={width as StyleProps['width']} > {onDismissPress ? ( // zIndex is required otherwise CardBody sits on top of it @@ -159,14 +164,24 @@ export const NudgeCard = memo( {/* ported over from CardBody */} {hasMedia && mediaPosition === 'left' ? renderMedia : null} - + {typeof title === 'string' ? ( & - Pick & { + Pick & + Pick & { /** Callback fired when the action button is pressed */ onActionPress?: PressableProps['onPress']; /** Callback fired when the dismiss button is pressed */ @@ -38,7 +39,8 @@ export type UpsellCardBaseProps = SharedProps & */ background?: ThemeVars.Color; /** - * @danger This is a migration escape hatch. It is not intended to be used normally. + * @deprecated Use `style` or `background` to customize card background. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ dangerouslySetBackground?: string; }; @@ -87,6 +89,7 @@ export const UpsellCard = memo( accessibilityLabel, width = upsellCardDefaultWidth, onPress, + style, }: UpsellCardProps) => { const content = ( ); diff --git a/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx b/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx index 25f80ec9ba..173b3bc6e9 100644 --- a/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx @@ -88,8 +88,8 @@ const ContainedAssetCardScreen = () => { description={ {subheadIconSignMap.upwardTrend}6.37% @@ -136,7 +136,7 @@ const ContainedAssetCardScreen = () => { /> - + ); diff --git a/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx b/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx index 49f0a25117..4aafac9450 100644 --- a/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx @@ -315,7 +315,7 @@ const ContentCardScreen = () => { - @@ -346,7 +346,7 @@ const ContentCardScreen = () => { - @@ -364,7 +364,7 @@ const ContentCardScreen = () => { - @@ -434,7 +434,7 @@ const ContentCardScreen = () => { - @@ -470,7 +470,7 @@ const ContentCardScreen = () => { - @@ -506,7 +506,7 @@ const ContentCardScreen = () => { - @@ -537,7 +537,7 @@ const ContentCardScreen = () => { - diff --git a/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx index fbace0dd87..1977ac17f7 100644 --- a/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx @@ -7,7 +7,6 @@ import { Carousel } from '../../carousel/Carousel'; import { CarouselItem } from '../../carousel/CarouselItem'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { RemoteImage } from '../../media/RemoteImage'; -import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography'; import { Text } from '../../typography/Text'; import type { MediaCardProps } from '../MediaCard'; import { MediaCard } from '../MediaCard'; @@ -85,15 +84,19 @@ const MediaCardScreen = () => { + Custom description with bold text and{' '} italic text - + } media={exampleMedia} - subtitle={Custom Subtitle} + subtitle={ + + Custom Subtitle + + } thumbnail={exampleThumbnail} - title={Custom Title} + title={Custom Title} /> diff --git a/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx b/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx index 6e7e940285..142f1cb287 100644 --- a/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx @@ -64,17 +64,17 @@ const UpsellCardScreen = () => { return ( - + - + @@ -86,14 +86,14 @@ const UpsellCardScreen = () => { Sign up } - dangerouslySetBackground={customTextNodeBackgroundColor} description={ - + Start your free 30 day trial of Coinbase One } + style={{ backgroundColor: customTextNodeBackgroundColor }} title={ - + Coinbase One } @@ -102,21 +102,21 @@ const UpsellCardScreen = () => { + Start your free 30 day trial of Coinbase One } + style={{ backgroundColor: customBackgroundColor }} title={ - + Coinbase One } /> - + Carousel @@ -126,19 +126,19 @@ const UpsellCardScreen = () => { , , , ]} diff --git a/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx b/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx index 998705abf4..b763481fec 100644 --- a/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx +++ b/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx @@ -80,7 +80,7 @@ describe('ContainedAssetCard', () => { } - maxWidth="none" + maxWidth={500} minWidth={120} subtitle="Subtitle" testID="card" @@ -89,6 +89,6 @@ describe('ContainedAssetCard', () => { , ); - expect(screen.getByTestId('card')).toHaveStyle({ maxWidth: 'none', minWidth: 120 }); + expect(screen.getByTestId('card')).toHaveStyle({ maxWidth: 500, minWidth: 120 }); }); }); diff --git a/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx b/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx index b90c33157d..a5e48084b9 100644 --- a/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx +++ b/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx @@ -81,10 +81,10 @@ describe('UpsellCard', () => { expect(screen.getByTestId(`${exampleProps.testID}-dismiss-button`)).toBeDefined(); }); - it('renders dangerouslySetBackground', () => { + it('renders custom background via style prop', () => { render( - + , ); expect(screen.getByTestId(exampleProps.testID as string)).toHaveStyle({ diff --git a/packages/mobile/src/carousel/Carousel.tsx b/packages/mobile/src/carousel/Carousel.tsx index 18e2e55947..047b9ded89 100644 --- a/packages/mobile/src/carousel/Carousel.tsx +++ b/packages/mobile/src/carousel/Carousel.tsx @@ -44,7 +44,7 @@ const wrap = (min: number, max: number, value: number): number => { return min + ((((value - min) % range) + range) % range); }; -export type CarouselItemRenderChildren = React.FC<{ isVisible: boolean }>; +export type CarouselItemRenderChildren = (args: { isVisible: boolean }) => React.ReactNode; export type CarouselItemBaseProps = Omit & SharedAccessibilityProps & { @@ -129,10 +129,10 @@ export type CarouselPaginationComponentBaseProps = { paginationAccessibilityLabel?: string | ((pageIndex: number) => string); /** * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead + * When omitted, the default pagination component renders the current dot-style design. + * @default 'dot' + * @deprecated Prefer the default dot pagination or provide a custom `PaginationComponent`. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ variant?: 'pill' | 'dot'; }; @@ -194,6 +194,11 @@ export type CarouselBaseProps = SharedProps & * Hides the pagination indicators (dots/bars showing current page). */ hidePagination?: boolean; + /** + * @deprecated Use the default dot pagination, or provide a custom `PaginationComponent` if you need custom visuals. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + paginationVariant?: CarouselPaginationComponentBaseProps['variant']; /** * Custom component to render navigation arrows. * @default DefaultCarouselNavigation @@ -263,14 +268,6 @@ export type CarouselBaseProps = SharedProps & * @default 3000 (3 seconds) */ autoplayInterval?: number; - /** - * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead - */ - paginationVariant?: CarouselPaginationComponentBaseProps['variant']; }; export type CarouselProps = CarouselBaseProps & { @@ -556,6 +553,7 @@ export const Carousel = memo( title, hideNavigation, hidePagination, + paginationVariant, drag = 'snap', snapMode = 'page', NavigationComponent = DefaultCarouselNavigation, @@ -573,7 +571,6 @@ export const Carousel = memo( loop, autoplay, autoplayInterval = 3000, - paginationVariant, ...props }: CarouselProps, ref: React.ForwardedRef, @@ -1165,7 +1162,7 @@ export const Carousel = memo( {(title || !hideNavigation) && ( {typeof title === 'string' ? ( - + {title} ) : ( diff --git a/packages/mobile/src/carousel/DefaultCarouselPagination.tsx b/packages/mobile/src/carousel/DefaultCarouselPagination.tsx index e45e2f1e4e..edaa47f6b2 100644 --- a/packages/mobile/src/carousel/DefaultCarouselPagination.tsx +++ b/packages/mobile/src/carousel/DefaultCarouselPagination.tsx @@ -50,6 +50,7 @@ const PaginationPill = memo(function PaginationPill({ background={isActive ? 'bgPrimary' : 'bgLine'} borderColor="transparent" borderRadius={100} + borderWidth={0} height={INDICATOR_HEIGHT} onPress={onPress} style={style} @@ -173,7 +174,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination style, styles, paginationAccessibilityLabel = defaultPaginationAccessibilityLabel, - variant = 'pill', + variant = 'dot', }: DefaultCarouselPaginationProps) { const theme = useTheme(); const isDot = variant === 'dot'; @@ -190,7 +191,12 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination : paginationAccessibilityLabel; return ( - + {totalPages > 0 ? ( Array.from({ length: totalPages }, (_, index) => isDot ? ( diff --git a/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx b/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx index 26597b66f8..e10a44c36d 100644 --- a/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx +++ b/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx @@ -18,7 +18,7 @@ figma.connect( }), }, example: ({ title, hidePagination }) => ( - + {/* Item content */} {/* Item content */} {/* Item content */} diff --git a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx index 35c54bd461..2ed4cb3a50 100644 --- a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx +++ b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx @@ -134,8 +134,6 @@ const BasicExamples = () => { <> { @@ -173,7 +169,6 @@ const BasicExamples = () => { loop NavigationComponent={SeeAllComponent} drag="free" - paginationVariant="dot" snapMode="item" styles={{ root: { paddingHorizontal: horizontalPadding }, @@ -195,7 +190,6 @@ const BasicExamples = () => { { { disabled={!canGoPrevious} name="caretLeft" onPress={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -371,7 +364,6 @@ const AutoplayExample = () => { { { return ( { { autoplay loop drag="snap" - paginationVariant="dot" snapMode="item" styles={{ root: { paddingHorizontal: horizontalPadding }, diff --git a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx index a76580529c..d304996017 100644 --- a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx +++ b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx @@ -271,16 +271,47 @@ describe('Carousel', () => { render(); - expect(mockNavigation).toHaveBeenCalledWith( + expect(mockNavigation).toHaveBeenCalled(); + expect(mockNavigation.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ onGoNext: expect.any(Function), onGoPrevious: expect.any(Function), disableGoNext: expect.any(Boolean), disableGoPrevious: expect.any(Boolean), }), - {}, ); }); + + it('does not pass a pagination variant by default', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render(); + + await waitFor(() => { + expect( + mockPagination.mock.calls.some((call) => { + const props = call[0]; + return props !== undefined && props.variant === undefined; + }), + ).toBe(true); + }); + }); + + it('forwards deprecated paginationVariant to custom pagination components', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render( + , + ); + + await waitFor(() => { + expect(mockPagination.mock.calls.some((call) => call[0]?.variant === 'pill')).toBe(true); + }); + }); }); describe('Accessibility', () => { @@ -1181,7 +1212,7 @@ describe('Carousel', () => { fireEvent.press(screen.getByTestId('get-current-page')); - expect(screen.getByTestId('current-page-display')).toHaveTextContent('Page 1 of'); + expect(screen.getByTestId('current-page-display')).toHaveTextContent(/Page 1 of/); fireEvent.press(screen.getByTestId('go-to-page-2')); @@ -1194,7 +1225,7 @@ describe('Carousel', () => { fireEvent.press(screen.getByTestId('get-current-page')); - expect(screen.getByTestId('current-page-display')).toHaveTextContent('Page 1 of'); + expect(screen.getByTestId('current-page-display')).toHaveTextContent(/Page 1 of/); }); }); @@ -1215,9 +1246,13 @@ describe('Carousel', () => { , ); - expect(screen.getByTestId('render-props-content')).toBeOnTheScreen(); - expect(screen.getByTestId('visibility-indicator')).toBeOnTheScreen(); - expect(screen.getByText('Content')).toBeOnTheScreen(); + expect( + screen.getByTestId('render-props-content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('visibility-indicator', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect(screen.getByText('Content', { includeHiddenElements: true })).toBeOnTheScreen(); }); it('supports both regular children and render props', () => { @@ -1241,11 +1276,21 @@ describe('Carousel', () => { , ); - expect(screen.getByTestId('regular-content')).toBeOnTheScreen(); - expect(screen.getByTestId('render-props-content')).toBeOnTheScreen(); - expect(screen.getByText('Regular Content')).toBeOnTheScreen(); - expect(screen.getByText('Render Props Content')).toBeOnTheScreen(); - expect(screen.getByTestId('visibility-status')).toBeOnTheScreen(); + expect( + screen.getByTestId('regular-content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('render-props-content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByText('Regular Content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByText('Render Props Content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('visibility-status', { includeHiddenElements: true }), + ).toBeOnTheScreen(); }); }); diff --git a/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx b/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx index aa03b553d4..372ae3f35b 100644 --- a/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx +++ b/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx @@ -47,6 +47,20 @@ const renderPagination = (props: Partial { + describe('variant', () => { + it('defaults to the dot variant', () => { + renderPagination({ totalPages: 3 }); + + expect(screen.getByTestId('carousel-pagination-dot')).toBeOnTheScreen(); + }); + + it('uses the pill variant when requested', () => { + renderPagination({ totalPages: 3, variant: 'pill' }); + + expect(screen.getByTestId('carousel-pagination-pill')).toBeOnTheScreen(); + }); + }); + describe('paginationAccessibilityLabel', () => { it('uses default function that includes page number when not provided', () => { renderPagination({ totalPages: 3 }); diff --git a/packages/mobile/src/cells/Cell.tsx b/packages/mobile/src/cells/Cell.tsx index a59a3cd17c..ca140ec8e1 100644 --- a/packages/mobile/src/cells/Cell.tsx +++ b/packages/mobile/src/cells/Cell.tsx @@ -1,5 +1,11 @@ import React, { memo, useMemo } from 'react'; -import { type StyleProp, StyleSheet, type ViewProps, type ViewStyle } from 'react-native'; +import { + type DimensionValue, + type StyleProp, + StyleSheet, + type ViewProps, + type ViewStyle, +} from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { CellPriority, SharedProps } from '@coinbase/cds-common/types'; import { hasCellPriority } from '@coinbase/cds-common/utils/cell'; @@ -35,6 +41,7 @@ export type CellSpacing = Pick< export type CellBaseProps = SharedProps & LinkableProps & Pick & { + /** Accessory element rendered at the end of the cell (e.g., chevron). */ accessory?: React.ReactElement; /** Custom accessory node rendered at the end of the cell. Takes precedence over `accessory`. */ accessoryNode?: React.ReactNode; @@ -59,7 +66,7 @@ export type CellBaseProps = SharedProps & * @deprecated Use `styles.end` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v9 */ - detailWidth?: number | string; + detailWidth?: DimensionValue; /** Is the cell disabled? Will apply opacity and disable interaction. */ disabled?: boolean; /** Which piece of content has the highest priority in regards to text truncation, growing, and shrinking. */ @@ -105,7 +112,24 @@ export const Cell = memo(function Cell({ accessory, accessoryNode, alignItems = 'center', + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, borderRadius = 200, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, children, styles, end, @@ -144,9 +168,55 @@ export const Cell = memo(function Cell({ const { marginX: innerSpacingMarginX, ...innerSpacingWithoutMarginX } = innerSpacing; + // Border props must be applied to the internal Pressable wrapper for correct visual rendering. + // The outer Box was only meant to create padding outside the Pressable area; this behavior + // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell. + const borderProps = useMemo( + () => ({ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + }), + [ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + ], + ); + const content = useMemo(() => { const contentContainerProps = { - borderRadius, + ...borderProps, testID, renderToHardwareTextureAndroid: disabled, ...(selected ? { background } : {}), @@ -234,7 +304,7 @@ export const Cell = memo(function Cell({ ); }, [ - borderRadius, + borderProps, testID, disabled, selected, @@ -282,7 +352,7 @@ export const Cell = memo(function Cell({ accessibilityState={{ disabled, ...accessibilityState }} background="bg" blendStyles={blendStyles} - borderRadius={borderRadius} + {...borderProps} contentStyle={pressStyles} disabled={disabled} onPress={onPress} @@ -305,7 +375,7 @@ export const Cell = memo(function Cell({ styles?.pressable, accessibilityState, blendStyles, - borderRadius, + borderProps, ]); return ( diff --git a/packages/mobile/src/cells/CellAccessory.tsx b/packages/mobile/src/cells/CellAccessory.tsx index 121498feab..07e3fe7cf3 100644 --- a/packages/mobile/src/cells/CellAccessory.tsx +++ b/packages/mobile/src/cells/CellAccessory.tsx @@ -40,7 +40,7 @@ export const CellAccessory = memo(function CellAccessory({ type, ...props }: Cel } return ( - + {icon} ); diff --git a/packages/mobile/src/cells/ContentCellFallback.tsx b/packages/mobile/src/cells/ContentCellFallback.tsx index b3ebece0f2..74402448f2 100644 --- a/packages/mobile/src/cells/ContentCellFallback.tsx +++ b/packages/mobile/src/cells/ContentCellFallback.tsx @@ -39,6 +39,10 @@ export type ContentCellFallbackProps = FallbackRectWidthProps & title?: boolean; }; +/** + * @deprecated Please use the new ListCellFallback component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const ContentCellFallback = memo(function ContentCellFallback({ accessory, accessoryNode, diff --git a/packages/mobile/src/cells/ListCell.tsx b/packages/mobile/src/cells/ListCell.tsx index 2467d975a9..fc93eb98f0 100644 --- a/packages/mobile/src/cells/ListCell.tsx +++ b/packages/mobile/src/cells/ListCell.tsx @@ -3,7 +3,7 @@ import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; import { compactListHeight, listHeight } from '@coinbase/cds-common/tokens/cell'; import { VStack } from '../layout/VStack'; -import { Text, type TextProps } from '../typography/Text'; +import { Text } from '../typography/Text'; import { Cell, type CellBaseProps, type CellProps, type CellSpacing } from './Cell'; import { CellAccessory, type CellAccessoryType } from './CellAccessory'; @@ -176,16 +176,20 @@ export const ListCell = memo(function ListCell({ variant, onPress, spacingVariant = compact ? 'compact' : 'normal', + minHeight: minHeightProp, style, styles, ...props }: ListCellProps) { + // we need to maintain fixed min-heights for the different cell style variants until they are dropped in a breaking change + // see CDS-1620 const minHeight = - spacingVariant === 'compact' + minHeightProp ?? + (spacingVariant === 'compact' ? compactListHeight : spacingVariant === 'normal' ? listHeight - : undefined; + : undefined); const accessoryType = selected && !disableSelectionAccessory ? 'selected' : accessory; const hasDetails = Boolean(detail || subdetail || detailNode || subdetailNode); diff --git a/packages/mobile/src/cells/MediaFallback.tsx b/packages/mobile/src/cells/MediaFallback.tsx index abc8075c16..fb5803d58b 100644 --- a/packages/mobile/src/cells/MediaFallback.tsx +++ b/packages/mobile/src/cells/MediaFallback.tsx @@ -14,8 +14,8 @@ export const MediaFallback = memo(function MediaFallback({ ...fallbackProps }: MediaFallbackProps) { if (type === 'image') { - return ; + return ; } - return ; + return ; }); diff --git a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx index cb9d29d7f9..21f886d845 100644 --- a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx @@ -794,6 +794,77 @@ const WithHelperText = () => ( ); +const BorderCustomization = () => { + const [isCondensed, setIsCondensed] = useState(true); + const spacingVariant = isCondensed ? 'condensed' : 'normal'; + + return ( + + setIsCondensed(Boolean(nextChecked))} + > + Spacing variant: {spacingVariant} + + + + + + + + + + + ); +}; + const CustomSpacing = () => ( <> ( const CondensedListCell = () => { const theme = useTheme(); return ( - + { }, []); return ( - + { + + + diff --git a/packages/mobile/src/cells/__tests__/CellMedia.test.tsx b/packages/mobile/src/cells/__tests__/CellMedia.test.tsx index 4eaeea1bda..e514d62ac6 100644 --- a/packages/mobile/src/cells/__tests__/CellMedia.test.tsx +++ b/packages/mobile/src/cells/__tests__/CellMedia.test.tsx @@ -192,42 +192,6 @@ describe('CellMedia', () => { expect(screen.getByText(glyphMap['arrowUp-24-inactive'])).toBeTruthy(); }); - it('renders an asset', () => { - render( - - - , - ); - const image = screen.getByRole('image'); - - expect(image).toHaveProp('source', { cache: undefined, uri: 'some/image/path' }); - expect(image).toHaveStyle({ borderRadius: 100000 }); - }); - - it('renders an avatar', () => { - render( - - - , - ); - const image = screen.getByRole('image'); - - expect(image).toHaveProp('source', { cache: undefined, uri: 'some/image/path' }); - expect(image).toHaveStyle({ borderRadius: 100000 }); - }); - - it('renders an image', () => { - render( - - - , - ); - const image = screen.getByRole('image'); - - expect(image).toHaveProp('source', { cache: undefined, uri: 'some/image/path' }); - expect(image).toHaveStyle({ borderRadius: 8 }); - }); - it('renders a pictogram', () => { render( @@ -239,46 +203,6 @@ describe('CellMedia', () => { }); describe('at normal scale', () => { - it('sets icon size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 32, height: 32 }); - }); - - it('sets asset size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 32, height: 32 }); - }); - - it('sets avatar size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 32, height: 32 }); - }); - - it('sets image size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 48, height: 48 }); - }); - it('sets pictogram size', () => { render( diff --git a/packages/mobile/src/cells/__tests__/ContentCell.test.tsx b/packages/mobile/src/cells/__tests__/ContentCell.test.tsx index 234b9c9a60..1f0a8d80eb 100644 --- a/packages/mobile/src/cells/__tests__/ContentCell.test.tsx +++ b/packages/mobile/src/cells/__tests__/ContentCell.test.tsx @@ -1,8 +1,6 @@ import { Text, View } from 'react-native'; import { render, screen } from '@testing-library/react-native'; -import { VStack } from '../../layout'; -import { Text as TypographyText } from '../../typography/Text'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Cell } from '../Cell'; import { CellMedia } from '../CellMedia'; @@ -231,7 +229,7 @@ describe('ContentCell', () => { , ); - expect(screen.container).not.toBeNull(); + expect(screen.root).not.toBeNull(); }); it('renders override nodes when provided', () => { @@ -290,9 +288,9 @@ describe('ContentCell', () => { , ); - const titleInstance = screen.getByText('Title').parent; - const subtitleInstance = screen.getByText('Subtitle').parent; - const descriptionInstance = screen.getByText('Description').parent; + const titleInstance = screen.getByText('Title').parent.parent; + const subtitleInstance = screen.getByText('Subtitle').parent.parent; + const descriptionInstance = screen.getByText('Description').parent.parent; expect(titleInstance?.props.numberOfLines).toBe(2); expect(subtitleInstance?.props.font).toBe('label1'); @@ -321,6 +319,6 @@ describe('ContentCell', () => { ); const metaInstance = screen.getByText('Meta').parent; - expect(metaInstance?.props.style).toBe(metaStyle); + expect(metaInstance?.props.style).toContainEqual(metaStyle); }); }); diff --git a/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx b/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx index af6fa9ce62..4e50fd85b9 100644 --- a/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx +++ b/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx @@ -29,7 +29,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('MediaFallback image')).toBeDefined(); + expect(screen.getByText('MediaFallback image', { includeHiddenElements: true })).toBeDefined(); }); it('should render description fallback', () => { @@ -38,7 +38,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('Fallback')).toBeDefined(); + expect(screen.getByText('Fallback', { includeHiddenElements: true })).toBeDefined(); expect(Fallback).toHaveBeenCalledWith( expect.objectContaining({ disableRandomRectWidth: true, @@ -47,7 +47,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 3), width: 110, }), - {}, + undefined, ); }); @@ -74,7 +74,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('Fallback')).toBeDefined(); + expect(screen.getByText('Fallback', { includeHiddenElements: true })).toBeDefined(); expect(Fallback).toHaveBeenCalledWith( expect.objectContaining({ disableRandomRectWidth: true, @@ -82,7 +82,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 1), width: 90, }), - {}, + undefined, ); }); @@ -92,7 +92,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('Fallback')).toBeDefined(); + expect(screen.getByText('Fallback', { includeHiddenElements: true })).toBeDefined(); expect(Fallback).toHaveBeenCalledWith( expect.objectContaining({ disableRandomRectWidth: true, @@ -100,7 +100,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 2), width: 90, }), - {}, + undefined, ); }); diff --git a/packages/mobile/src/cells/__tests__/ListCell.test.tsx b/packages/mobile/src/cells/__tests__/ListCell.test.tsx index b6222283c4..b3aa9a7bc4 100644 --- a/packages/mobile/src/cells/__tests__/ListCell.test.tsx +++ b/packages/mobile/src/cells/__tests__/ListCell.test.tsx @@ -3,41 +3,11 @@ import { noop } from '@coinbase/cds-utils'; import { render, screen } from '@testing-library/react-native'; import { Button } from '../../buttons'; -import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultThemeProvider, treeHasStyleProp } from '../../utils/testHelpers'; import { CellHelperText } from '../CellHelperText'; import { CellMedia } from '../CellMedia'; import { ListCell } from '../ListCell'; -function flattenStyle(style: unknown): Array> { - if (!style) return []; - if (Array.isArray(style)) return style.flatMap(flattenStyle); - if (typeof style === 'object') return [style as Record]; - return []; -} - -function treeHasStyleProp( - tree: unknown, - predicate: (style: Record) => boolean, -): boolean { - if (!tree) return false; - - if (Array.isArray(tree)) { - return tree.some((node) => treeHasStyleProp(node, predicate)); - } - - if (typeof tree !== 'object') return false; - - const node = tree as { - props?: { style?: unknown }; - children?: unknown[]; - }; - - const styles = flattenStyle(node.props?.style); - if (styles.some(predicate)) return true; - - return (node.children ?? []).some((child) => treeHasStyleProp(child, predicate)); -} - describe('ListCell', () => { it('renders a Text component title', () => { render( @@ -288,7 +258,7 @@ describe('ListCell', () => { , ); - expect(screen.getByText('Helper Text')).toBeTruthy(); + expect(screen.getByText(/Helper Text/, { includeHiddenElements: true })).toBeTruthy(); }); it('renders empty strings without crashing', () => { @@ -298,7 +268,7 @@ describe('ListCell', () => { , ); - expect(screen.container).not.toBeNull(); + expect(screen.root).not.toBeNull(); }); it('can set an accessibilityLabel and accessibilityHint when a pressable', () => { diff --git a/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx b/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx index 7aa24522cb..21935b0439 100644 --- a/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx +++ b/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx @@ -20,7 +20,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-description')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-description', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if detail is passed', () => { @@ -29,7 +31,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-detail')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-detail', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if subdetail is passed', () => { @@ -38,7 +42,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-subdetail')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-subdetail', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if title is passed', () => { @@ -47,7 +53,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-title')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-title', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a MediaFallback component if media is passed', () => { @@ -56,7 +64,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-media')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-media', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if helperText is passed', () => { @@ -65,7 +75,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-helper-text')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-helper-text', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders ListCellFallback component with innerSpacing and outerSpacing', () => { diff --git a/packages/mobile/src/chips/Chip.tsx b/packages/mobile/src/chips/Chip.tsx index 61caf5b527..228dbca7fe 100644 --- a/packages/mobile/src/chips/Chip.tsx +++ b/packages/mobile/src/chips/Chip.tsx @@ -71,6 +71,7 @@ export const Chip = memo( paddingX={paddingX} paddingY={paddingY} style={[contentStyle, styles?.content]} + testID={testID ? `${testID}-content` : undefined} > {start} {typeof children === 'string' ? ( diff --git a/packages/mobile/src/chips/ChipProps.ts b/packages/mobile/src/chips/ChipProps.ts index c8a4820451..ad00408c3b 100644 --- a/packages/mobile/src/chips/ChipProps.ts +++ b/packages/mobile/src/chips/ChipProps.ts @@ -1,9 +1,5 @@ -import { type StyleProp, type ViewStyle } from 'react-native'; -import { - type DimensionValue, - type SharedAccessibilityProps, - type SharedProps, -} from '@coinbase/cds-common/types'; +import { type DimensionValue, type StyleProp, type ViewStyle } from 'react-native'; +import { type SharedAccessibilityProps, type SharedProps } from '@coinbase/cds-common/types'; import type { PressableProps } from '../system'; diff --git a/packages/mobile/src/chips/__tests__/Chip.test.tsx b/packages/mobile/src/chips/__tests__/Chip.test.tsx index 5f7fde7fc5..32307cd4ae 100644 --- a/packages/mobile/src/chips/__tests__/Chip.test.tsx +++ b/packages/mobile/src/chips/__tests__/Chip.test.tsx @@ -61,7 +61,7 @@ describe('Chip', () => { it('renders correctly when passing custom styles to contentStyle prop', () => { render(); - expect(screen.getByTestId(chipTestID).children[0]).toHaveStyle(customContentStyle); + expect(screen.getByTestId(`${chipTestID}-content`)).toHaveStyle(customContentStyle); }); it('applies custom styles to root and content', () => { @@ -72,8 +72,7 @@ describe('Chip', () => { render(); - const chip = screen.getByTestId(chipTestID); - expect(chip).toHaveStyle({ borderWidth: 2 }); - expect(chip.children[0]).toHaveStyle({ paddingVertical: 10 }); + expect(screen.getByTestId(chipTestID)).toHaveStyle({ borderWidth: 2 }); + expect(screen.getByTestId(`${chipTestID}-content`)).toHaveStyle({ paddingVertical: 10 }); }); }); diff --git a/packages/mobile/src/coachmark/Coachmark.tsx b/packages/mobile/src/coachmark/Coachmark.tsx index 8482067cf8..c21fb0a9c5 100644 --- a/packages/mobile/src/coachmark/Coachmark.tsx +++ b/packages/mobile/src/coachmark/Coachmark.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, memo } from 'react'; import { useWindowDimensions } from 'react-native'; -import type { View } from 'react-native'; -import { type DimensionValue, type SharedProps } from '@coinbase/cds-common'; +import type { DimensionValue, View } from 'react-native'; +import { type SharedProps } from '@coinbase/cds-common'; import { IconButton } from '../buttons'; import { useTheme } from '../hooks/useTheme'; @@ -71,13 +71,13 @@ export const Coachmark = memo( return ( {media} diff --git a/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx b/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx index 0e318f1d4a..29f2c08c5f 100644 --- a/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx +++ b/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx @@ -83,7 +83,7 @@ describe('Coachmark', () => { , ); - expect(screen.getByTestId('remoteimage')).toBeTruthy(); + expect(screen.getByTestId('remoteimage', { includeHiddenElements: true })).toBeTruthy(); }); it('renders with custom width', () => { diff --git a/packages/mobile/src/controls/HelperText.tsx b/packages/mobile/src/controls/HelperText.tsx index 205a86b8e7..275d0413ab 100644 --- a/packages/mobile/src/controls/HelperText.tsx +++ b/packages/mobile/src/controls/HelperText.tsx @@ -17,6 +17,13 @@ export type HelperTextProps = { errorIconAccessibilityLabel?: string; /** Test ID for the error icon */ errorIconTestID?: string; + /** Custom styles for individual elements of the HelperText component */ + styles?: { + /** Root text element */ + root?: TextProps['style']; + /** Error icon element */ + icon?: TextProps['style']; + }; } & TextProps; export const HelperText = memo(function HelperText({ @@ -26,6 +33,8 @@ export const HelperText = memo(function HelperText({ children, align, dangerouslySetColor, + style, + styles, ...props }: HelperTextProps) { const theme = useTheme(); @@ -36,14 +45,22 @@ export const HelperText = memo(function HelperText({ const glyph = glyphMap[glyphKey]; const iconStyle = useMemo( - () => ({ - fontFamily: 'CoinbaseIcons', - fontSize: iconSize, - height: iconSize, - width: iconSize, - letterSpacing: 4, - }), - [iconSize], + () => [ + { + fontFamily: 'CoinbaseIcons', + fontSize: iconSize, + height: iconSize, + width: iconSize, + letterSpacing: 4, + }, + // TODO: when we actually remove dangerouslySetColor: + // when migrating from dangerouslySetColor to style.color, + // root style/className color will not automatically style the error icon like dangerouslySetColor. + // Consumers must set both styles.root and styles.icon (or classNames equivalents). + // We need to have a migrator handle this or document in future migration guide. + styles?.icon, + ], + [iconSize, styles?.icon], ); return ( @@ -52,6 +69,7 @@ export const HelperText = memo(function HelperText({ color={color} dangerouslySetColor={dangerouslySetColor} font="label2" + style={[style, styles?.root]} {...props} > {color === 'fgNegative' && ( diff --git a/packages/mobile/src/controls/InputIconButton.tsx b/packages/mobile/src/controls/InputIconButton.tsx index 26cd0c8440..95d93ac754 100644 --- a/packages/mobile/src/controls/InputIconButton.tsx +++ b/packages/mobile/src/controls/InputIconButton.tsx @@ -12,7 +12,7 @@ export const variantTransformMap: Record = { negative: 'primary', foreground: 'primary', primary: 'primary', - foregroundMuted: 'foregroundMuted', + foregroundMuted: 'secondary', secondary: 'secondary', }; diff --git a/packages/mobile/src/controls/InputStack.tsx b/packages/mobile/src/controls/InputStack.tsx index b06a0dff01..c189e7f8fe 100644 --- a/packages/mobile/src/controls/InputStack.tsx +++ b/packages/mobile/src/controls/InputStack.tsx @@ -2,6 +2,7 @@ import React, { memo, useMemo } from 'react'; import { Animated, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; +import { inputStackGap } from '@coinbase/cds-common/tokens/input'; import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; import type { InputVariant } from '@coinbase/cds-common/types/InputBaseProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; @@ -87,7 +88,16 @@ export type InputStackBaseProps = SharedProps & { }; export type InputStackProps = Omit & - InputStackBaseProps; + InputStackBaseProps & { + styles?: { + /** Root container element */ + root?: StyleProp; + /** Input horizontal container element */ + inputContainer?: StyleProp; + /** Input area element */ + input?: StyleProp; + }; + }; const variantColorMap: Record = { primary: 'fgPrimary', @@ -120,6 +130,8 @@ export const InputStack = memo(function InputStack({ inputBackground = 'bg', borderWidth = 100, focusedBorderWidth = borderWidth, + styles, + style, ...props }: InputStackProps) { const theme = useTheme(); @@ -222,10 +234,23 @@ export const InputStack = memo(function InputStack({ inputAreaSize.height, ]); + const rootStyles = useMemo(() => [style, styles?.root], [style, styles?.root]); + + const inputContainerStyles = useMemo( + () => [staticStyles.inputAreaContainerStyle, styles?.inputContainer], + [styles?.inputContainer], + ); + + const combinedInputAreaStyles = useMemo( + () => [inputAreaStyles, styles?.input], + [inputAreaStyles, styles?.input], + ); + return ( {labelNode}} {!!prependNode && <>{prependNode}} - + {focused && } {focused && enableColorSurge && ( @@ -245,7 +270,7 @@ export const InputStack = memo(function InputStack({ )} {!!startNode && <>{startNode}} {!!labelNode && labelVariant === 'inside' ? ( - + {labelNode} {inputNode} @@ -266,9 +291,9 @@ export const InputStack = memo(function InputStack({ // When `overflow: auto` is set the thickened border when focused is not accounted for // hence you see a cutoff. // Padding must accommodate the focus border extension (default 2px for focusedBorderWidth: 200) -const styles = StyleSheet.create({ +const staticStyles = StyleSheet.create({ inputAreaContainerStyle: { padding: 2, - flexGrow: 1, + flexGrow: 2, }, }); diff --git a/packages/mobile/src/controls/NativeInput.tsx b/packages/mobile/src/controls/NativeInput.tsx index 0600962f6d..9fc2f3bd26 100644 --- a/packages/mobile/src/controls/NativeInput.tsx +++ b/packages/mobile/src/controls/NativeInput.tsx @@ -71,6 +71,7 @@ export const NativeInput = memo( const containerStyle: ViewStyle = useMemo(() => { return { flex: 2, + minWidth: 0, padding: theme.space[compact ? 1 : 2], ...containerSpacing, ...(!disabled && diff --git a/packages/mobile/src/controls/Radio.tsx b/packages/mobile/src/controls/Radio.tsx index 9f7154b42a..fc9ec2d942 100644 --- a/packages/mobile/src/controls/Radio.tsx +++ b/packages/mobile/src/controls/Radio.tsx @@ -34,7 +34,7 @@ export type RadioBaseProps = Omit< export type RadioProps = RadioBaseProps; -const DotSvg = ({ color = 'black', width = 20 }: { color?: ColorValue; width?: number }) => { +const DotSvg = ({ color = 'black', width }: { color?: ColorValue; width: number }) => { return ( @@ -99,13 +99,13 @@ const RadioWithRef = forwardRef(function Radio( return ( - {...props} ref={ref} accessibilityHint={accessibilityHint} accessibilityLabel={accessibilityLabelValue} accessibilityRole="radio" hitSlop={5} label={children} + {...props} > {RadioIcon} diff --git a/packages/mobile/src/controls/SearchInput.tsx b/packages/mobile/src/controls/SearchInput.tsx index 55cbec3e65..f018c12b3a 100644 --- a/packages/mobile/src/controls/SearchInput.tsx +++ b/packages/mobile/src/controls/SearchInput.tsx @@ -1,10 +1,10 @@ import React, { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; import type { ForwardedRef } from 'react'; import type { + BlurEvent, + FocusEvent, GestureResponderEvent, - NativeSyntheticEvent, TextInput as RNTextInput, - TextInputFocusEventData, TextInputProps as RNTextInputProps, } from 'react-native'; import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; @@ -116,7 +116,7 @@ export const SearchInput = memo( const refs = useMergeRefs(ref, internalRef); const handleOnFocus = useCallback( - (e: NativeSyntheticEvent) => { + (e: FocusEvent) => { onFocus?.(e); if (!disableBackArrow && startIcon === undefined) { @@ -127,7 +127,7 @@ export const SearchInput = memo( ); const handleOnBlur = useCallback( - (e: NativeSyntheticEvent) => { + (e: BlurEvent) => { onBlur?.(e); if (startIcon === undefined) { diff --git a/packages/mobile/src/controls/SelectContext.tsx b/packages/mobile/src/controls/SelectContext.tsx index 91c48c0bb6..2328417609 100644 --- a/packages/mobile/src/controls/SelectContext.tsx +++ b/packages/mobile/src/controls/SelectContext.tsx @@ -12,9 +12,21 @@ const defaultContext = { const errorMessage = 'SelectContext is undefined. SelectProvider was not found higher up the tree. '; +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectContext = createContext(defaultContext); +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectProvider = SelectContext.Provider; +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const useSelectContext = () => { const context = React.useContext(SelectContext); // TODO: check for something required diff --git a/packages/mobile/src/controls/SelectOption.tsx b/packages/mobile/src/controls/SelectOption.tsx index 33567862f6..f9edd09c73 100644 --- a/packages/mobile/src/controls/SelectOption.tsx +++ b/packages/mobile/src/controls/SelectOption.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from 'react'; +import { memo, useCallback } from 'react'; import type { GestureResponderEvent } from 'react-native'; import { selectCellMobileSpacingConfig } from '@coinbase/cds-common/tokens/select'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types'; @@ -25,6 +25,10 @@ export type SelectOptionBaseProps = Omit export type SelectOptionProps = SelectOptionBaseProps; +/** + * @deprecated Please use the new Select alpha component instead. If you are using this component outside of Select, we recommend replacing it with ListCell. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectOption = memo(function SelectOption({ title, description, diff --git a/packages/mobile/src/controls/Switch.tsx b/packages/mobile/src/controls/Switch.tsx index b9eb3ef7ec..be0905e28c 100644 --- a/packages/mobile/src/controls/Switch.tsx +++ b/packages/mobile/src/controls/Switch.tsx @@ -1,5 +1,5 @@ import React, { forwardRef, memo, useMemo } from 'react'; -import { StyleSheet, type View } from 'react-native'; +import { type StyleProp, StyleSheet, type View, type ViewStyle } from 'react-native'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout/Box'; @@ -7,12 +7,29 @@ import { Interactable } from '../system/Interactable'; import { Control, type ControlBaseProps, type ControlIconProps } from './Control'; -export type SwitchBaseProps = Omit< - ControlBaseProps, - 'style' ->; +export type SwitchBaseProps = ControlBaseProps; -export type SwitchProps = SwitchBaseProps; +export type SwitchProps = SwitchBaseProps & { + /** + * Label content rendered next to the switch control. + * + * @example + * ```tsx + * Dark mode + * ``` + */ + children?: React.ReactNode; + /** Slot-level styles for Switch. */ + styles?: { + /** Persistent outer wrapper across all variants. */ + root?: StyleProp; + /** + * Control wrapper style. + * Applied to the underlying `Control` element (same element that receives `style`). + */ + control?: StyleProp; + }; +}; const SwitchIcon = ({ pressed, @@ -95,31 +112,39 @@ const SwitchIcon = ({ }; const SwitchWithRef = forwardRef(function SwitchWithRef( - { children, ...props }: SwitchProps, + { children, style, styles, ...props }: SwitchProps, ref: React.ForwardedRef, ) { const theme = useTheme(); const { switchHeight } = theme.controlSize; + const controlStyles = useMemo( + () => StyleSheet.flatten([style, styles?.control]), + [style, styles?.control], + ); const switchNode = ( {SwitchIcon} ); - return children ? ( - + return ( + {switchNode} - ) : ( - switchNode ); }); diff --git a/packages/mobile/src/controls/TextInput.tsx b/packages/mobile/src/controls/TextInput.tsx index ab0305a13b..6cd11371ac 100644 --- a/packages/mobile/src/controls/TextInput.tsx +++ b/packages/mobile/src/controls/TextInput.tsx @@ -11,9 +11,8 @@ import React, { import { Pressable } from 'react-native'; import type { ForwardedRef } from 'react'; import type { - NativeSyntheticEvent, + DimensionValue, TextInput as RNTextInput, - TextInputFocusEventData, TextInputProps as RNTextInputProps, ViewStyle, } from 'react-native'; @@ -26,7 +25,6 @@ import type { SharedProps, TextAlignProps, } from '@coinbase/cds-common/types'; -import type { DimensionValue } from '@coinbase/cds-common/types/DimensionStyles'; import type { InputVariant } from '@coinbase/cds-common/types/InputBaseProps'; import { useInputBorderStyle } from '../hooks/useInputBorderStyle'; @@ -170,13 +168,13 @@ export const TextInput = memo( focusedBorderWidth, ); - const editableInputAddonProps = { + const editableInputAddonProps: TextInputProps = { ...editableInputProps, - onFocus: (e: NativeSyntheticEvent) => { + onFocus: (e) => { editableInputProps?.onFocus?.(e); setFocused(true); }, - onBlur: (e: NativeSyntheticEvent) => { + onBlur: (e) => { editableInputProps?.onBlur?.(e); setFocused(false); }, @@ -197,7 +195,7 @@ export const TextInput = memo( ...(labelVariant === 'inside' && hasLabel && !compact && { - paddingBottom: 0, + paddingBottom: theme.space[1], paddingTop: 0, }), }), @@ -217,7 +215,8 @@ export const TextInput = memo( const inaccessibleStart = useMemo(() => { if (isValidElement(start) && start.type === InputIconButton) { return cloneElement(start, { - ...start.props, + // ReactElement default props is unknown, so we need to cast to the correct type + ...(start.props as InputIconButtonProps), accessibilityLabel: undefined, accessibilityHint: undefined, accessibilityElementsHidden: true, @@ -313,6 +312,7 @@ export const TextInput = memo( > { key={`${variant}-input-iconbutton`} editable={__DEV__} label={variant} - start={ - - } + start={} variant={variant} /> ))} @@ -75,7 +73,7 @@ const AddCustomColor = () => { disableInheritFocusStyle accessibilityLabel="Add" name="add" - variant="foregroundMuted" + variant="secondary" /> } /> @@ -93,7 +91,7 @@ const AddCustomColorEnd = () => { transparent accessibilityLabel="Add" name="add" - variant="foregroundMuted" + variant="secondary" /> } label="Label" @@ -130,7 +128,7 @@ const InputIconButtonScreen = () => { - + diff --git a/packages/mobile/src/controls/__tests__/Checkbox.test.tsx b/packages/mobile/src/controls/__tests__/Checkbox.test.tsx index 90e1369450..def9a2d37b 100644 --- a/packages/mobile/src/controls/__tests__/Checkbox.test.tsx +++ b/packages/mobile/src/controls/__tests__/Checkbox.test.tsx @@ -1,6 +1,5 @@ -import { Pressable } from 'react-native'; import { glyphMap } from '@coinbase/cds-icons/glyphMap'; -import { fireEvent, render, screen, within } from '@testing-library/react-native'; +import { fireEvent, render, screen } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; import { DefaultThemeProvider } from '../../utils/testHelpers'; @@ -16,15 +15,19 @@ describe('Checkbox', () => { expect(screen.getByTestId('mock-checkbox')).toBeAccessible(); }); - it('renders a Pressable', () => { + it('renders and responds to press', () => { + const onChange = jest.fn(); render( - Checkbox + Checkbox , ); - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); - expect(screen.getByText('Checkbox')).toBeTruthy(); + const checkboxText = screen.getByText('Checkbox'); + expect(checkboxText).toBeTruthy(); + + fireEvent.press(checkboxText); + expect(onChange).toHaveBeenCalled(); }); it('renders a check icon when checked', () => { @@ -98,7 +101,7 @@ describe('Checkbox', () => { , ); - expect(screen.queryAllByA11yState({ checked: true })).toHaveLength(1); + expect(screen.getByRole('checkbox')).toBeChecked(); }); it('has accessibility state disabled when disabled', () => { @@ -108,7 +111,7 @@ describe('Checkbox', () => { , ); - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(1); + expect(screen.getByRole('checkbox')).toBeDisabled(); }); it('disabled checkbox passes a11y', () => { @@ -212,9 +215,8 @@ describe('Checkbox', () => { , ); - const iconBox = screen.getByTestId('checkbox-icon'); - // The icon glyph is inside the Box, find the Text element by role - const iconText = within(iconBox).getByRole('image'); + // Find the check icon glyph text + const iconText = screen.getByText(glyphMap['checkmark-24-inactive']); expect(iconText).toHaveStyle({ color: defaultTheme.lightColor.bgPositive, }); @@ -229,9 +231,8 @@ describe('Checkbox', () => { , ); - const iconBox = screen.getByTestId('checkbox-icon'); - // The icon glyph is inside the Box, find the Text element by role - const iconText = within(iconBox).getByRole('image'); + // Find the minus icon glyph text + const iconText = screen.getByText(glyphMap['minus-24-inactive']); expect(iconText).toHaveStyle({ color: defaultTheme.lightColor.bgWarning, }); diff --git a/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx b/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx index bd762757ab..73fed3ce37 100644 --- a/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx +++ b/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx @@ -70,7 +70,7 @@ describe('CheckboxCell', () => { ); // CheckboxCell has proper accessibility state (only the main cell should have checked state) - expect(screen.queryAllByA11yState({ checked: true })).toHaveLength(1); + expect(screen.getByRole('checkbox')).toBeChecked(); }); it('shows unchecked state correctly', () => { @@ -86,7 +86,7 @@ describe('CheckboxCell', () => { ); // CheckboxCell has proper accessibility state (only the main cell should have checked state) - expect(screen.queryAllByA11yState({ checked: false })).toHaveLength(1); + expect(screen.getByRole('checkbox')).not.toBeChecked(); }); it('triggers onChange when pressed with correct parameters', () => { @@ -161,8 +161,9 @@ describe('CheckboxCell', () => { , ); - // CheckboxCell has proper accessibility state (both main cell and internal control have disabled state) - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(2); + // CheckboxCell should have disabled accessibility state + const disabledCheckboxes = screen.queryAllByRole('checkbox', { disabled: true }); + expect(disabledCheckboxes.length).toBeGreaterThanOrEqual(1); }); it('attaches testID', () => { @@ -305,9 +306,7 @@ describe('CheckboxCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('checkbox')).toHaveLength(1); - expect(screen.getByTestId('checked-accessible-checkbox')).toHaveAccessibilityState({ - checked: true, - }); + expect(screen.getByTestId('checked-accessible-checkbox')).toBeChecked(); }); it('renders with proper accessibility when disabled', () => { @@ -326,9 +325,7 @@ describe('CheckboxCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('checkbox')).toHaveLength(1); - expect(screen.getByTestId('disabled-accessible-checkbox')).toHaveAccessibilityState({ - disabled: true, - }); + expect(screen.getByTestId('disabled-accessible-checkbox')).toBeDisabled(); }); it('works without description', () => { diff --git a/packages/mobile/src/controls/__tests__/HelperText.test.tsx b/packages/mobile/src/controls/__tests__/HelperText.test.tsx index 0b15288a7a..76f674c2df 100644 --- a/packages/mobile/src/controls/__tests__/HelperText.test.tsx +++ b/packages/mobile/src/controls/__tests__/HelperText.test.tsx @@ -14,17 +14,23 @@ describe('HelperText.test', () => { expect(screen.getByText('Test text')).toBeTruthy(); }); - it('renders custom color', () => { + it('renders custom color and icon styles via style slots', () => { render( - + Test text , ); - expect(screen.getByText('Test text')).toHaveStyle({ color: 'yellow' }); - expect(screen.getByRole('image')).toHaveStyle({ color: 'yellow' }); + expect(screen.getByText(/Test text/)).toHaveStyle({ color: 'yellow' }); + expect(screen.getByText(/Test text/)).toHaveStyle({ marginTop: 8 }); + expect(screen.getByTestId('error-icon')).toHaveStyle({ color: 'yellow' }); }); it('renders custom spacing', () => { diff --git a/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx b/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx index b1353c55d3..bd2b5f1dce 100644 --- a/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx +++ b/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx @@ -9,12 +9,7 @@ describe('InputIconButton', () => { it('passes a11y', () => { render( - + , ); expect(screen.getByTestId(INPUTICONBUTTON_TEST_ID)).toBeAccessible(); @@ -23,12 +18,7 @@ describe('InputIconButton', () => { it('renders an InputIconButton', () => { render( - + , ); expect(screen.getByTestId(INPUTICONBUTTON_TEST_ID)).toBeTruthy(); diff --git a/packages/mobile/src/controls/__tests__/InputStack.test.tsx b/packages/mobile/src/controls/__tests__/InputStack.test.tsx index 40ed602c3c..b9e9de8631 100644 --- a/packages/mobile/src/controls/__tests__/InputStack.test.tsx +++ b/packages/mobile/src/controls/__tests__/InputStack.test.tsx @@ -1,49 +1,11 @@ -import { TextInput as RNTextInput } from 'react-native'; -import TestRenderer from 'react-test-renderer'; import { render, screen } from '@testing-library/react-native'; import { DefaultThemeProvider, theme } from '../../utils/testHelpers'; -import type { InputStackProps } from '../InputStack'; import { InputStack } from '../InputStack'; import { NativeInput } from '../NativeInput'; const TEST_ID = 'input'; -function expectAttribute< - K extends keyof Pick, ->(prop: K, values: readonly NonNullable[]) { - const input = ; - - values.forEach((value) => { - it(`will set "${value}" for \`${prop}\` prop`, async () => { - const inputRenderer = TestRenderer.create( - - - , - ); - - const inputStackInstance = await inputRenderer.root.findByProps({ testID: TEST_ID }); - expect(inputStackInstance.props[prop]).toEqual(value); - }); - }); -} - -describe('width', () => { - expectAttribute('width', ['10%', '50%', '100%']); -}); - -describe('height', () => { - expectAttribute('height', ['10%', '50%', '100%']); -}); - -describe('disabled', () => { - expectAttribute('disabled', [false, true]); -}); - -describe('variant', () => { - expectAttribute('variant', ['foreground', 'foregroundMuted', 'negative', 'positive', 'primary']); -}); - describe('styles', () => { it('renders a custom borderStyle', async () => { const borderStyle = { diff --git a/packages/mobile/src/controls/__tests__/RadioCell.test.tsx b/packages/mobile/src/controls/__tests__/RadioCell.test.tsx index 06a2216e96..ce9145a884 100644 --- a/packages/mobile/src/controls/__tests__/RadioCell.test.tsx +++ b/packages/mobile/src/controls/__tests__/RadioCell.test.tsx @@ -69,8 +69,7 @@ describe('RadioCell', () => { , ); - // The RadioCell should have selected accessibility state - expect(screen.queryAllByA11yState({ selected: true })).toHaveLength(1); // Only the cell + expect(screen.getByRole('radio')).toBeSelected(); }); it('shows unselected state correctly', () => { @@ -85,8 +84,7 @@ describe('RadioCell', () => { , ); - // The RadioCell should have unselected accessibility state - expect(screen.queryAllByA11yState({ selected: false })).toHaveLength(1); // Only the cell + expect(screen.getByRole('radio')).not.toBeSelected(); }); it('triggers onChange when pressed', () => { @@ -140,8 +138,9 @@ describe('RadioCell', () => { , ); - // The RadioCell should have disabled accessibility state (both main cell and internal control have disabled state) - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(2); + // The RadioCell should have disabled accessibility state + const disabledRadios = screen.queryAllByRole('radio', { disabled: true }); + expect(disabledRadios.length).toBeGreaterThanOrEqual(1); }); it('attaches testID', () => { @@ -324,9 +323,7 @@ describe('RadioCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('radio')).toHaveLength(1); - expect(screen.getByTestId('selected-accessible-radio')).toHaveAccessibilityState({ - selected: true, - }); + expect(screen.getByTestId('selected-accessible-radio')).toBeSelected(); }); it('renders with proper accessibility when disabled', () => { @@ -345,9 +342,7 @@ describe('RadioCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('radio')).toHaveLength(1); - expect(screen.getByTestId('disabled-accessible-radio')).toHaveAccessibilityState({ - disabled: true, - }); + expect(screen.getByTestId('disabled-accessible-radio')).toBeDisabled(); }); it('works without description', () => { diff --git a/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx b/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx index f03d66f8cc..63744c1ac5 100644 --- a/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx +++ b/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx @@ -1,4 +1,3 @@ -import { Pressable } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { Text } from '../../typography/Text'; @@ -14,15 +13,20 @@ describe('Radio', () => { ); expect(screen.getByTestId('mock-radio')).toBeAccessible(); }); - it('renders a Pressable', () => { + + it('renders and responds to press', () => { + const onChange = jest.fn(); render( - Radio + Radio , ); - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); - expect(screen.getByText('Radio')).toBeTruthy(); + const radioText = screen.getByText('Radio'); + expect(radioText).toBeTruthy(); + + fireEvent.press(radioText); + expect(onChange).toHaveBeenCalled(); }); it('renders a dot icon when checked', () => { @@ -64,7 +68,7 @@ describe('Radio', () => { , ); - expect(screen.queryAllByA11yState({ checked: true })).toHaveLength(1); + expect(screen.getByRole('radio')).toBeChecked(); }); it('has accessibility state disabled when disabled', () => { @@ -74,7 +78,7 @@ describe('Radio', () => { , ); - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(1); + expect(screen.getByRole('radio')).toBeDisabled(); }); it('Can set custom accessibility label and hints', () => { diff --git a/packages/mobile/src/controls/__tests__/SearchInput.test.tsx b/packages/mobile/src/controls/__tests__/SearchInput.test.tsx index ff49803516..8d4c01468e 100644 --- a/packages/mobile/src/controls/__tests__/SearchInput.test.tsx +++ b/packages/mobile/src/controls/__tests__/SearchInput.test.tsx @@ -195,7 +195,7 @@ describe('Search', () => { render(SearchComponent); // This will throw if we find duplicates - expect(screen.getByLabelText(`search`)).toBeAccessible(); + expect(screen.getByLabelText('search', { includeHiddenElements: true })).toBeAccessible(); }); it('announces the Back arrow icon button', () => { @@ -220,13 +220,17 @@ describe('Search', () => { it('renders a close icon button at the end node', () => { render(SearchComponent); - expect(screen.getByTestId(`${TEST_ID}-close-iconbtn`)).toBeDefined(); + expect( + screen.getByTestId(`${TEST_ID}-close-iconbtn`, { includeHiddenElements: true }), + ).toBeDefined(); }); it('fires `onSearch` when search btn is pressed', () => { render(SearchComponent); - fireEvent.press(screen.getByTestId(`${TEST_ID}-searchinput-iconbtn`)); + fireEvent.press( + screen.getByTestId(`${TEST_ID}-searchinput-iconbtn`, { includeHiddenElements: true }), + ); expect(onSearchSpy).toHaveBeenCalled(); }); @@ -234,7 +238,9 @@ describe('Search', () => { it('fires `onClear` when clear btn is pressed', () => { render(SearchComponent); - fireEvent.press(screen.getByTestId(`${TEST_ID}-close-iconbtn`)); + fireEvent.press( + screen.getByTestId(`${TEST_ID}-close-iconbtn`, { includeHiddenElements: true }), + ); expect(onClearSpy).toHaveBeenCalled(); }); diff --git a/packages/mobile/src/controls/__tests__/Switch.test.tsx b/packages/mobile/src/controls/__tests__/Switch.test.tsx index 88a9c9950b..06e85a21f4 100644 --- a/packages/mobile/src/controls/__tests__/Switch.test.tsx +++ b/packages/mobile/src/controls/__tests__/Switch.test.tsx @@ -3,7 +3,7 @@ import { Text, View } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; -import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultThemeProvider, treeHasStyleProp } from '../../utils/testHelpers'; import { Switch } from '../Switch'; describe('Switch.test', () => { @@ -28,11 +28,11 @@ describe('Switch.test', () => { ); expect(screen.getByText('checked is false')).toBeTruthy(); - expect(screen.getByRole('switch')).toHaveAccessibilityState({ checked: false }); + expect(screen.getByRole('switch')).not.toBeChecked(); fireEvent.press(screen.getByRole('switch')); expect(screen.getByText('checked is true')).toBeTruthy(); - expect(screen.getByRole('switch')).toHaveAccessibilityState({ checked: true }); + expect(screen.getByRole('switch')).toBeChecked(); }); it('passes accessibility', () => { @@ -129,6 +129,30 @@ describe('Switch.test', () => { expect(screen.getByTestId('test-test-id')).toBeTruthy(); }); + it('keeps a stable root wrapper regardless of label presence', () => { + const { toJSON, rerender } = render( + + + , + ); + + const treeWithoutLabel = toJSON(); + expect(treeWithoutLabel).toBeTruthy(); + expect(Array.isArray(treeWithoutLabel)).toBe(false); + expect(treeWithoutLabel).toHaveProperty('type', 'View'); + + rerender( + + with label + , + ); + + const treeWithLabel = toJSON(); + expect(treeWithLabel).toBeTruthy(); + expect(Array.isArray(treeWithLabel)).toBe(false); + expect(treeWithLabel).toHaveProperty('type', 'View'); + }); + it('has default palette', () => { render( @@ -177,4 +201,40 @@ describe('Switch.test', () => { backgroundColor: defaultTheme.lightColor.bgTertiary, }); }); + + it('applies styles.root', () => { + const { toJSON } = render( + + + label + + , + ); + + const tree = toJSON(); + expect(treeHasStyleProp(tree, (s) => s.borderTopWidth === 1)).toBe(true); + }); + + it('applies styles.control and preserves style prop behavior', () => { + const { toJSON } = render( + + + , + ); + + const tree = toJSON(); + expect(treeHasStyleProp(tree, (s) => s.borderLeftWidth === 4)).toBe(true); + expect(treeHasStyleProp(tree, (s) => s.borderRightWidth === 5)).toBe(true); + }); }); diff --git a/packages/mobile/src/controls/__tests__/TextInput.test.tsx b/packages/mobile/src/controls/__tests__/TextInput.test.tsx index b8c06c6dc3..6193a7448f 100644 --- a/packages/mobile/src/controls/__tests__/TextInput.test.tsx +++ b/packages/mobile/src/controls/__tests__/TextInput.test.tsx @@ -371,11 +371,11 @@ describe('TextInput', () => { , ); - const startNode = screen.getByTestId(startTestID); + const startNode = screen.getByTestId(startTestID, { includeHiddenElements: true }); expect(startNode).toBeTruthy(); expect(startNode).toHaveTextContent('Compact Label'); - expect(screen.getByText('Compact Label')).toBeTruthy(); + expect(screen.getByText('Compact Label', { includeHiddenElements: true })).toBeTruthy(); }); it('renders labelNode without compact', () => { @@ -433,8 +433,8 @@ describe('TextInput', () => { , ); - const startNode = screen.getByTestId(startTestID); - const customLabel = screen.getByTestId(labelTestID); + const startNode = screen.getByTestId(startTestID, { includeHiddenElements: true }); + const customLabel = screen.getByTestId(labelTestID, { includeHiddenElements: true }); expect(startNode).toBeTruthy(); expect(customLabel).toBeTruthy(); expect(customLabel).toHaveTextContent('Custom Label Node'); @@ -496,8 +496,8 @@ describe('TextInput', () => { , ); - const customLabel = screen.getByTestId(labelTestID); - const startContent = screen.getByTestId(startTestID); + const customLabel = screen.getByTestId(labelTestID, { includeHiddenElements: true }); + const startContent = screen.getByTestId(startTestID, { includeHiddenElements: true }); expect(customLabel).toBeTruthy(); expect(startContent).toBeTruthy(); }); @@ -520,12 +520,12 @@ describe('TextInput', () => { , ); - const startNode = screen.getByTestId(startTestID); - const customLabel = screen.getByTestId(labelTestID); + const startNode = screen.getByTestId(startTestID, { includeHiddenElements: true }); + const customLabel = screen.getByTestId(labelTestID, { includeHiddenElements: true }); expect(startNode).toBeTruthy(); expect(customLabel).toBeTruthy(); expect(customLabel).toHaveTextContent('Custom Label Node'); - expect(screen.queryByText('Regular Label')).toBeFalsy(); + expect(screen.queryByText('Regular Label', { includeHiddenElements: true })).toBeFalsy(); }); it('positions label correctly with inside variant and start content', () => { @@ -542,8 +542,8 @@ describe('TextInput', () => { , ); - const label = screen.getByTestId('label-test'); - const startContent = screen.getByTestId('start-content'); + const label = screen.getByTestId('label-test', { includeHiddenElements: true }); + const startContent = screen.getByTestId('start-content', { includeHiddenElements: true }); expect(label).toBeTruthy(); expect(startContent).toBeTruthy(); diff --git a/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx b/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx index f09eb96cac..82f0e08e94 100644 --- a/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx +++ b/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useControlMotionProps } from '../useControlMotionProps'; diff --git a/packages/mobile/src/dates/DateInput.tsx b/packages/mobile/src/dates/DateInput.tsx index 5e25b47d4c..bd1df5293b 100644 --- a/packages/mobile/src/dates/DateInput.tsx +++ b/packages/mobile/src/dates/DateInput.tsx @@ -1,11 +1,11 @@ import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; import { + type BlurEvent, type NativeSyntheticEvent, type StyleProp, type TextInput as NativeTextInput, type TextInputChangeEventData, type TextInputEndEditingEventData, - type TextInputFocusEventData, type ViewStyle, } from 'react-native'; import { IntlDateFormat } from '@coinbase/cds-common/dates/IntlDateFormat'; @@ -90,7 +90,7 @@ export const DateInput = memo( */ const handleBlur = useCallback( - (event: NativeSyntheticEvent) => { + (event: BlurEvent) => { onBlur?.(event); if (!required || !hasTyped.current) return; const error = validateDateInput(inputValue); @@ -122,7 +122,6 @@ export const DateInput = memo( ); diff --git a/packages/mobile/src/dates/DatePicker.tsx b/packages/mobile/src/dates/DatePicker.tsx index 789db33a7f..fada52242a 100644 --- a/packages/mobile/src/dates/DatePicker.tsx +++ b/packages/mobile/src/dates/DatePicker.tsx @@ -232,7 +232,6 @@ export const DatePicker = memo( {showPicker && ( { openCalendarAccessibilityLabel="Birthdate calendar" /> - + diff --git a/packages/mobile/src/dates/__tests__/Calendar.test.tsx b/packages/mobile/src/dates/__tests__/Calendar.test.tsx index f0ee9fb76b..239f2306ab 100644 --- a/packages/mobile/src/dates/__tests__/Calendar.test.tsx +++ b/packages/mobile/src/dates/__tests__/Calendar.test.tsx @@ -86,13 +86,14 @@ describe('Calendar', () => { it('renders days of the week', () => { render(); - // Check for first letter of each day - const sLetters = screen.getAllByText('S'); + // Weekday header row uses aria-hidden; include hidden elements to assert visual labels exist. + const hidden = { includeHiddenElements: true } as const; + const sLetters = screen.getAllByText('S', hidden); expect(sLetters.length).toBeGreaterThanOrEqual(2); // Sunday and Saturday (plus potentially dates) - expect(screen.getByText('M')).toBeTruthy(); - expect(screen.getAllByText('T').length).toBeGreaterThanOrEqual(1); // Tuesday and Thursday - expect(screen.getByText('W')).toBeTruthy(); - expect(screen.getByText('F')).toBeTruthy(); + expect(screen.getByText('M', hidden)).toBeTruthy(); + expect(screen.getAllByText('T', hidden).length).toBeGreaterThanOrEqual(1); // Tuesday and Thursday + expect(screen.getByText('W', hidden)).toBeTruthy(); + expect(screen.getByText('F', hidden)).toBeTruthy(); }); it('handles disabled state correctly', () => { @@ -249,14 +250,15 @@ describe('Calendar', () => { it('days of week header is not accessible to screen readers', () => { render(); - // The days of week header HStack should have accessible={false} - // This is tested indirectly by checking the structure const calendar = screen.getByTestId(testID); expect(calendar).toBeTruthy(); - // Days of week letters should still be present in the DOM - expect(screen.getAllByText('S').length).toBeGreaterThan(0); - expect(screen.getAllByText('M').length).toBeGreaterThan(0); + // Header row is aria-hidden: excluded from default queries (a11y tree) but still rendered. + expect(screen.queryAllByText('S')).toHaveLength(0); + expect(screen.queryAllByText('M')).toHaveLength(0); + const hidden = { includeHiddenElements: true } as const; + expect(screen.getAllByText('S', hidden).length).toBeGreaterThan(0); + expect(screen.getAllByText('M', hidden).length).toBeGreaterThan(0); }); it('respects minDate and disables dates before it', () => { diff --git a/packages/mobile/src/dates/__tests__/DatePicker.test.tsx b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx index 40a311fac6..4b06d9b95c 100644 --- a/packages/mobile/src/dates/__tests__/DatePicker.test.tsx +++ b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx @@ -75,10 +75,7 @@ describe('DatePicker', () => { expect(mockOnOpen).toHaveBeenCalledTimes(1); - // Calendar should be visible - await waitFor(() => { - expect(screen.getByText('Confirm')).toBeTruthy(); - }); + screen.getByText('Confirm'); }); it('closes calendar when handle bar is pressed', async () => { @@ -90,9 +87,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('Confirm')).toBeTruthy(); - }); + screen.getByText('Confirm'); // Close calendar via handle bar using testID const handleBar = screen.getByTestId('handleBar'); @@ -119,9 +114,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByLabelText('Custom close label')).toBeTruthy(); - }); + expect(screen.getByLabelText('Custom close label')).toBeTruthy(); }); it('displays confirm button with custom text', async () => { @@ -131,9 +124,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('Done')).toBeTruthy(); - }); + expect(screen.getByText('Done')).toBeTruthy(); }); it('confirm button is disabled when no date is selected', async () => { @@ -143,10 +134,8 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - const confirmButton = screen.getByRole('button', { name: 'Confirm' }); - expect(confirmButton).toBeDisabled(); - }); + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toBeDisabled(); }); it('confirm button has custom accessibility hint', async () => { @@ -161,10 +150,8 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - const confirmButton = screen.getByRole('button', { name: 'Confirm' }); - expect(confirmButton).toHaveProp('accessibilityHint', 'Custom confirm button hint'); - }); + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toHaveProp('accessibilityHint', 'Custom confirm button hint'); }); it('confirm button is enabled after selecting a date from calendar', async () => { @@ -175,9 +162,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -212,9 +197,7 @@ describe('DatePicker', () => { expect(mockOnOpen).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -268,9 +251,7 @@ describe('DatePicker', () => { expect(mockOnOpen).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Confirm' })).toBeTruthy(); - }); + screen.getByRole('button', { name: 'Confirm' }); // Close calendar using testID const handleBar = screen.getByTestId('handleBar'); @@ -301,10 +282,8 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - // Should show June 2024 (the month of the current date) - expect(screen.getByText('June 2024')).toBeTruthy(); - }); + // Should show June 2024 (the month of the current date) + expect(screen.getByText('June 2024')).toBeTruthy(); }); it('passes disabled state to DateInput and Calendar', () => { @@ -324,9 +303,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Previous month arrow should be disabled since minDate is in current month const prevArrow = screen.getByLabelText('Go to previous month'); @@ -342,9 +319,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Next month arrow should be disabled since maxDate is in current month const nextArrow = screen.getByLabelText('Go to next month'); @@ -360,9 +335,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Check that the calendar is rendered (specific dates being disabled is tested in Calendar.test.tsx) const allButtons = screen.getAllByRole('button'); @@ -447,9 +420,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -483,9 +454,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -512,9 +481,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + expect(screen.getByText('July 2024')).toBeTruthy(); }); it('passes navigation accessibility labels to Calendar', async () => { @@ -530,9 +497,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByLabelText('Next month custom')).toBeTruthy(); - }); + expect(screen.getByLabelText('Next month custom')).toBeTruthy(); expect(screen.getByLabelText('Previous month custom')).toBeTruthy(); }); @@ -552,9 +517,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -589,9 +552,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Confirm' })).toBeTruthy(); - }); + screen.getByRole('button', { name: 'Confirm' }); // Try to press disabled confirm button const confirmButton = screen.getByRole('button', { name: 'Confirm' }); diff --git a/packages/mobile/src/dots/DotCount.tsx b/packages/mobile/src/dots/DotCount.tsx index b6d45920a2..60c3f6f056 100644 --- a/packages/mobile/src/dots/DotCount.tsx +++ b/packages/mobile/src/dots/DotCount.tsx @@ -55,8 +55,6 @@ const [opacityEnter, opacityExit, scaleEnter, scaleExit] = convertMotionConfigs( dotScaleExitConfig, ]); -const dotTextPaddingHorizontal = 6; - const variantColorMap: Record = { negative: 'bgNegative', }; @@ -86,6 +84,18 @@ export type DotCountBaseProps = SharedProps & children?: React.ReactNode; /** Indicates what shape Dot is overlapping */ overlap?: DotOverlap; + /** + * An optional fixed height of the DotCount component. + * Width grows based on content length. + * @default 24 + * */ + height?: number; + /** + * An optional fixed width of the DotCount component. + * By default, width grows based on content length. + * @default auto + * */ + width?: number; }; export type DotCountProps = DotCountBaseProps & { @@ -108,6 +118,8 @@ export const DotCount = memo( variant = 'negative', count, max, + height = dotCountSize, + width, overlap, style, styles, @@ -117,7 +129,7 @@ export const DotCount = memo( const [childrenSize, onChildrenLayout] = useDotsLayout(); const transforms = useDotPinStyles( childrenSize, - { width: dotCountSize + dotTextPaddingHorizontal, height: dotCountSize } as LayoutRectangle, + { width: width ?? height, height } as LayoutRectangle, overlap, ); @@ -143,11 +155,17 @@ export const DotCount = memo( return [ styleSheet.container, { + height, + minWidth: height, + width, + paddingHorizontal: theme.space[0.75], + borderWidth: theme.borderWidth[100], + borderRadius: theme.borderRadius[400], borderColor: theme.color.bgSecondary, backgroundColor: theme.color[variantColorMap[variant]], }, ]; - }, [theme.color, variant]); + }, [height, width, theme.space, theme.borderWidth, theme.borderRadius, theme.color, variant]); // avoid displaying 0 during animations and preserve exit animation useEffect(() => { @@ -189,11 +207,6 @@ export const DotCount = memo( [containerStyles, animatedStyles, styles?.container], ); - const textStyles = useMemo( - () => [{ paddingHorizontal: dotTextPaddingHorizontal }, styles?.text], - [styles?.text], - ); - const rootStyles = useMemo(() => [style, styles?.root], [styles?.root, style]); // only check childrenSize when children is defined @@ -207,7 +220,7 @@ export const DotCount = memo( {!shouldUnmount && shouldShow && ( - + {parseDotCountMaxOverflow(countInternal, max)} @@ -223,9 +236,5 @@ const styleSheet = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', display: 'flex', - borderWidth: 1, - minWidth: dotCountSize, - height: dotCountSize, - borderRadius: 16, }, }); diff --git a/packages/mobile/src/dots/DotSymbol.tsx b/packages/mobile/src/dots/DotSymbol.tsx index 19bf9b7237..ba9b456615 100644 --- a/packages/mobile/src/dots/DotSymbol.tsx +++ b/packages/mobile/src/dots/DotSymbol.tsx @@ -121,7 +121,7 @@ export const DotSymbol = memo( const shouldShow = children !== undefined ? childrenSize !== null : true; return ( - + {children} diff --git a/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx b/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx index 7c6461ed55..0b03dbdf16 100644 --- a/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx +++ b/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx @@ -40,7 +40,9 @@ describe('DotSymbol', () => { nativeEvent: { layout: { height: 12, width: 12 } }, }); - expect(screen.getByTestId('dotsymbol-remote-image').props.source).toEqual({ uri: src }); + expect( + screen.getByTestId('dotsymbol-remote-image', { includeHiddenElements: true }).props.source, + ).toEqual({ uri: src }); }); it('renders an image when source is a string', () => { @@ -55,7 +57,9 @@ describe('DotSymbol', () => { nativeEvent: { layout: { height: 12, width: 12 } }, }); - expect(screen.getByTestId('dotsymbol-remote-image').props.source).toEqual({ uri: src }); + expect( + screen.getByTestId('dotsymbol-remote-image', { includeHiddenElements: true }).props.source, + ).toEqual({ uri: src }); }); it('passes a11y for DotSymbol that have a children', () => { diff --git a/packages/mobile/src/examples/ExampleScreen.tsx b/packages/mobile/src/examples/ExampleScreen.tsx index 08482319aa..1abca8d9e6 100644 --- a/packages/mobile/src/examples/ExampleScreen.tsx +++ b/packages/mobile/src/examples/ExampleScreen.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useMemo, useRef } from 'react'; +import React, { createContext, type JSX, useCallback, useContext, useMemo, useRef } from 'react'; import { ScrollView } from 'react-native'; import { gutter } from '@coinbase/cds-common/tokens/sizing'; import type { PaddingProps } from '@coinbase/cds-common/types'; @@ -34,7 +34,7 @@ export const Example = ({ const { registerExample } = useContext(ExampleContext); // Register exactly once during first render - const exampleNumberRef = useRef(); + const exampleNumberRef = useRef(undefined); if (exampleNumberRef.current === undefined) { exampleNumberRef.current = registerExample(); } diff --git a/packages/mobile/src/gradients/LinearGradient.tsx b/packages/mobile/src/gradients/LinearGradient.tsx index fcc80a32b5..01c262699f 100644 --- a/packages/mobile/src/gradients/LinearGradient.tsx +++ b/packages/mobile/src/gradients/LinearGradient.tsx @@ -46,9 +46,10 @@ type LinearGradientProps = { */ colors: NonNullable[]; /** - * @deprecated Please use the elevated prop instead. This will be removed in a future major release. - * @deprecationExpectedRemoval v6 * Sets layout position between SVG and children. Set it to false when gradient should overlay children content. + * + * @deprecated Use the `elevated` prop instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v6 * @default true */ isBelowChildren?: boolean; diff --git a/packages/mobile/src/hooks/__tests__/constants.ts b/packages/mobile/src/hooks/__tests__/constants.ts deleted file mode 100644 index 5f6db193d9..0000000000 --- a/packages/mobile/src/hooks/__tests__/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const mockStatusBarHeight = 20; diff --git a/packages/mobile/src/hooks/__tests__/useA11y.test.ts b/packages/mobile/src/hooks/__tests__/useA11y.test.ts index ade93a0a97..3629ffa283 100644 --- a/packages/mobile/src/hooks/__tests__/useA11y.test.ts +++ b/packages/mobile/src/hooks/__tests__/useA11y.test.ts @@ -1,5 +1,5 @@ import { AccessibilityInfo } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useA11y } from '../useA11y'; diff --git a/packages/mobile/src/hooks/__tests__/useAppState.test.ts b/packages/mobile/src/hooks/__tests__/useAppState.test.ts index 2f21f2ebfb..cec7c72d8d 100644 --- a/packages/mobile/src/hooks/__tests__/useAppState.test.ts +++ b/packages/mobile/src/hooks/__tests__/useAppState.test.ts @@ -1,46 +1,59 @@ -import type { AppStateStatus } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { AppState, type AppStateStatus } from 'react-native'; +import { renderHook } from '@testing-library/react-native'; import { useAppState } from '../useAppState'; describe('useAppState', () => { - const removeListenerSpy = jest.fn(); - const addListenerSpy = jest.fn(() => { - return { - remove: removeListenerSpy, - }; + const mockRemoveListener = jest.fn(); + let addEventListenerSpy: jest.SpyInstance; + const originalCurrentState = AppState.currentState; + + beforeEach(() => { + jest.clearAllMocks(); + addEventListenerSpy = jest.spyOn(AppState, 'addEventListener').mockReturnValue({ + remove: mockRemoveListener, + }); + }); + + afterEach(() => { + addEventListenerSpy.mockRestore(); + Object.defineProperty(AppState, 'currentState', { + value: originalCurrentState, + writable: true, + configurable: true, + }); }); - const mockCurrentAppState = (state: AppStateStatus) => { - jest.resetModules(); - jest.doMock('react-native/Libraries/AppState/AppState', () => ({ - currentState: state, - addEventListener: addListenerSpy, - })); + const mockCurrentState = (state: AppStateStatus) => { + Object.defineProperty(AppState, 'currentState', { + value: state, + writable: true, + configurable: true, + }); }; it('returns AppState.currentState - active', () => { - mockCurrentAppState('active'); + mockCurrentState('active'); const { result } = renderHook(() => useAppState()); expect(result.current).toBe('active'); }); it('returns AppState.currentState - inactive', () => { - mockCurrentAppState('inactive'); + mockCurrentState('inactive'); const { result } = renderHook(() => useAppState()); expect(result.current).toBe('inactive'); }); it('adds an event listener for state changes', () => { - mockCurrentAppState('active'); + mockCurrentState('active'); renderHook(() => useAppState()); - expect(addListenerSpy).toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalled(); }); it('removes event listener on unmount', () => { - mockCurrentAppState('inactive'); + mockCurrentState('inactive'); const { unmount } = renderHook(() => useAppState()); unmount(); - expect(removeListenerSpy).toHaveBeenCalled(); + expect(mockRemoveListener).toHaveBeenCalled(); }); }); diff --git a/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts b/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts index b1c711fe34..38fb2f878a 100644 --- a/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts +++ b/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { innerDefaults, outerDefaults, useCellSpacing } from '../useCellSpacing'; diff --git a/packages/mobile/src/hooks/__tests__/useDimension.test.ts b/packages/mobile/src/hooks/__tests__/useDimension.test.ts index 9ebaf6a1ad..29f18283c8 100644 --- a/packages/mobile/src/hooks/__tests__/useDimension.test.ts +++ b/packages/mobile/src/hooks/__tests__/useDimension.test.ts @@ -1,10 +1,20 @@ -import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { renderHook } from '@testing-library/react-native'; import { useDimensions } from '../useDimensions'; +const safeAreaInitialMetrics = { + frame: { x: 0, y: 0, width: 0, height: 0 }, + insets: { top: 20, left: 0, right: 0, bottom: 0 }, +}; + describe('useDimensions.test', () => { it('returns screen dimensions', () => { - const { result } = renderHook(() => useDimensions()); + const { result } = renderHook(() => useDimensions(), { + wrapper: ({ children }) => + React.createElement(SafeAreaProvider, { initialMetrics: safeAreaInitialMetrics }, children), + }); expect(result.current.screenHeight).toBe(1334); expect(result.current.screenWidth).toBe(750); diff --git a/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts b/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts index 8fced6da5a..d13b989be9 100644 --- a/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts +++ b/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts @@ -5,25 +5,26 @@ import type { ScrollView, View, } from 'react-native'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-native'; import throttle from 'lodash/throttle'; import { useHorizontalScrollToTarget } from '../useHorizontalScrollToTarget'; jest.mock('lodash/throttle'); +type ThrottledMock = jest.Mock & { cancel: jest.Mock }; + describe('useHorizontalScrollToTarget', () => { let mockScrollView: ScrollView; let mockActiveTarget: View; - let throttledFn: jest.Mock; + let throttledFn: ThrottledMock; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); // Mock throttle to return the function immediately - throttledFn = jest.fn(); - // @ts-expect-error - Testing internal ref assignment + throttledFn = jest.fn() as ThrottledMock; throttledFn.cancel = jest.fn(); (throttle as jest.Mock).mockImplementation((fn) => { throttledFn.mockImplementation(fn); @@ -78,7 +79,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -93,7 +93,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(400); result.current.handleScrollContainerLayout({ @@ -108,7 +107,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -125,7 +123,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -142,7 +139,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -159,7 +155,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -176,7 +171,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 10 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -207,7 +201,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -228,7 +221,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -243,7 +235,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -260,7 +251,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContainerLayout({ nativeEvent: { layout: { width: 500 } }, @@ -275,7 +265,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContainerLayout({ nativeEvent: { layout: { width: 500 } }, @@ -289,13 +278,14 @@ describe('useHorizontalScrollToTarget', () => { describe('active target scrolling', () => { it('should scroll to active target when offscreen left', () => { - const { result, rerender } = renderHook( - ({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), - { initialProps: { activeTarget: null } }, - ); + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null } + >(({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), { + initialProps: { activeTarget: null }, + }); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -307,7 +297,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget }); expect(mockActiveTarget.measureLayout).toHaveBeenCalled(); @@ -319,13 +308,14 @@ describe('useHorizontalScrollToTarget', () => { }); it('should scroll to active target when offscreen right', () => { - const { result, rerender } = renderHook( - ({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), - { initialProps: { activeTarget: null } }, - ); + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null } + >(({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), { + initialProps: { activeTarget: null }, + }); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -337,7 +327,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget }); expect(mockActiveTarget.measureLayout).toHaveBeenCalled(); @@ -349,13 +338,14 @@ describe('useHorizontalScrollToTarget', () => { }); it('should not scroll when target is visible', () => { - const { result, rerender } = renderHook( - ({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), - { initialProps: { activeTarget: null } }, - ); + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null } + >(({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), { + initialProps: { activeTarget: null }, + }); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -367,7 +357,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget }); expect(mockActiveTarget.measureLayout).toHaveBeenCalled(); @@ -375,14 +364,16 @@ describe('useHorizontalScrollToTarget', () => { }); it('should use autoScrollOffset when scrolling', () => { - const { result, rerender } = renderHook( + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null; autoScrollOffset: number } + >( ({ activeTarget, autoScrollOffset }) => useHorizontalScrollToTarget({ activeTarget, autoScrollOffset }), { initialProps: { activeTarget: null, autoScrollOffset: 0 } }, ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -394,7 +385,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget, autoScrollOffset: 20 }); expect(mockScrollView.scrollTo).toHaveBeenCalledWith({ @@ -408,7 +398,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ activeTarget: null })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; }); @@ -421,7 +410,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = null; }); @@ -435,7 +423,6 @@ describe('useHorizontalScrollToTarget', () => { unmount(); - // @ts-expect-error - Testing internal ref assignment expect(throttledFn.cancel).toHaveBeenCalled(); }); }); @@ -445,7 +432,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(500); result.current.handleScrollContainerLayout({ @@ -463,7 +449,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(300); result.current.handleScrollContainerLayout({ @@ -481,7 +466,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ diff --git a/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts b/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts index 07d35ef10b..00ead4cc79 100644 --- a/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts +++ b/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts @@ -1,5 +1,5 @@ import { focusedInputBorderWidth, inputBorderWidth } from '@coinbase/cds-common/tokens/input'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { useInputBorderAnimation } from '../useInputBorderAnimation'; diff --git a/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts b/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts index 24602fbab5..7a76bdc6d2 100644 --- a/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts +++ b/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts @@ -1,6 +1,6 @@ import { act } from 'react'; import { Animated } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { usePressAnimation } from '../usePressAnimation'; diff --git a/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts b/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts index 533dfe17c1..1e7d045c44 100644 --- a/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts +++ b/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts @@ -1,5 +1,5 @@ import { AccessibilityInfo } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react-native'; import { useScreenReaderStatus } from '../useScreenReaderStatus'; @@ -15,9 +15,10 @@ describe('useScreenReaderStatus', () => { it('should return true when screen reader is enabled', async () => { (AccessibilityInfo.isScreenReaderEnabled as jest.Mock).mockResolvedValueOnce(true); - const { result, waitForNextUpdate } = renderHook(() => useScreenReaderStatus()); - await waitForNextUpdate(); - expect(result.current).toBe(true); + const { result } = renderHook(() => useScreenReaderStatus()); + await waitFor(() => { + expect(result.current).toBe(true); + }); }); it('should return false when screen reader is disabled', () => { diff --git a/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts b/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts index 238403be11..1d1564d95a 100644 --- a/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts +++ b/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts @@ -1,5 +1,5 @@ import { act } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useScrollOffset } from '../useScrollOffset'; diff --git a/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx b/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx index 08e037b741..9aa3ac2471 100644 --- a/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx +++ b/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import { ScrollView } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react-native'; +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import { Button } from '../../buttons'; import { Box } from '../../layout'; diff --git a/packages/mobile/src/hooks/__tests__/useStatusBarHeight.test.ts b/packages/mobile/src/hooks/__tests__/useStatusBarHeight.test.ts deleted file mode 100644 index b7a17e5eeb..0000000000 --- a/packages/mobile/src/hooks/__tests__/useStatusBarHeight.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { StatusBar } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; - -import { useStatusBarHeight } from '../useStatusBarHeight'; - -import { mockStatusBarHeight } from './constants'; - -describe('useStatusBarHeight.test', () => { - beforeEach(() => { - jest.resetModules(); - }); - - it('returns status bar height', () => { - const { result } = renderHook(() => useStatusBarHeight()); - - expect(result.current).toBe(mockStatusBarHeight); - }); - - it('returns default status bar height on android', () => { - jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ OS: 'android' })); - - const { result } = renderHook(() => useStatusBarHeight()); - - expect(result.current).toBe(StatusBar.currentHeight); - }); -}); diff --git a/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx b/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx index e53b5ed759..f57725c8ba 100644 --- a/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx +++ b/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; import * as openWebBrowser from '../../utils/openWebBrowser'; diff --git a/packages/mobile/src/hooks/useA11y.ts b/packages/mobile/src/hooks/useA11y.ts index e10497ba90..e6c9d6f7fa 100644 --- a/packages/mobile/src/hooks/useA11y.ts +++ b/packages/mobile/src/hooks/useA11y.ts @@ -17,7 +17,7 @@ import { AccessibilityInfo, findNodeHandle } from 'react-native'; * */ export const useA11y = () => { - const setA11yFocus = useCallback((ref: React.RefObject) => { + const setA11yFocus = useCallback((ref: React.RefObject) => { // TODO: Migrate this to fabric supported API const reactTag = findNodeHandle(ref.current as React.Component); if (reactTag) { diff --git a/packages/mobile/src/hooks/useAppState.ts b/packages/mobile/src/hooks/useAppState.ts index a398a333e2..50f2cb7935 100644 --- a/packages/mobile/src/hooks/useAppState.ts +++ b/packages/mobile/src/hooks/useAppState.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { AppState } from 'react-native'; -import type { AppStateStatus } from 'react-native'; +import { AppState, type AppStateStatus } from 'react-native'; export const useAppState = () => { const [appState, setAppState] = useState(AppState.currentState); diff --git a/packages/mobile/src/hooks/useDimensions.ts b/packages/mobile/src/hooks/useDimensions.ts index 82fa91b25b..51ed8fc44c 100644 --- a/packages/mobile/src/hooks/useDimensions.ts +++ b/packages/mobile/src/hooks/useDimensions.ts @@ -1,6 +1,5 @@ import { useWindowDimensions } from 'react-native'; - -import { useStatusBarHeight } from './useStatusBarHeight'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // The bottom Navigation bar height needs the be accounted for but could not find a lib to help with this. export const IOS_BOTTOM_NAV_BAR_HEIGHT = 50; @@ -8,7 +7,7 @@ export const IOS_BOTTOM_NAV_BAR_HEIGHT = 50; // This is the beginning of our new dimensions hook. It will build on the old retail `useDimensions` hook. export function useDimensions() { const { height: screenHeight, width: screenWidth } = useWindowDimensions(); - const statusBarHeight = useStatusBarHeight(); + const { top: statusBarHeight } = useSafeAreaInsets(); return { screenHeight, screenWidth, diff --git a/packages/mobile/src/hooks/useDotPinStyles.ts b/packages/mobile/src/hooks/useDotPinStyles.ts index 6ec4f30719..f5ac07027b 100644 --- a/packages/mobile/src/hooks/useDotPinStyles.ts +++ b/packages/mobile/src/hooks/useDotPinStyles.ts @@ -1,8 +1,32 @@ import type { LayoutRectangle } from 'react-native'; import type { DotOverlap } from '@coinbase/cds-common'; +/** Valid keys for accessing pin position offsets. */ export type DotPinStylesKey = 'end' | 'start' | 'bottom' | 'top'; +/** + * Calculates positioning offsets for pinning a dot badge to the edges of its parent element. + * + * Returns transform offsets that position the dot so it overlaps the edge by half its size. + * Used by DotCount, DotSymbol, and DotStatusColor to position badges at corners like "top-end". + * + * @param childrenSize - Measured dimensions of the parent/host element + * @param dotSize - Dimensions of the dot badge (number for uniform size, or LayoutRectangle) + * @param overlap - When 'circular', pulls offsets inward to better align with circular parents (e.g., avatars) + * @returns Object with edge offsets { end, start, bottom, top }, or null if dimensions unavailable + * + * @example + * ```tsx + * const transforms = useDotPinStyles(childrenSize, dotSize, overlap); + * // For pin="top-end", use: + * const style = { + * transform: [ + * { translateX: transforms.end }, + * { translateY: transforms.top } + * ] + * }; + * ``` + */ export const useDotPinStyles = ( childrenSize: LayoutRectangle | null = null, dotSize: LayoutRectangle | number | null = null, diff --git a/packages/mobile/src/hooks/useHasNotch.ts b/packages/mobile/src/hooks/useHasNotch.ts index 1cc3065ffa..3b6073288b 100644 --- a/packages/mobile/src/hooks/useHasNotch.ts +++ b/packages/mobile/src/hooks/useHasNotch.ts @@ -1,8 +1,11 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; +/** + * @deprecated This logic is seriously outdated. The last iPhone version to have a 20px status bar was iPhone 8. Most modern iOS devices no longer have a "notch". This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const useHasNotch = () => { const { top } = useSafeAreaInsets(); - // we choose to hide the statusbar on iOS for devices with a notch, which - // has a top inset of more than 20. + // older iphones without a notch (or island for current phones) have a status bar of 20px return top > 20; }; diff --git a/packages/mobile/src/hooks/useScrollTo.ts b/packages/mobile/src/hooks/useScrollTo.ts index 3c536f9ea3..bf68a558b7 100644 --- a/packages/mobile/src/hooks/useScrollTo.ts +++ b/packages/mobile/src/hooks/useScrollTo.ts @@ -18,7 +18,7 @@ export type ScrollToFns = { }; export const useScrollTo = (ref?: AnyRef): [ScrollRef, ScrollToFns] => { - const internalRef = useRef(); + const internalRef = useRef(undefined); const scrollRef = useMergeRefs(ref, internalRef); const scrollTo = useCallback(({ x = 0, y = 0, animated = true }: ScrollToParams) => { internalRef.current?.scrollTo({ x, y, animated }); diff --git a/packages/mobile/src/hooks/useStatusBarHeight.ts b/packages/mobile/src/hooks/useStatusBarHeight.ts index c125b82c52..5404619f1f 100644 --- a/packages/mobile/src/hooks/useStatusBarHeight.ts +++ b/packages/mobile/src/hooks/useStatusBarHeight.ts @@ -1,16 +1,9 @@ -import { useEffect, useState } from 'react'; -import { NativeEventEmitter, NativeModules, Platform, StatusBar } from 'react-native'; -import type { NativeModule } from 'react-native'; - -const { StatusBarManager } = NativeModules; - -type StatusBarNativeModule = { - getHeight: (arg1: ({ height }: { height: number }) => void) => void; -} & NativeModule; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; /** * @deprecated Use `useSafeAreaInsets().top` from `react-native-safe-area-context` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v9 + * * This approach is recommended by Expo and provides more reliable values across platforms. * @see https://docs.expo.dev/versions/latest/sdk/safe-area-context/ * @@ -24,27 +17,6 @@ type StatusBarNativeModule = { * const statusBarHeight = insets.top; */ export const useStatusBarHeight = () => { - const [statusBarHeight, setStatusBarHeight] = useState(); - - useEffect(() => { - if (Platform.OS === 'ios' && StatusBarManager !== undefined) { - const statusBarManager = StatusBarManager as StatusBarNativeModule; - const emitter = new NativeEventEmitter(statusBarManager); - - statusBarManager.getHeight(({ height }: { height: number }) => setStatusBarHeight(height)); - - const subscription = emitter.addListener( - 'statusBarFrameWillChange', - ({ frame: { height } }: { frame: { height: number } }) => { - setStatusBarHeight(height); - }, - ); - - return () => subscription.remove(); - } - setStatusBarHeight(StatusBar.currentHeight); - return () => {}; - }, []); - - return statusBarHeight; + const { top } = useSafeAreaInsets(); + return top; }; diff --git a/packages/mobile/src/icons/Icon.tsx b/packages/mobile/src/icons/Icon.tsx index 5966716216..5ba1a32028 100644 --- a/packages/mobile/src/icons/Icon.tsx +++ b/packages/mobile/src/icons/Icon.tsx @@ -46,7 +46,10 @@ export type IconBaseProps = SharedProps & * @default primary */ color?: ThemeVars.Color; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style`, `styles.icon`, or the `color` prop to customize icon color. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: string | Animated.AnimatedInterpolation; animated?: boolean; style?: Animated.WithAnimatedValue>; @@ -158,7 +161,8 @@ export const Icon = memo(function Icon({ accessibilityRole="image" accessible={!!accessibilityLabel} allowFontScaling={false} - style={iconStyle} + // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern + style={iconStyle as StyleProp} > {glyph} diff --git a/packages/mobile/src/icons/TextIcon.tsx b/packages/mobile/src/icons/TextIcon.tsx index 7e372b243b..684ce895db 100644 --- a/packages/mobile/src/icons/TextIcon.tsx +++ b/packages/mobile/src/icons/TextIcon.tsx @@ -53,7 +53,8 @@ export const TextIcon = memo(function TextIcon({ color: iconColor, }, style, - ] as TextStyle, + // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern + ] as StyleProp, [style, iconColor, iconSize], ); diff --git a/packages/mobile/src/jest.d.ts b/packages/mobile/src/jest.d.ts index 4ffd9e6b26..034d98878c 100644 --- a/packages/mobile/src/jest.d.ts +++ b/packages/mobile/src/jest.d.ts @@ -1,3 +1,50 @@ /// -/// -/// + +/** + * Custom accessibility matcher type declaration. + * Replaces the react-native-accessibility-engine types. + */ + +type AccessibilityViolation = { + pathToComponent: string[]; + problem: string; + solution: string; + link: string; +}; + +type AccessibilityOptions = { + /** Specific rule IDs to check. If not provided, all rules are checked. */ + rules?: string[]; + /** Custom handler to filter or modify violations before the assertion. */ + customViolationHandler?: (violations: AccessibilityViolation[]) => AccessibilityViolation[]; +}; + +type AccessibilityMatchers = { + /** + * Check if a component is accessible according to React Native accessibility rules. + * + * @param options - Optional configuration for accessibility checks + * @example + * expect(screen.getByTestId('my-button')).toBeAccessible(); + * expect(screen.getByTestId('my-button')).toBeAccessible({ + * customViolationHandler: (violations) => violations.filter(v => v.problem !== 'some problem') + * }); + */ + toBeAccessible(options?: AccessibilityOptions): R; +}; + +// Implicit Jest global `expect`. +declare global { + namespace jest { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-empty-object-type + interface Matchers extends AccessibilityMatchers {} + } +} + +// Explicit `@jest/globals` `expect` matchers. +declare module '@jest/expect' { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-empty-object-type + interface Matchers> extends AccessibilityMatchers {} +} + +export {}; diff --git a/packages/mobile/src/layout/Box.tsx b/packages/mobile/src/layout/Box.tsx index cc5a35facc..0a70083eb7 100644 --- a/packages/mobile/src/layout/Box.tsx +++ b/packages/mobile/src/layout/Box.tsx @@ -1,6 +1,6 @@ import React, { forwardRef, memo, useMemo } from 'react'; import { Animated, type StyleProp, View, type ViewProps, type ViewStyle } from 'react-native'; -import type { PinningDirection } from '@coinbase/cds-common'; +import type { PinningDirection, SharedProps } from '@coinbase/cds-common'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { ElevationLevels } from '@coinbase/cds-common/types/ElevationLevels'; @@ -9,34 +9,36 @@ import { useTheme } from '../hooks/useTheme'; import { pinStyles } from '../styles/pinStyles'; import { getStyles, type StyleProps } from '../styles/styleProps'; -export type BoxBaseProps = StyleProps & { - children?: React.ReactNode; - style?: Animated.WithAnimatedValue>; - animated?: boolean; - /** Determines box shadow styles. Parent should have overflow set to visible to ensure styles are not clipped. */ - elevation?: ElevationLevels; - font?: ThemeVars.FontFamily | 'inherit'; - /** Direction in which to absolutely pin the box. */ - pin?: PinningDirection; - /** Add a border around all sides of the box. */ - bordered?: boolean; - /** Add a border to the top side of the box. */ - borderedTop?: boolean; - /** Add a border to the bottom side of the box. */ - borderedBottom?: boolean; - /** Add a border to the leading side of the box. */ - borderedStart?: boolean; - /** Add a border to the trailing side of the box. */ - borderedEnd?: boolean; - /** Add a border to the leading and trailing sides of the box. */ - borderedHorizontal?: boolean; - /** Add a border to the top and bottom sides of the box. */ - borderedVertical?: boolean; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ - dangerouslySetBackground?: string; - /** Used to locate this element in unit and end-to-end tests. */ - testID?: string; -}; +export type BoxBaseProps = SharedProps & + StyleProps & { + children?: React.ReactNode; + style?: Animated.WithAnimatedValue>; + animated?: boolean; + /** Determines box shadow styles. Parent should have overflow set to visible to ensure styles are not clipped. */ + elevation?: ElevationLevels; + font?: ThemeVars.FontFamily | 'inherit'; + /** Direction in which to absolutely pin the box. */ + pin?: PinningDirection; + /** Add a border around all sides of the box. */ + bordered?: boolean; + /** Add a border to the top side of the box. */ + borderedTop?: boolean; + /** Add a border to the bottom side of the box. */ + borderedBottom?: boolean; + /** Add a border to the leading side of the box. */ + borderedStart?: boolean; + /** Add a border to the trailing side of the box. */ + borderedEnd?: boolean; + /** Add a border to the leading and trailing sides of the box. */ + borderedHorizontal?: boolean; + /** Add a border to the top and bottom sides of the box. */ + borderedVertical?: boolean; + /** + * @deprecated Use `style` or the `background` style prop to set custom background colors. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + dangerouslySetBackground?: string; + }; export type BoxProps = BoxBaseProps & Omit; @@ -396,7 +398,8 @@ export const Box = memo( ); return ( - + // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern + } testID={testID} {...props}> {children} ); diff --git a/packages/mobile/src/layout/Fallback.tsx b/packages/mobile/src/layout/Fallback.tsx index db8c0645de..2d32e8a82a 100644 --- a/packages/mobile/src/layout/Fallback.tsx +++ b/packages/mobile/src/layout/Fallback.tsx @@ -19,7 +19,7 @@ export type FallbackBaseProps = { * @default rectangle */ shape?: Shape; - width: number | string; + width: DimensionValue; /** Disables randomization of rectangle shape width. */ disableRandomRectWidth?: boolean; /** @@ -48,7 +48,11 @@ export const Fallback = memo(function Fallback({ [disableRandomRectWidth, rectWidthVariant], ); - const { width, borderRadius } = useFallbackShape(shape, baseWidth, fallbackShapeOptions); + const { width, borderRadius } = useFallbackShape( + shape, + baseWidth, + fallbackShapeOptions, + ); const { activeColorScheme } = useTheme(); const shimmerColor = fallbackShimmer[activeColorScheme]; diff --git a/packages/mobile/src/layout/Group.tsx b/packages/mobile/src/layout/Group.tsx index 3fc7beddcb..e44a9b390f 100644 --- a/packages/mobile/src/layout/Group.tsx +++ b/packages/mobile/src/layout/Group.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { forwardRef, memo, type ReactElement, useMemo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -33,11 +33,11 @@ export type GroupBaseProps = BoxProps & { */ renderItem?: (info: { Wrapper: React.ComponentType>; - item: React.ReactChild; + item: ReactElement | string | number; index: number; isFirst: boolean; isLast: boolean; - }) => React.ReactChild; + }) => ReactElement | string | number; }; export type RenderGroupItem = GroupBaseProps['renderItem']; diff --git a/packages/mobile/src/layout/Spacer.tsx b/packages/mobile/src/layout/Spacer.tsx index b8fe22a8be..4954336872 100644 --- a/packages/mobile/src/layout/Spacer.tsx +++ b/packages/mobile/src/layout/Spacer.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -42,16 +42,14 @@ export const Spacer = memo(function Spacer({ minHorizontal, minVertical, animated, + style, ...viewProps }: SpacerProps) { const theme = useTheme(); const Component = animated ? Animated.View : View; - - return ( - + [ getSpacerStyle({ flexGrow, flexShrink, @@ -63,8 +61,23 @@ export const Spacer = memo(function Spacer({ minHorizontal, minVertical, spacingScaleValues: theme.space, - }) as ViewStyle - } - /> + }) as ViewStyle, + style, + ] as StyleProp, + [ + flexGrow, + flexShrink, + flexBasis, + horizontal, + vertical, + maxHorizontal, + maxVertical, + minHorizontal, + minVertical, + theme.space, + style, + ], ); + + return ; }); diff --git a/packages/mobile/src/layout/__tests__/Box.test.tsx b/packages/mobile/src/layout/__tests__/Box.test.tsx index 0623231ac2..2bb907643e 100644 --- a/packages/mobile/src/layout/__tests__/Box.test.tsx +++ b/packages/mobile/src/layout/__tests__/Box.test.tsx @@ -140,7 +140,7 @@ describe('Box', () => { it('renders width styles', async () => { render( - + Child , ); @@ -150,7 +150,7 @@ describe('Box', () => { expect(screen.getByTestId('parent')).toBeAccessible(); expect(screen.getByTestId('parent')).toHaveStyle({ - width: '321px', + width: 321, maxWidth: 789, minWidth: '66%', }); @@ -158,7 +158,7 @@ describe('Box', () => { it('renders height styles', async () => { render( - + Child , ); @@ -168,7 +168,7 @@ describe('Box', () => { expect(screen.getByTestId('parent')).toBeAccessible(); expect(screen.getByTestId('parent')).toHaveStyle({ - height: '321px', + height: 321, maxHeight: 789, minHeight: '66%', }); @@ -177,12 +177,12 @@ describe('Box', () => { it('renders position styles', async () => { render( Child @@ -194,11 +194,11 @@ describe('Box', () => { expect(screen.getByTestId('parent')).toBeAccessible(); expect(screen.getByTestId('parent')).toHaveStyle({ - bottom: '8rem', + bottom: 8, left: '1000%', position: 'absolute', - right: '30px', - top: '25%', + right: 30, + top: 25, zIndex: 7, }); }); diff --git a/packages/mobile/src/media/Avatar.tsx b/packages/mobile/src/media/Avatar.tsx index c239dffd87..187f5a842a 100644 --- a/packages/mobile/src/media/Avatar.tsx +++ b/packages/mobile/src/media/Avatar.tsx @@ -1,6 +1,8 @@ import React, { memo, useMemo } from 'react'; import { StyleSheet } from 'react-native'; +import { ClipPath, Defs, Path, Rect, Svg } from 'react-native-svg'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; +import { hexagonShapePath } from '@coinbase/cds-common/svg/shape'; import { colorSchemeMap } from '@coinbase/cds-common/tokens/avatar'; import type { AvatarFallbackColor, @@ -21,6 +23,7 @@ import { shapeStyles } from './RemoteImageGroup'; const smallAvatarSize = 44; export const coloredFallbackTestID = 'cds-avatar-colored-fallback'; +const avatarHexagonClipPathId = 'cds-avatar-hexagon-fallback-clip-path'; export const fallbackImageSrc = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gOTAK/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcGCAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwEDBAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgAOAA4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+t80Zo4o4oAM0ZrsPBvw7m8Sxi7uZDa2GcKwHzyeu30HvXdf8Kp0Dytnlz7v+ennHP8Ah+lAHiuaM12XjL4dTeG4jd2sjXViDhiR88f1x1HvXG8UAGaKOKKADI9Kkt4vtFxFEOC7Bc/U4qPJ9KVXZGDLwwOQaAPpS0tY7G1ht4VCRRKEVcdABipc1meG9eh8RaRBeRMNzACRB1R+4/z2rU/OgCK5t47y3lgmUPFKpR1I6gjBr5uu4fst1NCefLdkz64OK+g/EWuQ+HtJnvJmHyjCIerv2Ar55klaWRnblmJJPqaAG5HpRRk+lFABzVnTtOutWvY7W0iMs8hwqj+Z9BVbB9a9o+GXhlNI0VL2VR9rvFD5PVY/4R+PX8vSgCfwX4EXwsDNJdSTXTrh1RisQ/Dv9T+QrrP89aT8qPyoA5Txr4FHilRNHdSQ3Ua4RHYtEfw7fUfrXjOo6dc6Tey2t3EYp4zgqf5j1FfSP5VxvxN8Mpq+jPfRKPtlmpfI6tH/ABD8Ov5+tAHi/NFGDRQBY060+3aja22f9dKsf5kD+tfSKIsaKigKqjAAHAFFFAC/j+lL+P6UUUAH4/pTXRZEZGG5WGCCOooooA+btRtPsOoXVtn/AFMrR/kSKKKKAP/Z'; @@ -105,8 +108,8 @@ export const Avatar = memo( return ( {placeholderLetter} @@ -117,8 +120,8 @@ export const Avatar = memo( return ( {placeholderLetter} @@ -129,8 +132,8 @@ export const Avatar = memo( return ( {placeholderLetter} @@ -149,10 +152,9 @@ export const Avatar = memo( () => ( @@ -162,11 +164,40 @@ export const Avatar = memo( [avatarText, shapeStyle, colorSchemeRgb], ); + const hexagonColoredFallback = useMemo( + () => ( + + + + + + + + + + {avatarText} + + ), + [avatarText, colorSchemeRgb], + ); + return ( + ) : shape === 'hexagon' ? ( + hexagonColoredFallback ) : ( coloredFallback )} @@ -210,4 +243,13 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + hexagonFallbackLabel: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: 'center', + justifyContent: 'center', + }, }); diff --git a/packages/mobile/src/media/Carousel/Carousel.tsx b/packages/mobile/src/media/Carousel/Carousel.tsx index 63020b8098..ab0b2b3ef8 100644 --- a/packages/mobile/src/media/Carousel/Carousel.tsx +++ b/packages/mobile/src/media/Carousel/Carousel.tsx @@ -155,7 +155,6 @@ export const Carousel = memo( return ( {content} diff --git a/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx b/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx index f5360bb595..a63979ef29 100644 --- a/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx +++ b/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react-native'; +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import { Button } from '../../../buttons'; import { Box } from '../../../layout'; diff --git a/packages/mobile/src/media/RemoteImage.tsx b/packages/mobile/src/media/RemoteImage.tsx index 8d590f9e88..f6adb981d2 100644 --- a/packages/mobile/src/media/RemoteImage.tsx +++ b/packages/mobile/src/media/RemoteImage.tsx @@ -13,6 +13,7 @@ import type { import { ClipPath, Defs, Image as SvgImage, Path, Svg, SvgXml } from 'react-native-svg'; import { SvgCssUri } from 'react-native-svg/css'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; +import { hexagonShapePath } from '@coinbase/cds-common/svg/shape'; import type { AspectRatio, AvatarSize, FixedValue, Shape } from '@coinbase/cds-common/types'; import { useTheme } from '../hooks/useTheme'; @@ -43,7 +44,7 @@ type BaseRemoteImageProps = Omit { return ( - + - + {image} @@ -227,11 +228,11 @@ export const RemoteImage = memo(function RemoteImage({ ); } @@ -285,8 +286,8 @@ export const RemoteImage = memo(function RemoteImage({ onError={onError} onLoad={onLoad} source={transformedSource as ImageSourcePropType} - {...props} style={stylesWithDimensions} + {...props} /> ); }); diff --git a/packages/mobile/src/media/RemoteImageGroup.tsx b/packages/mobile/src/media/RemoteImageGroup.tsx index 1d4d70b970..b0f3b96b2b 100644 --- a/packages/mobile/src/media/RemoteImageGroup.tsx +++ b/packages/mobile/src/media/RemoteImageGroup.tsx @@ -87,15 +87,22 @@ export const RemoteImageGroup = ({ if (!isValidElement(child)) { return null; } - const childShape: RemoteImageProps['shape'] = child.props.shape; + + const childShape: RemoteImageProps['shape'] = ( + child as React.ReactElement + ).props.shape; // dynamically apply uniform sizing and shape to all RemoteImage children elements - const clonedChild = React.cloneElement(child as React.ReactElement, { - testID: `${testID ? `${testID}-` : ''}image-${index}`, - width: sizeAsNumber, - height: sizeAsNumber, - ...(childShape ? undefined : { shape }), - }); + const clonedChild = React.cloneElement( + // the type of child (after isValidElement check) is not inferred so it must be typecast here + child as React.ReactElement, + { + testID: `${testID ? `${testID}-` : ''}image-${index}`, + width: sizeAsNumber, + height: sizeAsNumber, + ...(childShape ? undefined : { shape }), + }, + ); // zIndex is progressively lower so that each child is stacked below the previous one const zIndex = -index; diff --git a/packages/mobile/src/media/__stories__/Avatar.stories.tsx b/packages/mobile/src/media/__stories__/Avatar.stories.tsx index d11a525b85..c9fd9af00c 100644 --- a/packages/mobile/src/media/__stories__/Avatar.stories.tsx +++ b/packages/mobile/src/media/__stories__/Avatar.stories.tsx @@ -77,6 +77,7 @@ const AvatarScreen = () => { accessibilityLabel="" alt="" borderColor="bgPositive" + borderWidth={0} size={size} src={image} /> diff --git a/packages/mobile/src/media/__tests__/Avatar.test.tsx b/packages/mobile/src/media/__tests__/Avatar.test.tsx index 1928e70edb..34bc5b87bd 100644 --- a/packages/mobile/src/media/__tests__/Avatar.test.tsx +++ b/packages/mobile/src/media/__tests__/Avatar.test.tsx @@ -14,12 +14,10 @@ describe('Avatar', () => { , ); - const image = screen.getByTestId('avatar-image'); + const image = screen.getByTestId('avatar-image', { includeHiddenElements: true }); expect(image).toBeTruthy(); expect(image?.props.source).toEqual({ uri: src }); - expect(image).toBeAccessible(); - expect(screen.queryByText('T')).toBeFalsy(); }); @@ -145,4 +143,23 @@ describe('Avatar', () => { expect(screen.getByText('T')).toBeTruthy(); }); + + it('renders a hexagon fallback when shape is hexagon and name is provided without src', async () => { + render( + + + , + ); + + await screen.findByTestId(coloredFallbackTestID); + + expect(screen.getByTestId(coloredFallbackTestID)).toBeAccessible(); + expect(screen.getByText('T')).toBeTruthy(); + }); }); diff --git a/packages/mobile/src/media/__tests__/RemoteImage.test.tsx b/packages/mobile/src/media/__tests__/RemoteImage.test.tsx index e158b8ec78..7cf54d2779 100644 --- a/packages/mobile/src/media/__tests__/RemoteImage.test.tsx +++ b/packages/mobile/src/media/__tests__/RemoteImage.test.tsx @@ -12,7 +12,7 @@ const mockSvgFetch = async () => ); describe('RemoteImage', () => { - it('shouldApplyDarkModeEnhacements border styles takes precedence over custom borderColor and passes a11y', () => { + it('shouldApplyDarkModeEnhacements border styles takes precedence over custom borderColor', () => { render( { /> , ); - const image = screen.queryByTestId('remoteimage'); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toBeTruthy(); - expect(image).toBeAccessible(); - expect(image).toHaveStyle({ borderWidth: 1, }); }); - it('darkModeEnhancementsApplied border styles takes precedence over custom borderColor and passes a11y', () => { + it('darkModeEnhancementsApplied border styles takes precedence over custom borderColor', () => { render( { /> , ); - const image = screen.queryByTestId('remoteimage'); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toBeTruthy(); - expect(image).toBeAccessible(); - expect(image).toHaveStyle({ borderWidth: 1, }); }); - it('has a default shape of square and passes a11y', () => { + it('has a default shape of square', () => { render( , ); - const image = screen.queryByTestId('remoteimage'); - - expect(image).toBeAccessible(); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toHaveStyle({ borderRadius: defaultTheme.borderRadius[100], }); }); - it('if width/height/size is not set, it will default to size = m. Passes a11y', () => { + it('if width/height/size is not set, it will default to size = m', () => { render( , ); - const image = screen.queryByTestId('remoteimage'); - - expect(image).toBeAccessible(); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toHaveStyle({ width: theme.avatarSize.m, @@ -134,9 +126,9 @@ describe('RemoteImage', () => { , ); - expect(screen.getByRole('image')).toHaveProp('accessibilityElementsHidden', false); - expect(screen.getByRole('image')).toHaveProp('importantForAccessibility', 'auto'); - expect(screen.getByLabelText('A label')).toBeTruthy(); + const image = screen.getByLabelText('A label'); + expect(image).toHaveProp('accessibilityElementsHidden', false); + expect(image).toHaveProp('importantForAccessibility', 'auto'); expect(screen.getByHintText('A hint')).toBeTruthy(); }); diff --git a/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx b/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx index 5e85c6784b..08ee974b54 100644 --- a/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx +++ b/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx @@ -69,7 +69,9 @@ describe('RemoteImageGroup', () => { render(); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ width: 24, @@ -84,7 +86,9 @@ describe('RemoteImageGroup', () => { await screen.findByTestId(TEST_ID); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ borderRadius: defaultTheme.borderRadius[1000], @@ -104,7 +108,9 @@ describe('RemoteImageGroup', () => { render(); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ width: 30, @@ -117,7 +123,9 @@ describe('RemoteImageGroup', () => { render(); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ width: 32, diff --git a/packages/mobile/src/motion/__tests__/Pulse.test.tsx b/packages/mobile/src/motion/__tests__/Pulse.test.tsx index d7ff7a4023..d3cd727139 100644 --- a/packages/mobile/src/motion/__tests__/Pulse.test.tsx +++ b/packages/mobile/src/motion/__tests__/Pulse.test.tsx @@ -63,7 +63,7 @@ describe('Pulse', () => { const ref = { current: null } as React.RefObject<{ play: () => Promise; stop: () => Promise; - }>; + } | null>; render( Children diff --git a/packages/mobile/src/motion/__tests__/Shake.test.tsx b/packages/mobile/src/motion/__tests__/Shake.test.tsx index 69cb40adb4..3812700e11 100644 --- a/packages/mobile/src/motion/__tests__/Shake.test.tsx +++ b/packages/mobile/src/motion/__tests__/Shake.test.tsx @@ -62,7 +62,7 @@ describe('Shake', () => { it('exposes imperative handlers that start the animation', () => { const ref = { current: null } as React.RefObject<{ play: () => Promise; - }>; + } | null>; render( Children diff --git a/packages/mobile/src/navigation/BrowserBarSearchInput.tsx b/packages/mobile/src/navigation/BrowserBarSearchInput.tsx index 489cd85020..7c14acee76 100644 --- a/packages/mobile/src/navigation/BrowserBarSearchInput.tsx +++ b/packages/mobile/src/navigation/BrowserBarSearchInput.tsx @@ -1,5 +1,5 @@ import { memo, useCallback } from 'react'; -import type { NativeSyntheticEvent, TextInputFocusEventData } from 'react-native'; +import type { BlurEvent, FocusEvent } from 'react-native'; import { SearchInput, type SearchInputProps } from '../controls/SearchInput'; @@ -29,7 +29,7 @@ export const BrowserBarSearchInput = memo( const { setHideStart, setHideEnd } = useBrowserBarContext(); const handleFocus = useCallback( - (e: NativeSyntheticEvent) => { + (e: FocusEvent) => { if (expandOnFocus) { setHideStart(true); setHideEnd(true); @@ -40,7 +40,7 @@ export const BrowserBarSearchInput = memo( ); const handleBlur = useCallback( - (e: NativeSyntheticEvent) => { + (e: BlurEvent) => { setHideEnd(false); setHideStart(false); onBlur?.(e); diff --git a/packages/mobile/src/navigation/NavigationTitleSelect.tsx b/packages/mobile/src/navigation/NavigationTitleSelect.tsx index 6edb4ed15f..ebdda497fa 100644 --- a/packages/mobile/src/navigation/NavigationTitleSelect.tsx +++ b/packages/mobile/src/navigation/NavigationTitleSelect.tsx @@ -1,8 +1,8 @@ import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { selectCellMobileSpacingConfig } from '@coinbase/cds-common/tokens/select'; -import { SelectProvider } from '../controls/SelectContext'; -import { SelectOption } from '../controls/SelectOption'; -import { useSelect } from '../controls/useSelect'; +import { Cell } from '../cells/Cell'; +import { CellAccessory } from '../cells/CellAccessory'; import { Icon } from '../icons'; import { HStack } from '../layout/HStack'; import { type DrawerRefBaseProps, Tray } from '../overlays'; @@ -35,16 +35,18 @@ export const NavigationTitleSelect = memo( setVisible(true); }, []); - const handleOptionPress = useCallback(() => { - trayRef.current?.handleClose(); - }, []); + const handleOptionPress = useCallback( + (id: string) => { + trayRef.current?.handleClose(); + onChange(id); + }, + [onChange], + ); const label = useMemo(() => { return options.find((option) => option.id === value)?.label; }, [options, value]); - const selectContextValue = useSelect({ onChange, value }); - return ( <> @@ -61,11 +63,26 @@ export const NavigationTitleSelect = memo( {visible && ( - - {options.map(({ id, label }) => ( - - ))} - + {options.map(({ id, label }) => { + const selected = id === value; + return ( + : undefined} + borderRadius={0} + onPress={() => handleOptionPress(id)} + selected={id === value} + {...selectCellMobileSpacingConfig} + > + {!!label && ( + + {label} + + )} + + ); + })} )} diff --git a/packages/mobile/src/navigation/TopNavBar.tsx b/packages/mobile/src/navigation/TopNavBar.tsx index 7a72f73bb5..2a2b6226b8 100644 --- a/packages/mobile/src/navigation/TopNavBar.tsx +++ b/packages/mobile/src/navigation/TopNavBar.tsx @@ -162,7 +162,7 @@ export const TopNavBar = memo( paddingBottom={paddingBottom} paddingTop={paddingTop} paddingX={paddingX} - position="sticky" + position="absolute" right={0} top={0} width="100%" diff --git a/packages/mobile/src/overlays/Alert.tsx b/packages/mobile/src/overlays/Alert.tsx index fdf3550570..680d5d38d6 100644 --- a/packages/mobile/src/overlays/Alert.tsx +++ b/packages/mobile/src/overlays/Alert.tsx @@ -1,9 +1,8 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'; -import { Modal as RNModal } from 'react-native'; +import { Modal as RNModal, type ViewStyle } from 'react-native'; import type { ButtonVariant, IllustrationPictogramNames, - PositionStyles, SharedProps, } from '@coinbase/cds-common/types'; @@ -17,7 +16,6 @@ import { Overlay } from './overlay/Overlay'; import { useAlertAnimation } from './useAlertAnimation'; export type AlertBaseProps = SharedProps & - Pick & Pick & { /** * Alert title @@ -57,6 +55,7 @@ export type AlertBaseProps = SharedProps & * @default horizontal */ actionLayout?: 'horizontal' | 'vertical'; + zIndex?: ViewStyle['zIndex']; }; export type AlertProps = AlertBaseProps; @@ -191,7 +190,7 @@ export const Alert = memo( { if (!nodes.length) return null; @@ -46,6 +51,16 @@ export const PortalHost = memo(({ nodes }: PortalHostProps) => { return <>{elements}; }); +/** + * Required root-level provider that enables CDS overlay components (Modal, Toast, Alert, + * Tooltip, Tray). Manages the registry of active overlays and provides the context for + * overlay state management and toast queuing. + * + * Unlike the PortalProvider in cds-web, cds-mobile does not use DOM portals. Overlay components render + * above other content using React Native's native Modal component. + * + * Must be rendered once near the root of your application, alongside ThemeProvider. + */ export const PortalProvider: React.FC> = ({ children, toastBottomOffset = 0, @@ -63,6 +78,11 @@ export const PortalProvider: React.FC { const { nodes } = usePortal(); return ; diff --git a/packages/mobile/src/overlays/Toast.tsx b/packages/mobile/src/overlays/Toast.tsx index d406c5237f..4d76e66f33 100644 --- a/packages/mobile/src/overlays/Toast.tsx +++ b/packages/mobile/src/overlays/Toast.tsx @@ -1,4 +1,5 @@ import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { ToastBaseProps as CommonToastBaseProps, ToastRefHandle, @@ -7,7 +8,6 @@ import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; import { Button } from '../buttons'; import { useA11y } from '../hooks/useA11y'; -import { useTheme } from '../hooks/useTheme'; import { Box, type BoxProps, HStack } from '../layout'; import { ColorSurge } from '../motion/ColorSurge'; import { Text } from '../typography/Text'; @@ -24,7 +24,7 @@ export const Toast = memo( { text, action, onWillHide, onDidHide, bottomOffset, variant, accessibilityLabel, ...props }, ref, ) => { - const theme = useTheme(); + const { bottom: safeAreaBottom } = useSafeAreaInsets(); const [{ opacity, bottom }, animateIn, animateOut] = useToastAnimation(); const { announceForA11y } = useA11y(); const defaultA11yLabel = text + (action ? action.label : ''); @@ -71,8 +71,9 @@ export const Toast = memo( return ( - + diff --git a/packages/mobile/src/overlays/__tests__/Toast.test.tsx b/packages/mobile/src/overlays/__tests__/Toast.test.tsx index e3a1a8592b..57aace5792 100644 --- a/packages/mobile/src/overlays/__tests__/Toast.test.tsx +++ b/packages/mobile/src/overlays/__tests__/Toast.test.tsx @@ -1,7 +1,8 @@ import { Animated } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { fireEvent, render, screen } from '@testing-library/react-native'; -import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultThemeProvider, SAFE_AREA_METRICS } from '../../utils/testHelpers'; import { Toast } from '../Toast'; jest.mock('react-native/Libraries/Animated/Animated', () => { @@ -31,9 +32,11 @@ describe('Toast', () => { it('renders text and passes a11y', () => { const text = 'Toast copy'; render( - - - , + + + + + , ); expect(screen.getByTestId('mock-toast')).toBeAccessible(); @@ -50,15 +53,17 @@ describe('Toast', () => { testID: 'toast-action', }; render( - - - , + + + + + , ); fireEvent.press(screen.getByTestId(action.testID)); @@ -72,9 +77,11 @@ describe('Toast', () => { it('triggers animation', () => { const text = 'Toast copy'; render( - - - , + + + + + , ); expect(animationParallelSpy).toHaveBeenCalled(); diff --git a/packages/mobile/src/overlays/drawer/Drawer.tsx b/packages/mobile/src/overlays/drawer/Drawer.tsx index 918c8e9ea8..56b7253cfb 100644 --- a/packages/mobile/src/overlays/drawer/Drawer.tsx +++ b/packages/mobile/src/overlays/drawer/Drawer.tsx @@ -8,7 +8,7 @@ import React, { useRef, useState, } from 'react'; -import { Animated, Keyboard, Modal, Platform, useWindowDimensions } from 'react-native'; +import { Animated, Keyboard, Modal, Platform, StatusBar, useWindowDimensions } from 'react-native'; import type { ModalProps, PressableProps, StyleProp, ViewStyle } from 'react-native'; import { drawerAnimationDefaultDuration, @@ -28,6 +28,7 @@ import type { SharedProps, } from '@coinbase/cds-common/types'; +import { useHasNotch } from '../../hooks/useHasNotch'; import { useTheme } from '../../hooks/useTheme'; import { Box } from '../../layout/Box'; import { HandleBar, type HandleBarProps } from '../handlebar/HandleBar'; @@ -39,7 +40,7 @@ import { useDrawerAnimation } from './useDrawerAnimation'; import { useDrawerPanResponder } from './useDrawerPanResponder'; import { useDrawerSpacing } from './useDrawerSpacing'; -export type DrawerRenderChildren = React.FC<{ handleClose: () => void }>; +export type DrawerRenderChildren = (args: { handleClose: () => void }) => React.ReactNode; export type DrawerRefBaseProps = { /** ref callback that animates out the drawer */ @@ -310,6 +311,12 @@ export const Drawer = memo( ], ); + // this outdated logic needs to be removed + // rather than hiding on the presence of a "notch" (all modern phones based on how we determine), we should hide the status bar when any overlay is visible + // see: https://linear.app/coinbase/issue/CDS-1557/temporarily-hide-status-bar-in-all-overlay-components + const hasNotch = useHasNotch(); + const hideStatusBar = hasNotch && ['left', 'right', 'top'].includes(pin); + return ( - + {/* for some reason we are hiding on iOS only (see linked issue above) */} + {Platform.select({ + ios: hideStatusBar ? } + opacity={new Animated.Value(1)} + placement="top" + subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} + testID={TEST_ID} + translateY={new Animated.Value(5)} + />, ); expect(screen.getByTestId(TEST_ID)).toBeAccessible(); }); it('renders content', () => { - render( - - test content} - opacity={new Animated.Value(1)} - placement="top" - subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} - testID={TEST_ID} - translateY={new Animated.Value(5)} - /> - , + renderWithProviders( + test content} + opacity={new Animated.Value(1)} + placement="top" + subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} + testID={TEST_ID} + translateY={new Animated.Value(5)} + />, ); expect(screen.getByText('test content')).toBeTruthy(); @@ -56,17 +66,15 @@ describe('InternalTooltip.test', () => { }); it('renders string content', () => { - render( - - - , + renderWithProviders( + , ); expect(screen.getByText('test content')).toBeTruthy(); @@ -74,20 +82,18 @@ describe('InternalTooltip.test', () => { }); it('renders active colorScheme when invertColorScheme sets to false', () => { - render( - - test content} - elevation={2} - invertColorScheme={false} - opacity={new Animated.Value(1)} - placement="top" - subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} - testID={TEST_ID} - translateY={new Animated.Value(5)} - /> - , + renderWithProviders( + test content} + elevation={2} + invertColorScheme={false} + opacity={new Animated.Value(1)} + placement="top" + subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} + testID={TEST_ID} + translateY={new Animated.Value(5)} + />, ); expect(screen.getByTestId(TEST_ID)).toHaveStyle({ diff --git a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx index e599017ba9..89db3eb0a6 100644 --- a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx +++ b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx @@ -1,5 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import { Button } from '../../../buttons'; import { useDimensions } from '../../../hooks/useDimensions'; diff --git a/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts b/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts index f1d5a6ef8b..cd0144ba90 100644 --- a/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts +++ b/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts @@ -38,7 +38,7 @@ export const basicCenterSubject: UseTooltipPositionTestData = { }, dimensions: galaxyScreenDimensions, - expectedTop: { opacity: 1, start: 112.13333129882812, top: 212.97779083251953 }, + expectedTop: { opacity: 1, start: 112.13333129882812, top: 238.93334579467773 }, // To do: expectedBottom: { diff --git a/packages/mobile/src/overlays/tooltip/__tests__/useTooltipPosition.test.tsx b/packages/mobile/src/overlays/tooltip/__tests__/useTooltipPosition.test.tsx new file mode 100644 index 0000000000..c22b5b0f75 --- /dev/null +++ b/packages/mobile/src/overlays/tooltip/__tests__/useTooltipPosition.test.tsx @@ -0,0 +1,214 @@ +import { Platform } from 'react-native'; +import { renderHook } from '@testing-library/react-native'; + +import { useDimensions } from '../../../hooks/useDimensions'; +import { DefaultThemeProvider } from '../../../utils/testHelpers'; +import type { UseTooltipPositionParams } from '../TooltipProps'; +import { useTooltipPosition } from '../useTooltipPosition'; + +jest.mock('../../../hooks/useDimensions'); + +const mockUseDimensions = (mocks: ReturnType) => { + (useDimensions as jest.Mock).mockReturnValue(mocks); +}; + +const createHookInstance = (options: UseTooltipPositionParams) => { + return renderHook(() => useTooltipPosition(options), { + wrapper: DefaultThemeProvider, + }); +}; + +const STATUS_BAR_HEIGHT = 24; +const SCREEN_HEIGHT = 800; +const SCREEN_WIDTH = 400; + +const baseSubjectLayout = { + height: 40, + width: 100, + pageOffsetX: 150, + pageOffsetY: 200, +}; + +const baseTooltipLayout = { + height: 50, + width: 150, + x: 0, + y: 0, +}; + +describe('useTooltipPosition - Android Edge-to-Edge', () => { + const originalPlatformOS = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + // Set platform to Android for these tests + Platform.OS = 'android'; + }); + + afterEach(() => { + Platform.OS = originalPlatformOS; + }); + + describe('Android Edge-to-Edge Mode (safe area insets > 0)', () => { + // In edge-to-edge mode, useSafeAreaInsets().top returns the status bar height + // The Modal and main view share the same coordinate system (both start from screen top) + // Therefore, NO adjustment should be made to pageOffsetY + + it('positions tooltip above subject without status bar offset', () => { + // Edge-to-edge: statusBarHeight comes from safe area insets and is > 0 + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = pageOffsetY - tooltipHeight (no status bar subtraction) + // = 200 - 50 = 150 + const expectedTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; + + expect(result.current.top).toBe(expectedTop); + expect(result.current.opacity).toBe(1); + }); + + it('positions tooltip below subject without status bar offset', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'bottom', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = pageOffsetY + subjectHeight (no status bar subtraction) + // = 200 + 40 = 240 + const expectedTop = baseSubjectLayout.pageOffsetY + baseSubjectLayout.height; + + expect(result.current.top).toBe(expectedTop); + expect(result.current.opacity).toBe(1); + }); + }); + + describe('Android Non-Edge-to-Edge Mode (safe area insets = 0)', () => { + // In non-edge-to-edge mode, useSafeAreaInsets().top returns 0 + // But the coordinate systems are still offset by the status bar + // Therefore, we need to subtract StatusBar.currentHeight from pageOffsetY + + it('positions tooltip above subject with status bar offset adjustment', () => { + // Non-edge-to-edge: statusBarHeight from safe area insets is 0 + // But StatusBar.currentHeight should be used for the offset + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: 0, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = (pageOffsetY - StatusBar.currentHeight) - tooltipHeight + // With StatusBar.currentHeight ≈ 24, this should be: (200 - 24) - 50 = 126 + // Note: The actual StatusBar.currentHeight value would come from the native module + // For this test, we verify the offset IS applied (top should be less than edge-to-edge case) + const edgeToEdgeTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; // 150 + + // In non-edge-to-edge, the top should be offset by the status bar height + // So it should be: 150 - ACTUAL_STATUS_BAR_HEIGHT + // We expect this to be LESS than the edge-to-edge case + expect(result.current.top).toBeLessThan(edgeToEdgeTop); + expect(result.current.opacity).toBe(1); + }); + + it('positions tooltip below subject with status bar offset adjustment', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: 0, + }); + + const { result } = createHookInstance({ + placement: 'bottom', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = (pageOffsetY - StatusBar.currentHeight) + subjectHeight + // With StatusBar.currentHeight ≈ 24, this should be: (200 - 24) + 40 = 216 + const edgeToEdgeTop = baseSubjectLayout.pageOffsetY + baseSubjectLayout.height; // 240 + + // In non-edge-to-edge, the top should be offset by the status bar height + expect(result.current.top).toBeLessThan(edgeToEdgeTop); + expect(result.current.opacity).toBe(1); + }); + }); + + describe('Android with yShiftByStatusBarHeight flag', () => { + // When yShiftByStatusBarHeight is true, the status bar offset should NOT be applied + // This is for cases where the tooltip is already in a context with aligned coordinates + + it('does not apply status bar offset when yShiftByStatusBarHeight is true', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + yShiftByStatusBarHeight: true, + }); + + // With yShiftByStatusBarHeight=true, should use pageOffsetY directly + const expectedTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; + + expect(result.current.top).toBe(expectedTop); + }); + }); +}); + +describe('useTooltipPosition - iOS (baseline comparison)', () => { + const originalPlatformOS = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; + }); + + afterEach(() => { + Platform.OS = originalPlatformOS; + }); + + it('positions tooltip without status bar offset on iOS', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // iOS always uses pageOffsetY directly (no status bar subtraction) + const expectedTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; + + expect(result.current.top).toBe(expectedTop); + expect(result.current.opacity).toBe(1); + }); +}); diff --git a/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts b/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts index 4369b1fb5a..2f63fe42eb 100644 --- a/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts +++ b/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { Platform } from 'react-native'; +import { Platform, StatusBar } from 'react-native'; import { gutter } from '@coinbase/cds-common/tokens/sizing'; import { IOS_BOTTOM_NAV_BAR_HEIGHT, useDimensions } from '../../hooks/useDimensions'; @@ -31,10 +31,20 @@ export const useTooltipPosition = ({ const { pageOffsetY } = subjectLayout; + // On Android, we detect edge-to-edge mode by checking useSafeAreaInsets().top: + // - When > 0: App content extends behind the status bar (edge-to-edge enabled). + // The tooltip and subject share the same coordinate origin, so no adjustment needed. + // - When === 0: App content is placed below the status bar (edge-to-edge disabled). + // The subject's pageOffsetY is measured from screen top, but the tooltip is + // rendered inside a Modal whose coordinate system starts below the status bar. + // We subtract StatusBar.currentHeight to reconcile these two origins. + const isEdgeToEdge = statusBarHeight > 0; const actualPageYOffset = Platform.OS === 'ios' || yShiftByStatusBarHeight ? pageOffsetY - : pageOffsetY - (statusBarHeight ?? 0); + : isEdgeToEdge + ? pageOffsetY + : pageOffsetY - (StatusBar.currentHeight ?? 0); return calculatedPlacement === 'bottom' ? actualPageYOffset + (subjectLayout?.height ?? 0) diff --git a/packages/mobile/src/overlays/tray/Tray.tsx b/packages/mobile/src/overlays/tray/Tray.tsx index 6578022243..3b15d19423 100644 --- a/packages/mobile/src/overlays/tray/Tray.tsx +++ b/packages/mobile/src/overlays/tray/Tray.tsx @@ -24,7 +24,7 @@ import { type DrawerRefBaseProps, } from '../drawer/Drawer'; -export type TrayRenderChildren = React.FC<{ handleClose: () => void }>; +export type TrayRenderChildren = (args: { handleClose: () => void }) => React.ReactNode; export type TrayBaseProps = Omit & { /** Component to render as the Tray content */ diff --git a/packages/mobile/src/overlays/useModal.ts b/packages/mobile/src/overlays/useModal.ts index c9dd8af227..56c221bf4e 100644 --- a/packages/mobile/src/overlays/useModal.ts +++ b/packages/mobile/src/overlays/useModal.ts @@ -1,7 +1,7 @@ import { useModal } from '@coinbase/cds-common/overlays/useModal'; /** - * @deprecated Use the visible and onRequestClose props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started. This will be removed in a future major release. + * @deprecated Use the `visible` and `onRequestClose` props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started. This will be removed in a future major release. * @deprecationExpectedRemoval v7 */ export { useModal }; diff --git a/packages/mobile/src/page/PageFooter.tsx b/packages/mobile/src/page/PageFooter.tsx index 92049d357b..41f4dc21d8 100644 --- a/packages/mobile/src/page/PageFooter.tsx +++ b/packages/mobile/src/page/PageFooter.tsx @@ -2,9 +2,10 @@ import React, { forwardRef, memo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageFooterHeight } from '@coinbase/cds-common/tokens/page'; -import type { PositionStyles, SharedProps } from '@coinbase/cds-common/types'; +import type { SharedProps } from '@coinbase/cds-common/types'; import { Box, type BoxProps } from '../layout/Box'; +import type { PositionStyles } from '../styles/styleProps'; export type PageFooterBaseProps = SharedProps & PositionStyles & { diff --git a/packages/mobile/src/page/PageHeader.tsx b/packages/mobile/src/page/PageHeader.tsx index 955f3390f3..0038001e15 100644 --- a/packages/mobile/src/page/PageHeader.tsx +++ b/packages/mobile/src/page/PageHeader.tsx @@ -2,11 +2,12 @@ import React, { forwardRef, memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageHeaderHeight } from '@coinbase/cds-common/tokens/page'; -import type { PositionStyles, SharedProps } from '@coinbase/cds-common/types'; +import type { SharedProps } from '@coinbase/cds-common/types'; import { Box, type BoxProps } from '../layout/Box'; import { HStack } from '../layout/HStack'; import { VStack } from '../layout/VStack'; +import type { PositionStyles } from '../styles/styleProps'; import { Text } from '../typography/Text'; export type PageHeaderBaseProps = SharedProps & diff --git a/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx b/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx index 209dc71b08..3787c8bd8e 100644 --- a/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx +++ b/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx @@ -43,11 +43,11 @@ const PageFooterInPageScreen = () => { Primary Content diff --git a/packages/mobile/src/page/__stories__/PageHeader.stories.tsx b/packages/mobile/src/page/__stories__/PageHeader.stories.tsx index ece6b2daa7..9d95ea2526 100644 --- a/packages/mobile/src/page/__stories__/PageHeader.stories.tsx +++ b/packages/mobile/src/page/__stories__/PageHeader.stories.tsx @@ -38,7 +38,7 @@ const exampleProps = { ), intermediary1: Intermediary Content, intermediary2: ( - + Hello there. This is a rather long text sentence since I do not have lorem ipsum handy. Hello there. This is a rather long text sentence since I do not have lorem ipsum handy. diff --git a/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx b/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx index cad188d261..96a7a24872 100644 --- a/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx +++ b/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx @@ -23,7 +23,7 @@ const PageHeaderInErrorEmptyState = () => { - + { } - position="sticky" start={exampleProps.start} title={exampleProps.title} - top="0" + top={0} /> Primary Content diff --git a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx index 6afa2475fd..b40840d8ed 100644 --- a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx @@ -1,17 +1,20 @@ -import { memo, useEffect, useMemo } from 'react'; -import { animated, useSpring } from '@react-spring/native'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { durations } from '@coinbase/cds-common/motion/tokens'; import { HStack } from '../layout/HStack'; +import { mobileCurves } from '../motion/convertMotionConfig'; import { Text } from '../typography/Text'; import type { StepperHeaderComponent } from './Stepper'; -const AnimatedHStack = animated(HStack); +const AnimatedHStack = Animated.createAnimatedComponent(HStack); export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( function DefaultStepperHeaderHorizontal({ activeStep, complete, + disableAnimateOnMount, flatStepIds, style, paddingBottom = 1.5, @@ -20,27 +23,41 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( fontFamily = font, ...props }) { - const [spring, springApi] = useSpring( - { - from: { opacity: 0 }, - to: { opacity: 1 }, - reset: true, - }, - [], - ); + const opacity = useSharedValue(disableAnimateOnMount ? 1 : 0); + const disableAnimateOnMountRef = useRef(disableAnimateOnMount); + const isInitialRender = useRef(true); + + const [displayedStep, setDisplayedStep] = useState(activeStep); + const [displayedComplete, setDisplayedComplete] = useState(complete); - // TO DO: resetting the spring doesn't work like it does in react-spring on web - // need to look into this deeper and understand why there is a difference in behavior useEffect(() => { - springApi.start({ - from: { opacity: 0 }, - to: { opacity: 1 }, - reset: true, - }); - }, [springApi, activeStep]); + if (isInitialRender.current) { + isInitialRender.current = false; + setDisplayedStep(activeStep); + setDisplayedComplete(complete); + if (disableAnimateOnMountRef.current) return; + opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear }); + return; + } + + // Fade out with old text, then swap text and fade in + opacity.value = withTiming(0, { duration: durations.fast1, easing: mobileCurves.linear }); + + const timeout = setTimeout(() => { + setDisplayedStep(activeStep); + setDisplayedComplete(complete); + opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear }); + }, durations.fast1 + durations.fast1); + + return () => clearTimeout(timeout); + }, [activeStep, complete, opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); - const styles = useMemo(() => [style, spring] as any, [style, spring]); - const flatStepIndex = activeStep ? flatStepIds.indexOf(activeStep.id) : -1; + const styles = useMemo(() => [style, animatedStyle], [style, animatedStyle]); + const flatStepIndex = displayedStep ? flatStepIds.indexOf(displayedStep.id) : -1; const emptyText = ' '; // Simple space for React Native return ( @@ -52,7 +69,7 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( {...props} > - {!activeStep || complete ? ( + {!displayedStep || displayedComplete ? ( emptyText ) : ( @@ -65,12 +82,12 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( > {flatStepIndex + 1}/{flatStepIds.length} - {activeStep.label && typeof activeStep.label === 'string' ? ( + {displayedStep.label && typeof displayedStep.label === 'string' ? ( - {activeStep.label} + {displayedStep.label} ) : ( - activeStep.label + displayedStep.label )} )} diff --git a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx index 7110050e13..7d07a67213 100644 --- a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx @@ -1,11 +1,12 @@ -import { memo } from 'react'; -import { animated, to } from '@react-spring/native'; +import { memo, useCallback, useEffect } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { Box } from '../layout/Box'; import type { StepperProgressComponent } from './Stepper'; -const AnimatedBox = animated(Box); +const AnimatedBox = Animated.createAnimatedComponent(Box); export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( function DefaultStepperProgressHorizontal({ @@ -19,7 +20,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( progress, complete, isDescendentActive, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, style, @@ -33,6 +34,24 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( height = 4, ...props }) { + const containerWidth = useSharedValue(0); + const animatedProgress = useSharedValue(progress); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + containerWidth.value = event.nativeEvent.layout.width; + }, + [containerWidth], + ); + + useEffect(() => { + animatedProgress.value = withTiming(progress, progressTimingConfig); + }, [progress, progressTimingConfig, animatedProgress]); + + const animatedStyle = useAnimatedStyle(() => ({ + width: animatedProgress.value * containerWidth.value, + })); + return ( @@ -57,7 +77,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( } borderRadius={borderRadius} height="100%" - width={to([progress], (width) => `${width * 100}%`)} + style={animatedStyle} /> ); diff --git a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx index d582c81709..1530a96872 100644 --- a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx +++ b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx @@ -1,13 +1,13 @@ -import { memo, useCallback, useMemo } from 'react'; -import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted'; +import { memo, useCallback, useEffect, useMemo } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { flattenSteps } from '@coinbase/cds-common/stepper/utils'; -import { animated, to, useSpring } from '@react-spring/native'; import { Box } from '../layout/Box'; import type { StepperProgressComponent, StepperValue } from './Stepper'; -const AnimatedBox = animated(Box); +const AnimatedBox = Animated.createAnimatedComponent(Box); export const DefaultStepperProgressVertical: StepperProgressComponent = memo( function DefaultStepperProgressVertical({ @@ -23,9 +23,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( isDescendentActive, style, activeStepLabelElement, - progressSpringConfig, - animate = true, - disableAnimateOnMount, + progressTimingConfig, background = 'bgLine', defaultFill = 'bgLinePrimarySubtle', activeFill = 'bgLinePrimarySubtle', @@ -36,7 +34,6 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( width = 2, ...props }) { - const hasMounted = useHasMounted(); const isLastStep = flatStepIds[flatStepIds.length - 1] === step.id; // Count the total number of sub-steps in the current step's tree @@ -56,35 +53,45 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( [], ); + // Fractional fill for steps with sub-steps. For all other cases, return 1 + // and let the cascade's `progress` prop control whether the bar is filled. const progressHeight = useMemo(() => { const totalSubSteps = countAllSubSteps(step.subSteps ?? []); - if (complete) return 1; - if (active && totalSubSteps === 0) return 1; - if (active && !isDescendentActive) return 0; - if (isDescendentActive) { + if (active && totalSubSteps > 0 && !isDescendentActive) return 0; + if (isDescendentActive && totalSubSteps > 0) { const activePosition = findSubStepPosition(step.subSteps ?? [], activeStepId); return activePosition / totalSubSteps; } - if (visited) return 1; - return 0; + return 1; }, [ countAllSubSteps, step.subSteps, - complete, active, isDescendentActive, - visited, findSubStepPosition, activeStepId, ]); - const fillHeightSpring = useSpring({ - height: progressHeight, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - config: progressSpringConfig, - }); + const containerHeight = useSharedValue(0); + const targetHeight = progress * progressHeight; + const animatedHeight = useSharedValue(targetHeight); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + containerHeight.value = event.nativeEvent.layout.height; + }, + [containerHeight], + ); + + useEffect(() => { + animatedHeight.value = withTiming(targetHeight, progressTimingConfig); + }, [targetHeight, progressTimingConfig, animatedHeight]); + + const animatedStyle = useAnimatedStyle(() => ({ + height: animatedHeight.value * containerHeight.value, + })); if (depth > 0 || isLastStep) return null; @@ -93,6 +100,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( background={background} flexGrow={1} minHeight={minHeight} + onLayout={handleLayout} position="relative" style={style} width={width} @@ -110,8 +118,8 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( ? visitedFill : defaultFill } - height={to([progress, fillHeightSpring.height], (p, h) => `${p * h * 100}%`)} position="absolute" + style={animatedStyle} width="100%" /> diff --git a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx index 8e94ff1798..fdf3221c85 100644 --- a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx @@ -24,7 +24,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( styles, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepHorizontal, @@ -75,7 +75,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -140,7 +140,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx index ae544fc189..77aad211e6 100644 --- a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx +++ b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx @@ -27,7 +27,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( styles, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepVertical, @@ -80,7 +80,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -146,7 +146,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/mobile/src/stepper/Stepper.tsx b/packages/mobile/src/stepper/Stepper.tsx index d1a48d81e0..af97aa541f 100644 --- a/packages/mobile/src/stepper/Stepper.tsx +++ b/packages/mobile/src/stepper/Stepper.tsx @@ -1,19 +1,15 @@ -import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; +import type { WithTimingConfig } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted'; -import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; +import { durations } from '@coinbase/cds-common/motion/tokens'; import { containsStep, flattenSteps, isStepVisited } from '@coinbase/cds-common/stepper/utils'; import type { IconName } from '@coinbase/cds-common/types'; -import { - type SpringConfig, - type SpringValue as SpringValueType, - useSprings, -} from '@react-spring/native'; import type { IconProps } from '../icons/Icon'; import { Box, type BoxBaseProps, type BoxProps } from '../layout/Box'; import { VStack } from '../layout/VStack'; +import { mobileCurves } from '../motion/convertMotionConfig'; import { DefaultStepperHeaderHorizontal } from './DefaultStepperHeaderHorizontal'; import { DefaultStepperIconVertical } from './DefaultStepperIconVertical'; @@ -73,13 +69,13 @@ export type StepperStepProps = Record & BoxProps & { /** - * An animated SpringValue between 0 and 1. - * You can use this to animate your own custom Progress subcomponent. + * A value between 0 and 1 representing the step's progress. + * Progress bar subcomponents animate to this value internally. */ - progress: SpringValueType; + progress: number; activeStepLabelElement: View | null; setActiveStepLabelElement: (element: View) => void; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: WithTimingConfig; animate?: boolean; disableAnimateOnMount?: boolean; completedStepAccessibilityLabel?: string; @@ -110,6 +106,7 @@ export type StepperHeaderProps = Record activeStep: StepperValue | null; flatStepIds: string[]; complete?: boolean; + disableAnimateOnMount?: boolean; style?: StyleProp; }; @@ -129,9 +126,9 @@ export type StepperProgressProps< Metadata extends Record = Record, > = StepperSubcomponentProps & BoxProps & { - progress: SpringValueType; + progress: number; activeStepLabelElement: View | null; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: WithTimingConfig; animate?: boolean; disableAnimateOnMount?: boolean; defaultFill?: ThemeVars.Color; @@ -215,9 +212,9 @@ export type StepperBaseProps = Record | null; /** An optional component to render in place of the default Header subcomponent. Set to null to render nothing in this slot. */ StepperHeaderComponent?: StepperHeaderComponent | null; - /** The spring config to use for the progress spring. */ - progressSpringConfig?: SpringConfig; - /** Whether to animate the progress spring. + /** The timing config to use for the progress animation. */ + progressTimingConfig?: WithTimingConfig; + /** Whether to animate the progress bar. * @default true */ animate?: boolean; @@ -248,8 +245,12 @@ export type StepperProps = Record = Record>( props: StepperProps & { ref?: React.Ref }, @@ -287,14 +288,13 @@ const StepperBase = memo( StepperHeaderComponent = direction === 'vertical' ? null : (DefaultStepperHeaderHorizontal as StepperHeaderComponent), - progressSpringConfig = defaultProgressSpringConfig, + progressTimingConfig = defaultProgressTimingConfig, animate = true, disableAnimateOnMount, ...props }: StepperProps, ref: React.Ref, ) => { - const hasMounted = useHasMounted(); const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); // Derive activeStep from activeStepId @@ -339,92 +339,57 @@ const StepperBase = memo( : -1; }, [activeStepId, steps]); - const previousComplete = usePreviousValue(complete) ?? false; - const previousActiveStepIndex = usePreviousValue(activeStepIndex) ?? -1; + // The effective cascade target: when complete, fill all steps up to the last one. + // Otherwise, fill up to activeStepIndex. + const cascadeTarget = complete ? steps.length - 1 : activeStepIndex; - const [progressSprings, progressSpringsApi] = useSprings(steps.length, (index) => ({ - progress: complete ? 1 : 0, - config: progressSpringConfig, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - })); + // Cascade animation state: advances one step at a time toward cascadeTarget. + // When disableAnimateOnMount is false (default), start unfilled (-1) so the + // cascade animates bars one-at-a-time up to the target on mount. + const [filledStepIndex, setFilledStepIndex] = useState(() => + disableAnimateOnMount ? cascadeTarget : -1, + ); + const targetStepIndexRef = useRef(cascadeTarget); useEffect(() => { - // update the previous values for next render - let stepsToAnimate: number[] = []; - let isAnimatingForward = false; - - // Case when going from not-complete to complete - if (Boolean(complete) !== previousComplete) { - if (complete) { - // Going to complete: animate remaining steps to filled. - // Use previousActiveStepIndex to determine which steps are already filled before the completion state update, - const lastFilledIndex = Math.max(activeStepIndex, previousActiveStepIndex); - stepsToAnimate = Array.from( - { length: steps.length - lastFilledIndex - 1 }, - (_, i) => lastFilledIndex + 1 + i, - ); - isAnimatingForward = true; - } else { - // Going from complete: animate from end down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: steps.length - activeStepIndex - 1 }, - (_, i) => steps.length - 1 - i, - ); - isAnimatingForward = false; - } - } + targetStepIndexRef.current = cascadeTarget; - // Case for normal step navigation (e.g. step 1 => step 2) - else if (activeStepIndex !== previousActiveStepIndex) { - if (activeStepIndex > previousActiveStepIndex) { - // Forward: animate from previousActiveStepIndex+1 to activeStepIndex - stepsToAnimate = Array.from( - { length: activeStepIndex - previousActiveStepIndex }, - (_, i) => previousActiveStepIndex + 1 + i, - ); - isAnimatingForward = true; - } else { - // Backward: animate from previousActiveStepIndex down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: previousActiveStepIndex - activeStepIndex }, - (_, i) => previousActiveStepIndex - i, - ); - isAnimatingForward = false; - } + if (!animate) { + setFilledStepIndex(cascadeTarget); + return; } - const animateNextStep = () => { - if (stepsToAnimate.length === 0) return; - const stepIndex = stepsToAnimate.shift(); - if (stepIndex === undefined) return; - - progressSpringsApi.start((index) => - index === stepIndex - ? { - progress: isAnimatingForward ? 1 : 0, - config: progressSpringConfig, - onRest: animateNextStep, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - } - : {}, - ); - }; - - // start the animation loop for relevant springs (stepsToAnimate) - animateNextStep(); - }, [ - progressSpringsApi, - complete, - steps.length, - steps, - activeStepIndex, - previousActiveStepIndex, - previousComplete, - progressSpringConfig, - animate, - disableAnimateOnMount, - hasMounted, - ]); + // Advance one step immediately to kick off the cascade + setFilledStepIndex((prev) => { + if (prev === cascadeTarget) return prev; + return prev < cascadeTarget ? prev + 1 : prev - 1; + }); + + // Continue advancing on a fixed interval for fluid, overlapping springs + const interval = setInterval(() => { + setFilledStepIndex((prev) => { + const target = targetStepIndexRef.current; + if (prev === target) return prev; + return prev < target ? prev + 1 : prev - 1; + }); + }, cascadeStaggerMs); + + return () => clearInterval(interval); + }, [cascadeTarget, animate]); + + // Compute progress for each step: 1 if filled, 0 if not + const getStepProgress = useCallback( + (index: number) => { + if (!animate) { + if (complete) return 1; + if (activeStepIndex < 0) return 0; + return index <= activeStepIndex ? 1 : 0; + } + if (filledStepIndex < 0) return 0; + return index <= filledStepIndex ? 1 : 0; + }, + [complete, animate, activeStepIndex, filledStepIndex], + ); return ( @@ -448,42 +414,43 @@ const StepperBase = memo( ? containsStep({ step, targetStepId: activeStepId }) : false; const RenderedStepComponent = step.Component ?? StepperStepComponent; + + if (!RenderedStepComponent) return null; + return ( - RenderedStepComponent && ( - - ) + ); })} diff --git a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx index 835f437ac8..1637559b47 100644 --- a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx +++ b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx @@ -3,6 +3,7 @@ import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum'; import { useStepper } from '@coinbase/cds-common/stepper/useStepper'; import { Button } from '../../buttons'; +import { Switch } from '../../controls/Switch'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons/Icon'; import { HStack, VStack } from '../../layout'; @@ -216,6 +217,29 @@ const NoActiveStep = () => { return ; }; +// ------------------------------------------------------------ +// Disable Animate on Mount +// ------------------------------------------------------------ +const DisableAnimateOnMount = () => { + const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false); + + return ( + + setDisableAnimateOnMount((prev) => !prev)} + > + disableAnimateOnMount + + + + ); +}; + // ------------------------------------------------------------ // Custom Progress Component // ------------------------------------------------------------ @@ -264,6 +288,10 @@ const StepperHorizontalScreen = () => { + + + + diff --git a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx index caf2bb5179..aa067c3d6f 100644 --- a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx +++ b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx @@ -8,6 +8,7 @@ import { import { Button } from '../../buttons'; import { ListCell } from '../../cells'; import { Collapsible } from '../../collapsible'; +import { Switch } from '../../controls/Switch'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons/Icon'; import { Box, HStack, VStack } from '../../layout'; @@ -247,6 +248,36 @@ const InitialActiveStep = () => { return ; }; +// ------------------------------------------------------------ +// Disable Animate on Mount +// ------------------------------------------------------------ +const disableAnimateOnMountSteps: StepperValue[] = [ + { id: 'first-step', label: 'First step' }, + { id: 'second-step', label: 'Second step' }, + { id: 'third-step', label: 'Third step' }, + { id: 'final-step', label: 'Final step' }, +]; + +const DisableAnimateOnMount = () => { + const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false); + + return ( + + setDisableAnimateOnMount((prev) => !prev)} + > + disableAnimateOnMount + + + + ); +}; + // ------------------------------------------------------------ // Nested Steps // ------------------------------------------------------------ @@ -733,6 +764,10 @@ const StepperVerticalScreen = () => { + + + + diff --git a/packages/mobile/src/sticky-footer/StickyFooter.tsx b/packages/mobile/src/sticky-footer/StickyFooter.tsx index 8e5c68d452..07b9c80a85 100644 --- a/packages/mobile/src/sticky-footer/StickyFooter.tsx +++ b/packages/mobile/src/sticky-footer/StickyFooter.tsx @@ -7,7 +7,7 @@ import { Box, type BoxProps } from '../layout'; export type StickyFooterProps = BoxProps & { /** * Whether to apply a box shadow to the StickyFooter element. - * @deprecated Use elevation instead. This will be removed in a future major release. + * @deprecated Use `elevation` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v8 */ elevated?: boolean; diff --git a/packages/mobile/src/styles/__tests__/getStyles.test.ts b/packages/mobile/src/styles/__tests__/getStyles.test.ts new file mode 100644 index 0000000000..c95b624328 --- /dev/null +++ b/packages/mobile/src/styles/__tests__/getStyles.test.ts @@ -0,0 +1,93 @@ +import type { Theme } from '../../core/theme'; +import { defaultTheme } from '../../themes/defaultTheme'; +import type { StyleProps } from '../styleProps'; +import { getStyles } from '../styleProps'; + +const theme: Theme = { + ...defaultTheme, + activeColorScheme: 'light', + spectrum: defaultTheme.lightSpectrum!, + color: defaultTheme.lightColor!, +}; + +describe('getStyles', () => { + it('skips undefined values', () => { + const styleProps: StyleProps = { padding: 1, width: undefined }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ padding: 8 }); + expect(result).not.toHaveProperty('width'); + }); + + it('passes through non-themed props as-is', () => { + const styleProps: StyleProps = { + width: 100, + height: '50%', + alignSelf: 'flex-start', + }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ + width: 100, + height: '50%', + alignSelf: 'flex-start', + }); + }); + + it('resolves themed space props (e.g. padding) from theme', () => { + const styleProps: StyleProps = { padding: 1, paddingTop: 2 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ padding: 8, paddingTop: 16 }); + }); + + it('resolves margin from theme with negated lookup', () => { + const styleProps: StyleProps = { margin: -1, marginTop: -2 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ margin: -8, marginTop: -16 }); + }); + + it('resolves themed color props from theme', () => { + const styleProps: StyleProps = { color: 'fg', background: 'bgPrimary' }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ + color: theme.color.fg, + backgroundColor: theme.color.bgPrimary, + }); + }); + + it('expands paddingX to paddingStart and paddingEnd', () => { + const styleProps: StyleProps = { paddingX: 1 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ paddingStart: 8, paddingEnd: 8 }); + }); + + it('expands marginY to marginTop and marginBottom', () => { + const styleProps: StyleProps = { marginY: -2 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ marginTop: -16, marginBottom: -16 }); + }); + + it('skips themed props when value is null', () => { + const styleProps = { + padding: 1, + margin: -2, + color: 'fg', + } as StyleProps & { + padding?: number | null; + margin?: number | null; + color?: keyof Theme['color'] | null; + }; + const withNull = { + ...styleProps, + padding: null, + margin: null, + color: null, + }; + const result = getStyles(withNull as unknown as StyleProps, theme); + expect(result).toEqual({}); + }); + + it('passes through null for non-themed dimension props', () => { + const styleProps = { width: null, height: 100 } as StyleProps; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ width: null, height: 100 }); + }); +}); diff --git a/packages/mobile/src/styles/styleProps.ts b/packages/mobile/src/styles/styleProps.ts index 101ac5c5b9..b1f7a4be90 100644 --- a/packages/mobile/src/styles/styleProps.ts +++ b/packages/mobile/src/styles/styleProps.ts @@ -1,11 +1,16 @@ -import type { TextStyle, ViewStyle } from 'react-native'; -import type { DimensionValue, Position } from '@coinbase/cds-common'; +import type { DimensionValue, TextStyle, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { ElevationLevels } from '@coinbase/cds-common/types/ElevationLevels'; import type { TypeOrNumber } from '@coinbase/cds-common/types/TypeOrNumber'; import type { Theme } from '../core/theme'; +/** Position-related style props using React Native types. Use this instead of common's PositionStyles for mobile. */ +export type PositionStyles = Pick< + ViewStyle, + 'position' | 'top' | 'bottom' | 'left' | 'right' | 'zIndex' +>; + type NegativeSpace = TypeOrNumber<'0' | `-${Exclude}`>; // TO DO: If possible, refactor DimensionValue to ViewStyle['width'] etc @@ -42,8 +47,8 @@ export type StyleProps = { alignSelf?: ViewStyle['alignSelf']; flexDirection?: ViewStyle['flexDirection']; flexWrap?: ViewStyle['flexWrap']; - position?: Position; - // position?: ViewStyle['position']; + // position?: Position; + position?: ViewStyle['position']; zIndex?: ViewStyle['zIndex']; padding?: ThemeVars.Space; paddingX?: ThemeVars.Space; @@ -67,12 +72,6 @@ export type StyleProps = { minHeight?: DimensionValue; maxWidth?: DimensionValue; maxHeight?: DimensionValue; - // width?: ViewStyle['width']; - // height?: ViewStyle['height']; - // minWidth?: ViewStyle['minWidth']; - // minHeight?: ViewStyle['minHeight']; - // maxWidth?: ViewStyle['maxWidth']; - // maxHeight?: ViewStyle['maxHeight']; aspectRatio?: ViewStyle['aspectRatio']; top?: DimensionValue; bottom?: DimensionValue; @@ -142,7 +141,7 @@ export const getStyles = (styleProps: StyleProps, theme: Theme) => { for (const styleProp in styleProps) { const value = styleProps[styleProp as keyof StyleProps]; - if (typeof value === 'undefined') continue; + if (value === undefined) continue; // If there are no stylePropAliases for this styleProp... if (typeof stylePropAliases[styleProp as keyof typeof stylePropAliases] === 'undefined') { @@ -151,10 +150,10 @@ export const getStyles = (styleProps: StyleProps, theme: Theme) => { style[styleProp as keyof typeof style] = value as any; } // If it is themed and it is margin* prop - else if (styleProp.startsWith('margin')) { + else if (styleProp.startsWith('margin') && value !== null) { style[styleProp as keyof typeof style] = -( theme[themedStyleProps[styleProp as keyof typeof themedStyleProps]] as any - )[-value as any] as any; + )[-(value as any)] as any; } // If it is themed... else { @@ -169,10 +168,10 @@ export const getStyles = (styleProps: StyleProps, theme: Theme) => { style[propAlias as keyof typeof style] = value as any; } // If it is themed and it is margin* prop - else if (styleProp.startsWith('margin')) { + else if (styleProp.startsWith('margin') && value !== null) { style[propAlias as keyof typeof style] = -( theme[themedStyleProps[styleProp as keyof typeof themedStyleProps]] as any - )[-value as any] as any; + )[-(value as number)] as any; } // If it is themed... else { diff --git a/packages/mobile/src/system/AndroidNavigationBar.tsx b/packages/mobile/src/system/AndroidNavigationBar.tsx index e333931754..3fa076dfef 100644 --- a/packages/mobile/src/system/AndroidNavigationBar.tsx +++ b/packages/mobile/src/system/AndroidNavigationBar.tsx @@ -35,6 +35,23 @@ export const useAndroidNavigationBarUpdater = ({ }, [bg, statusBarStyle]); }; +/** + * Updates the **Android system navigation bar** (bottom bar) colors to match the active CDS theme. + * + * This component is **side-effect only** (renders `null`). When mounted, it sets: + * - **navigation bar background color** to the theme background (`theme.color.bg`) + * - **navigation bar icon brightness** (light/dark) based on the computed status bar style + * + * ### When to use + * - Your app wants the Android navigation bar to visually match the CDS theme (light/dark) + * - You intentionally want an opaque navigation bar that matches your app background (non edge-to-edge look). + * + * ### When NOT to use + * - Your app already manages system bars via another library/app-level integration. + * - You intentionally want to keep the OS default navigation bar styling. + * - You are using Android edge-to-edge defaults (transparent system bars / scrims) and want the platform to + * manage navigation bar transparency + contrast automatically. + */ export const AndroidNavigationBar = memo((props: AndroidNavigationBarProps) => { const updateAndroidNavigationBar = useAndroidNavigationBarUpdater(props); const hasRun = useRef(false); diff --git a/packages/mobile/src/system/PressableOpacity.tsx b/packages/mobile/src/system/PressableOpacity.tsx index 8b77888c72..324bfb2a89 100644 --- a/packages/mobile/src/system/PressableOpacity.tsx +++ b/packages/mobile/src/system/PressableOpacity.tsx @@ -18,7 +18,7 @@ export type PressableOpacityProps = Omit< */ export const PressableOpacity = ({ children, ...props }: PressableOpacityProps) => { return ( - + {children} ); diff --git a/packages/mobile/src/system/ThemeProvider.tsx b/packages/mobile/src/system/ThemeProvider.tsx index 8c8cdddca4..5eb155d694 100644 --- a/packages/mobile/src/system/ThemeProvider.tsx +++ b/packages/mobile/src/system/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, memo, useContext, useMemo } from 'react'; import type { ColorScheme } from '@coinbase/cds-common/core/theme'; import type { Theme, ThemeConfig } from '../core/theme'; @@ -7,9 +7,27 @@ export type ThemeContextValue = Theme; export const ThemeContext = createContext(undefined); -// export type ThemeProviderProps = SystemProviderProps & -// ThemeManagerProps & -// FramerMotionProviderProps; +// Not used for any theme optimizations in the mobile ThemeProvider, but exported for feature-parity with cds-web +/** + * Diff two themes and return a new partial theme with only the differences. + */ +export const diffThemes = (theme: Theme, parentTheme?: Theme) => { + if (!parentTheme) return theme; + const themeDiff = { + id: theme.id, + activeColorScheme: theme.activeColorScheme, + } as Record; + (Object.keys(theme) as (keyof Theme)[]).forEach((key) => { + if (key === 'id' || key === 'activeColorScheme') return; + themeDiff[key] = {}; + Object.keys(theme[key] ?? {}).forEach((value) => { + if ((theme[key] as any)?.[value] !== (parentTheme[key] as any)?.[value]) { + themeDiff[key][value] = (theme[key] as any)[value]; + } + }); + }); + return themeDiff as Partial; +}; export type ThemeProviderProps = { theme: ThemeConfig; @@ -17,7 +35,7 @@ export type ThemeProviderProps = { children?: React.ReactNode; }; -export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProviderProps) => { +export const ThemeProvider = memo(({ theme, activeColorScheme, children }: ThemeProviderProps) => { const themeApi = useMemo(() => { const activeSpectrumKey = activeColorScheme === 'dark' ? 'darkSpectrum' : 'lightSpectrum'; const activeColorKey = activeColorScheme === 'dark' ? 'darkColor' : 'lightColor'; @@ -26,22 +44,22 @@ export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProvi if (!theme[activeColorKey]) throw Error( - `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeColorScheme} colors are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeColorScheme} colors are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); if (!theme[activeSpectrumKey]) throw Error( - `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeSpectrumKey} values are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeSpectrumKey} values are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); if (theme[inverseSpectrumKey] && !theme[inverseColorKey]) throw Error( - `ThemeProvider theme has ${inverseSpectrumKey} values defined but no ${inverseColorKey} colors are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider theme has ${inverseSpectrumKey} values defined but no ${inverseColorKey} colors are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); if (theme[inverseColorKey] && !theme[inverseSpectrumKey]) throw Error( - `ThemeProvider theme has ${inverseColorKey} colors defined but no ${inverseSpectrumKey} values are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider theme has ${inverseColorKey} colors defined but no ${inverseSpectrumKey} values are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); return { @@ -53,14 +71,14 @@ export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProvi }, [theme, activeColorScheme]); return {children}; -}; +}); export type InvertedThemeProviderProps = { children?: React.ReactNode; }; /** Falls back to the currently active colorScheme if the inverse colors are not defined in the theme. */ -export const InvertedThemeProvider = ({ children }: InvertedThemeProviderProps) => { +export const InvertedThemeProvider = memo(({ children }: InvertedThemeProviderProps) => { const context = useContext(ThemeContext); if (!context) throw Error('InvertedThemeProvider must be used within a ThemeProvider'); const inverseColorScheme = context.activeColorScheme === 'dark' ? 'light' : 'dark'; @@ -72,4 +90,4 @@ export const InvertedThemeProvider = ({ children }: InvertedThemeProviderProps) {children} ); -}; +}); diff --git a/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx b/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx deleted file mode 100644 index a7b0b7cc47..0000000000 --- a/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { figma } from '@figma/code-connect'; - -import { AndroidNavigationBar } from '../AndroidNavigationBar'; - -figma.connect( - AndroidNavigationBar, - 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10414%3A896', - { - imports: [ - "import { AndroidNavigationBar } from '@coinbase/cds-mobile/system/AndroidNavigationBar'", - ], - props: { - showsearch27799: figma.boolean('show search'), - showhelpcenter176314: figma.boolean('show help center'), - showsecondarycta24034: figma.boolean('show secondary cta'), - shownotification24028: figma.boolean('show notification'), - type156900: figma.instance('type'), - showpagetitle80: figma.boolean('show page title'), - showtabs24024: figma.boolean('show tabs'), - showprimarycta24032: figma.boolean('show primary cta'), - showbackarrow24022: figma.boolean('show back arrow'), - device: figma.enum('device', { - desktop: 'desktop', - tablet: 'tablet', - 'responsive mobile': 'responsive-mobile', - }), - }, - example: () => , - }, -); diff --git a/packages/mobile/src/system/__stories__/AndroidNavigationBar.stories.tsx b/packages/mobile/src/system/__stories__/AndroidNavigationBar.stories.tsx new file mode 100644 index 0000000000..85e210f36b --- /dev/null +++ b/packages/mobile/src/system/__stories__/AndroidNavigationBar.stories.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { Button } from '../../buttons'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useTheme } from '../../hooks/useTheme'; +import { VStack } from '../../layout/VStack'; +import { Text } from '../../typography/Text'; +import { AndroidNavigationBar } from '../AndroidNavigationBar'; + +const ThemeDemo = ({ colorScheme }: { colorScheme: 'light' | 'dark' }) => { + const theme = useTheme(); + + return ( + + + Active Color Scheme: {theme.activeColorScheme} + + The Android navigation bar should match the theme background color. + + + + + + + ); +}; + +const AndroidNavigationBarScreen = () => { + return ( + + + + + AndroidNavigationBar is a side-effect only component that renders null. When mounted, it + updates the Android system navigation bar (bottom bar) colors to match the active CDS + theme. + + + Note: Only works on Android API 26+ (Android 8.0+). On iOS or older Android versions, + the component has no effect. + + + + + + + + + ); +}; + +export default AndroidNavigationBarScreen; diff --git a/packages/mobile/src/system/__stories__/Palette.stories.tsx b/packages/mobile/src/system/__stories__/Palette.stories.tsx index a848dcbe20..a155305b7d 100644 --- a/packages/mobile/src/system/__stories__/Palette.stories.tsx +++ b/packages/mobile/src/system/__stories__/Palette.stories.tsx @@ -18,9 +18,9 @@ const Palette = ({ elevation }: { elevation?: ElevationLevels }) => { diff --git a/packages/mobile/src/system/__stories__/Pressable.stories.tsx b/packages/mobile/src/system/__stories__/Pressable.stories.tsx index 0d9e6b8fb4..9ab3cc2011 100644 --- a/packages/mobile/src/system/__stories__/Pressable.stories.tsx +++ b/packages/mobile/src/system/__stories__/Pressable.stories.tsx @@ -102,7 +102,7 @@ const PressableScreen = () => { accessibilityRole="button" background={color as ThemeVars.Color} > - + {color} @@ -123,7 +123,7 @@ const PressableScreen = () => { accessibilityRole="button" background={color as ThemeVars.Color} > - + {color} diff --git a/packages/mobile/src/system/__stories__/Spectrum.stories.tsx b/packages/mobile/src/system/__stories__/Spectrum.stories.tsx index 4c058521ea..0a81478269 100644 --- a/packages/mobile/src/system/__stories__/Spectrum.stories.tsx +++ b/packages/mobile/src/system/__stories__/Spectrum.stories.tsx @@ -34,8 +34,8 @@ const SpectrumScreen = () => { const background = `rgb(${theme.spectrum[paletteValue]})`; const foreground = getAccessibleColor({ background }); return ( - - + + {paletteValue} diff --git a/packages/mobile/src/system/__tests__/StatusBar.test.tsx b/packages/mobile/src/system/__tests__/StatusBar.test.tsx index e37a34d835..2ea44071ac 100644 --- a/packages/mobile/src/system/__tests__/StatusBar.test.tsx +++ b/packages/mobile/src/system/__tests__/StatusBar.test.tsx @@ -1,20 +1,10 @@ -import { StatusBar as RNStatusBar } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; import { DefaultThemeProvider } from '../../utils/testHelpers'; -import { useStatusBarStyle, useStatusBarUpdater } from '../StatusBar'; +import { useStatusBarStyle } from '../StatusBar'; import { ThemeProvider } from '../ThemeProvider'; -jest.mock('react-native/Libraries/Components/StatusBar/StatusBar', () => ({ - ...jest.requireActual>( - 'react-native/Libraries/Components/StatusBar/StatusBar', - ), - setBarStyle: jest.fn(), - setBackgroundColor: jest.fn(), - setTranslucent: jest.fn(), -})); - const MockDarkMode: React.FC> = ({ children }) => ( {children} @@ -58,37 +48,3 @@ describe('useStatusBarStyle', () => { expect(result.current).toBe('light-content'); }); }); - -describe('useStatusBarUpdater', () => { - it('correctly updates React Native StatusBar bar style', () => { - const { result } = renderHook(() => useStatusBarUpdater(), { - wrapper: DefaultThemeProvider, - }); - result.current(); - expect(RNStatusBar.setBarStyle).toHaveBeenCalledWith('dark-content', true); - }); - - it('does not call setBackgroundColor or setTranslucent on iOS', () => { - const { result } = renderHook(() => useStatusBarUpdater(), { - wrapper: DefaultThemeProvider, - }); - result.current(); - expect(RNStatusBar.setBarStyle).toHaveBeenCalledWith('dark-content', true); - expect(RNStatusBar.setBackgroundColor).not.toHaveBeenCalled(); - expect(RNStatusBar.setTranslucent).not.toHaveBeenCalled(); - }); - - it('does call setBackgroundColor or setTranslucent on Android', () => { - jest.mock('react-native/Libraries/Utilities/Platform', () => ({ - ...jest.requireActual>('react-native/Libraries/Utilities/Platform'), - OS: 'android', - })); - const { result } = renderHook(() => useStatusBarUpdater(), { - wrapper: DefaultThemeProvider, - }); - result.current(); - expect(RNStatusBar.setBarStyle).toHaveBeenCalledWith('dark-content', true); - expect(RNStatusBar.setBackgroundColor).toHaveBeenCalled(); - expect(RNStatusBar.setTranslucent).toHaveBeenCalled(); - }); -}); diff --git a/packages/mobile/src/system/__tests__/useAndroidNavigationBarUpdater.test.tsx b/packages/mobile/src/system/__tests__/useAndroidNavigationBarUpdater.test.tsx deleted file mode 100644 index 9136f723ae..0000000000 --- a/packages/mobile/src/system/__tests__/useAndroidNavigationBarUpdater.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import changeNavigationBarColor from 'react-native-navigation-bar-color'; -import { renderHook } from '@testing-library/react-hooks'; - -import { defaultTheme } from '../../themes/defaultTheme'; -import { useAndroidNavigationBarUpdater } from '../AndroidNavigationBar'; -import { ThemeProvider } from '../ThemeProvider'; - -const LightModeProvider = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -jest.useFakeTimers(); -jest.mock('react-native-navigation-bar-color'); -const mockPlatform = (OS: 'ios' | 'android', Version?: number) => { - jest.runAllTimers(); - jest.resetModules(); - jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ OS, Version })); -}; - -describe('useAndroidNavigationBarUpdater', () => { - it('does not fire for iOS', () => { - mockPlatform('ios'); - const { result } = renderHook(() => useAndroidNavigationBarUpdater(), { - wrapper: LightModeProvider, - }); - result.current(); - expect(changeNavigationBarColor).not.toHaveBeenCalled(); - }); - - it('correctly fires for android version', () => { - mockPlatform('android', 26); - - const { result } = renderHook(() => useAndroidNavigationBarUpdater(), { - wrapper: LightModeProvider, - }); - result.current(); - expect(changeNavigationBarColor).toHaveBeenCalled(); - }); -}); diff --git a/packages/mobile/src/tabs/TabLabel.tsx b/packages/mobile/src/tabs/TabLabel.tsx index 73475aa465..7cd566e9bb 100644 --- a/packages/mobile/src/tabs/TabLabel.tsx +++ b/packages/mobile/src/tabs/TabLabel.tsx @@ -78,12 +78,12 @@ export const TabLabel = memo( {shouldMeasureElement ? ( - + {/* This element is used to ensure the element width doesn't change when we change font-weight */} - + ) : ( - + )} diff --git a/packages/mobile/src/tabs/Tabs.tsx b/packages/mobile/src/tabs/Tabs.tsx index ebfa6d87eb..b77b0cdbc3 100644 --- a/packages/mobile/src/tabs/Tabs.tsx +++ b/packages/mobile/src/tabs/Tabs.tsx @@ -31,7 +31,9 @@ type TabContainerProps = { const TabContainer = ({ id, registerRef, ...props }: TabContainerProps) => { const refCallback = useCallback( - (ref: View | null) => ref && registerRef(id, ref), + (ref: View | null) => { + if (ref) registerRef(id, ref); + }, [id, registerRef], ); return ; @@ -204,6 +206,7 @@ export const TabsActiveIndicator = ({ if (previousActiveTabRect.current !== activeTabRect) { previousActiveTabRect.current = activeTabRect; + // TODO: writing to shared value during render causes a reanimated warning which we have to suppress in jest setup animatedTabRect.value = isFirstRenderWithWidth ? newActiveTabRect : withSpring(newActiveTabRect, tabsSpringConfig); diff --git a/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx b/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx index 2ccaadadb4..16783f0765 100644 --- a/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx +++ b/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { Pressable, ScrollView } from 'react-native'; import { interpolateColor, @@ -240,7 +240,6 @@ const SegmentedTabsScreen = () => ( padding={3} tabs={basicSegments} title="With Padding" - width="fit-content" /> { }} tabs={iconSegments} title="Icon Labels" - width="fit-content" /> ); }; diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx index 1a1505f5b3..2813f22f11 100644 --- a/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx +++ b/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx @@ -61,11 +61,6 @@ describe('SegmentedTab', () => { expect(screen.getByText('Buy')).toBeTruthy(); expect(screen.getByText('Buy')).toHaveAnimatedStyle({ color: `rgb(${defaultTheme.lightSpectrum.gray100})`, - fontFamily: 'Inter_600SemiBold', - fontSize: 16, - fontWeight: '600', - lineHeight: 24, - textAlign: 'left', }); }); @@ -80,11 +75,6 @@ describe('SegmentedTab', () => { jest.advanceTimersByTime(300); expect(screen.getByTestId(`${TEST_ID}-label`)).toHaveAnimatedStyle({ color: `rgb(${defaultTheme.lightSpectrum.gray0})`, - fontFamily: 'Inter_600SemiBold', - fontSize: 16, - fontWeight: '600', - lineHeight: 24, - textAlign: 'left', }); }); diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx index e53dfd049e..fe02cafb01 100644 --- a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx +++ b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx @@ -42,6 +42,9 @@ const exampleProps: SegmentedTabsProps = { tabs, activeTab: tabs[0], onChange: jest.fn(), + // Reanimated's Jest matcher can throw when a style array contains `undefined`. + // Providing an explicit indicator style keeps the test environment stable. + styles: { activeIndicator: {} }, }; const mockApi = { @@ -62,6 +65,7 @@ describe('SegmentedTabs', () => { jest.runOnlyPendingTimers(); jest.useRealTimers(); }); + it('passes a11y', () => { render( @@ -91,7 +95,6 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 0 }, { translateY: 0 }], }); }); @@ -131,7 +134,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 68 }, { translateY: 0 }], }); }); @@ -210,7 +212,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 20 }, { translateY: 0 }], }); }); @@ -244,7 +245,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 0 }, { translateY: 8 }], }); }); @@ -278,7 +278,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 20 }, { translateY: 8 }], }); }); diff --git a/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx b/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx index feba9fdfee..8c07becfc6 100644 --- a/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx +++ b/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx @@ -57,7 +57,7 @@ describe('TabIndicator', () => { it('renders with ref', () => { const TEST_ID = 'tabIndicator'; - const ref = { current: undefined } as unknown as React.RefObject; + const ref = { current: undefined } as unknown as React.RefObject; render( diff --git a/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts b/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts index d19f5831ce..8dca5051d5 100644 --- a/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts +++ b/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts @@ -1,6 +1,6 @@ import { act } from 'react'; import { Animated } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useDotAnimation } from '../useDotAnimation'; diff --git a/packages/mobile/src/tabs/hooks/useDotAnimation.ts b/packages/mobile/src/tabs/hooks/useDotAnimation.ts index 4036efaf20..7210e9cf59 100644 --- a/packages/mobile/src/tabs/hooks/useDotAnimation.ts +++ b/packages/mobile/src/tabs/hooks/useDotAnimation.ts @@ -6,10 +6,28 @@ import { dotHidden, dotVisible, } from '@coinbase/cds-common/animation/dot'; -import { getDotSize } from '@coinbase/cds-common/tokens/dot'; import { convertMotionConfig } from '../../animation/convertMotionConfig'; +/** + * Fixed widths to use to achieve an animated in/out effect on the Dot component + */ +const dotSizeTokens = { s: 28, m: 36, l: 48 } as const; +/** + * Returns the appropriate dot container width based on the notification count. + * Width increases to accommodate more digits (1-digit, 2-digit, 3+ digits). + * + * @param count - The notification count to determine width for + * @returns The pixel width for the dot container + */ +const getDotSize = (count?: number) => { + if (!count || count < 10) return dotSizeTokens.s; + if (count >= 10 && count < 100) return dotSizeTokens.m; + if (count >= 100) return dotSizeTokens.l; + + return dotSizeTokens.s; +}; + // opacity animation const opacityInConfig = convertMotionConfig({ ...animateDotOpacityConfig, @@ -34,6 +52,36 @@ const scaleOutConfig = convertMotionConfig({ fromValue: getDotSize(), }); +/** + * Hook that provides animated values and animation functions for dot badge transitions. + * + * Used to animate the appearance/disappearance of notification dot badges in tab labels. + * Runs parallel opacity (fade) and width (scale) animations for smooth enter/exit effects. + * + * @returns Object containing: + * - `opacity` - Animated.Value controlling the dot's opacity (0 = hidden, 1 = visible) + * - `width` - Animated.Value controlling the dot container's width (0 = collapsed, getDotSize(count) = expanded) + * - `animateIn` - Triggers the enter animation when a dot badge should appear + * - `animateOut` - Triggers the exit animation when a dot badge should disappear + * + * @example + * ```tsx + * const { opacity, width, animateIn, animateOut } = useDotAnimation(); + * + * useEffect(() => { + * if (count > 0) animateIn(count); + * else animateOut(count); + * }, [count]); + * + * return ( + * + * + * + * + * + * ); + * ``` + */ export const useDotAnimation = () => { const opacity = useRef(new Animated.Value(dotHidden)).current; const width = useRef(new Animated.Value(dotHidden)).current; diff --git a/packages/mobile/src/tag/Tag.tsx b/packages/mobile/src/tag/Tag.tsx index ce625bd35f..efcad6c784 100644 --- a/packages/mobile/src/tag/Tag.tsx +++ b/packages/mobile/src/tag/Tag.tsx @@ -100,12 +100,12 @@ export const Tag = memo( alignItems={alignItems} background="bg" borderRadius={tagBorderRadiusMap[intent]} - dangerouslySetBackground={backgroundColor} flexDirection={flexDirection} gap={gap} justifyContent={justifyContent} paddingX={tagHorizontalSpacing[intent]} paddingY={paddingY} + style={{ backgroundColor }} testID={testID} {...props} > @@ -116,9 +116,9 @@ export const Tag = memo( ) : null} {children} diff --git a/packages/mobile/src/tour/DefaultTourMask.tsx b/packages/mobile/src/tour/DefaultTourMask.tsx index 4e21452439..e343c07759 100644 --- a/packages/mobile/src/tour/DefaultTourMask.tsx +++ b/packages/mobile/src/tour/DefaultTourMask.tsx @@ -1,7 +1,9 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; import { Defs, Mask, Rect as NativeRect, Svg } from 'react-native-svg'; import { defaultRect, type Rect } from '@coinbase/cds-common/types/Rect'; +import { useDimensions } from '../hooks/useDimensions'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout'; @@ -11,6 +13,7 @@ export const DefaultTourMask = memo( ({ activeTourStepTarget, padding, borderRadius = 12 }: TourMaskComponentProps) => { const [rect, setRect] = useState(defaultRect); const theme = useTheme(); + const { statusBarHeight } = useDimensions(); const overlayFillRgba = theme.color.bgOverlay; const defaultPadding = theme.space[2]; @@ -27,10 +30,19 @@ export const DefaultTourMask = memo( ); useEffect(() => { - activeTourStepTarget?.measureInWindow((x, y, width, height) => - setRect({ x, y, width, height }), - ); - }, [activeTourStepTarget]); + activeTourStepTarget?.measureInWindow((x, y, width, height) => { + // On Android, measureInWindow returns coordinates relative to the app's visible area. + // The Modal's coordinate system starts from the screen top (y=0 at very top of display). + // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar, + // and measureInWindow returns y relative to below the status bar. We need to ADD + // statusBarHeight to convert to screen coordinates for the Modal. + // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from + // screen top, but the Modal still starts from screen top, so no adjustment is needed. + const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight; + + setRect({ x, y: adjustedY, width, height }); + }); + }, [activeTourStepTarget, statusBarHeight]); return ( diff --git a/packages/mobile/src/tour/Tour.tsx b/packages/mobile/src/tour/Tour.tsx index 7242ce1ea6..4ad1754a1f 100644 --- a/packages/mobile/src/tour/Tour.tsx +++ b/packages/mobile/src/tour/Tour.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { Modal, View } from 'react-native'; +import { Modal, Platform, View } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; import { OverlayContentContext, @@ -27,6 +27,7 @@ import { } from '@floating-ui/react-native'; import { animated, config as springConfig, useSpring } from '@react-spring/native'; +import { useDimensions } from '../hooks/useDimensions'; import { useTheme } from '../hooks/useTheme'; import { DefaultTourMask } from './DefaultTourMask'; @@ -116,6 +117,7 @@ const TourComponent = ({ testID, }: TourProps) => { const theme = useTheme(); + const { statusBarHeight } = useDimensions(); const defaultTourStepOffset = theme.space[3]; const defaultTourStepShiftPadding = theme.space[4]; @@ -155,7 +157,7 @@ const TourComponent = ({ [animationApi, onChange], ); - const api = useTour({ steps, activeTourStep, onChange: handleChange }); + const api = useTour({ steps, activeTourStep, onChange: handleChange }); const { activeTourStepTarget, setActiveTourStepTarget } = api; // Component Lifecycle & Side Effects @@ -175,9 +177,18 @@ const TourComponent = ({ const handleActiveTourStepTargetChange = useCallback( (target: View | null) => { target?.measureInWindow((x, y, width, height) => { + // On Android, measureInWindow returns coordinates relative to the app's visible area. + // The Modal's coordinate system starts from the screen top (y=0 at very top of display). + // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar, + // and measureInWindow returns y relative to below the status bar. We need to ADD + // statusBarHeight to convert to screen coordinates for the Modal. + // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from + // screen top, but the Modal still starts from screen top, so no adjustment is needed. + const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight; + refs.setReference({ measure: (callback: (x: number, y: number, width: number, height: number) => void) => { - callback(x, y, width, height); + callback(x, adjustedY, width, height); void animationApi.start({ to: { opacity: 1 }, config: springConfig.slow }); }, }); @@ -185,7 +196,7 @@ const TourComponent = ({ setActiveTourStepTarget(target); }, - [animationApi, refs, setActiveTourStepTarget], + [animationApi, refs, setActiveTourStepTarget, statusBarHeight], ); return ( @@ -209,7 +220,7 @@ const TourComponent = ({ {!(activeTourStep.hideOverlay ?? hideOverlay) && !!activeTourStepTarget && ( diff --git a/packages/mobile/src/tour/TourStep.tsx b/packages/mobile/src/tour/TourStep.tsx index af91763f0c..6aa4165d84 100644 --- a/packages/mobile/src/tour/TourStep.tsx +++ b/packages/mobile/src/tour/TourStep.tsx @@ -16,7 +16,11 @@ type TourStepProps = { export const TourStep = ({ id, children }: TourStepProps) => { const { activeTourStep, setActiveTourStepTarget } = useTourContext(); const refCallback = useCallback( - (ref: View) => activeTourStep?.id === id && ref && setActiveTourStepTarget(ref), + (ref: View | null) => { + if (activeTourStep?.id === id && ref) { + setActiveTourStepTarget(ref); + } + }, [activeTourStep, id, setActiveTourStepTarget], ); return ( diff --git a/packages/mobile/src/tour/__stories__/Tour.stories.tsx b/packages/mobile/src/tour/__stories__/Tour.stories.tsx index 2b413a93d7..df81e55583 100644 --- a/packages/mobile/src/tour/__stories__/Tour.stories.tsx +++ b/packages/mobile/src/tour/__stories__/Tour.stories.tsx @@ -21,9 +21,9 @@ const TourExamples = ({ step4Ref, ids, }: { - step2Ref: React.RefObject; - step3Ref: React.RefObject; - step4Ref: React.RefObject; + step2Ref: React.RefObject; + step3Ref: React.RefObject; + step4Ref: React.RefObject; ids: TourStepId[]; }) => { const { startTour } = useTourContext(); @@ -85,8 +85,8 @@ const StepOne = () => { }; const scrollIntoView = async ( - scrollViewRef: React.RefObject, - elementRef: React.RefObject, + scrollViewRef: React.RefObject, + elementRef: React.RefObject, ) => { const scrollView = scrollViewRef.current; if (!scrollView) return; diff --git a/packages/mobile/src/tour/__tests__/Tour.test.tsx b/packages/mobile/src/tour/__tests__/Tour.test.tsx index 1d1ad49a01..24421cc9a5 100644 --- a/packages/mobile/src/tour/__tests__/Tour.test.tsx +++ b/packages/mobile/src/tour/__tests__/Tour.test.tsx @@ -3,9 +3,15 @@ import { Button, Text } from 'react-native'; import { useTourContext } from '@coinbase/cds-common/tour/TourContext'; import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { useDimensions } from '../../hooks/useDimensions'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Tour, type TourProps } from '../Tour'; +jest.mock('../../hooks/useDimensions'); +const mockUseDimensions = (mocks: ReturnType) => { + (useDimensions as jest.Mock).mockReturnValue(mocks); +}; + const StepOne = () => { const { goNextTourStep } = useTourContext(); @@ -51,6 +57,14 @@ const exampleProps: TourProps = { }; describe('Tour', () => { + beforeEach(() => { + mockUseDimensions({ + screenHeight: 844, + screenWidth: 390, + statusBarHeight: 47, + }); + }); + it('passes accessibility', async () => { render( diff --git a/packages/mobile/src/typography/Text.tsx b/packages/mobile/src/typography/Text.tsx index 0a58d183b2..59d92595a4 100644 --- a/packages/mobile/src/typography/Text.tsx +++ b/packages/mobile/src/typography/Text.tsx @@ -60,9 +60,15 @@ export type TextBaseProps = StyleProps & { * @default false */ noWrap?: boolean; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style` or the `color` style prop to set custom text colors. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: TextStyle['color']; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style` or the `background` style prop to set custom text background colors. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetBackground?: TextStyle['backgroundColor']; /** * @deprecated Do not use this prop, it is a migration escape hatch. This will be removed in a future major release. @@ -90,6 +96,8 @@ const styles = StyleSheet.create({ }, }); +const HEADER_FONTS = new Set(['display1', 'display2', 'display3', 'title1', 'title2']); + export const Text = memo( forwardRef( ( @@ -176,6 +184,7 @@ export const Text = memo( flexGrow, opacity, renderEmptyNode = true, + accessibilityRole = HEADER_FONTS.has(font) ? 'header' : undefined, ...props }, ref, @@ -372,9 +381,11 @@ export const Text = memo( return ( } testID={testID} {...props} > diff --git a/packages/mobile/src/typography/TextBody.tsx b/packages/mobile/src/typography/TextBody.tsx index 6a7b58da4e..88784b044f 100644 --- a/packages/mobile/src/typography/TextBody.tsx +++ b/packages/mobile/src/typography/TextBody.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="body"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextBodyBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="body"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextBodyProps = TextProps; +/** + * @deprecated Use `Text` with `font="body"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextBody = memo( forwardRef(({ font = 'body', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextCaption.tsx b/packages/mobile/src/typography/TextCaption.tsx index f41aced2f3..45ecf00653 100644 --- a/packages/mobile/src/typography/TextCaption.tsx +++ b/packages/mobile/src/typography/TextCaption.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="caption"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextCaptionBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="caption"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextCaptionProps = TextProps; +/** + * @deprecated Use `Text` with `font="caption"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextCaption = memo( forwardRef(({ font = 'caption', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextDisplay1.tsx b/packages/mobile/src/typography/TextDisplay1.tsx index 6fdaedc810..2e7faca203 100644 --- a/packages/mobile/src/typography/TextDisplay1.tsx +++ b/packages/mobile/src/typography/TextDisplay1.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="display1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay1BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="display1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay1Props = TextProps; +/** + * @deprecated Use `Text` with `font="display1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextDisplay1 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'display1', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextDisplay2.tsx b/packages/mobile/src/typography/TextDisplay2.tsx index d0f06dc745..fa3cc37e56 100644 --- a/packages/mobile/src/typography/TextDisplay2.tsx +++ b/packages/mobile/src/typography/TextDisplay2.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="display2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay2BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="display2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay2Props = TextProps; +/** + * @deprecated Use `Text` with `font="display2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextDisplay2 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'display2', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextDisplay3.tsx b/packages/mobile/src/typography/TextDisplay3.tsx index 4ed5184b6e..76a353e08f 100644 --- a/packages/mobile/src/typography/TextDisplay3.tsx +++ b/packages/mobile/src/typography/TextDisplay3.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="display3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay3BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="display3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay3Props = TextProps; +/** + * @deprecated Use `Text` with `font="display3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextDisplay3 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'display3', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextHeadline.tsx b/packages/mobile/src/typography/TextHeadline.tsx index cb7622b3e9..d6adaf333f 100644 --- a/packages/mobile/src/typography/TextHeadline.tsx +++ b/packages/mobile/src/typography/TextHeadline.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="headline"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextHeadlineBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="headline"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextHeadlineProps = TextProps; +/** + * @deprecated Use `Text` with `font="headline"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextHeadline = memo( forwardRef(({ font = 'headline', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextInherited.tsx b/packages/mobile/src/typography/TextInherited.tsx index dc2994c3e4..4caf1a5457 100644 --- a/packages/mobile/src/typography/TextInherited.tsx +++ b/packages/mobile/src/typography/TextInherited.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="inherit"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextInheritedBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="inherit"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextInheritedProps = TextProps; +/** + * @deprecated Use `Text` with `font="inherit"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextInherited = memo( forwardRef(({ font = 'inherit', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextLabel1.tsx b/packages/mobile/src/typography/TextLabel1.tsx index 3679c7290e..1109425446 100644 --- a/packages/mobile/src/typography/TextLabel1.tsx +++ b/packages/mobile/src/typography/TextLabel1.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="label1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel1BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="label1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel1Props = TextProps; +/** + * @deprecated Use `Text` with `font="label1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextLabel1 = memo( forwardRef(({ font = 'label1', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextLabel2.tsx b/packages/mobile/src/typography/TextLabel2.tsx index 007095fc11..8ef01ac486 100644 --- a/packages/mobile/src/typography/TextLabel2.tsx +++ b/packages/mobile/src/typography/TextLabel2.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="label2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel2BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="label2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel2Props = TextProps; +/** + * @deprecated Use `Text` with `font="label2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextLabel2 = memo( forwardRef(({ font = 'label2', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextLegal.tsx b/packages/mobile/src/typography/TextLegal.tsx index 79bf152f41..8abd316515 100644 --- a/packages/mobile/src/typography/TextLegal.tsx +++ b/packages/mobile/src/typography/TextLegal.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="legal"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLegalBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="legal"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLegalProps = TextProps; +/** + * @deprecated Use `Text` with `font="legal"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextLegal = memo( forwardRef(({ font = 'legal', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle1.tsx b/packages/mobile/src/typography/TextTitle1.tsx index e674a9ae90..e47a91ead1 100644 --- a/packages/mobile/src/typography/TextTitle1.tsx +++ b/packages/mobile/src/typography/TextTitle1.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle1BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle1Props = TextProps; +/** + * @deprecated Use `Text` with `font="title1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle1 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'title1', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle2.tsx b/packages/mobile/src/typography/TextTitle2.tsx index eed58710e0..b57b96b7ce 100644 --- a/packages/mobile/src/typography/TextTitle2.tsx +++ b/packages/mobile/src/typography/TextTitle2.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle2BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle2Props = TextProps; +/** + * @deprecated Use `Text` with `font="title2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle2 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'title2', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle3.tsx b/packages/mobile/src/typography/TextTitle3.tsx index e179427bfc..9348ee8639 100644 --- a/packages/mobile/src/typography/TextTitle3.tsx +++ b/packages/mobile/src/typography/TextTitle3.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle3BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle3Props = TextProps; +/** + * @deprecated Use `Text` with `font="title3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle3 = memo( forwardRef(({ font = 'title3', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle4.tsx b/packages/mobile/src/typography/TextTitle4.tsx index a7741f0d25..53dfbe80d9 100644 --- a/packages/mobile/src/typography/TextTitle4.tsx +++ b/packages/mobile/src/typography/TextTitle4.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title4"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle4BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title4"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle4Props = TextProps; +/** + * @deprecated Use `Text` with `font="title4"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle4 = memo( forwardRef(({ font = 'title4', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/__stories__/Link.stories.tsx b/packages/mobile/src/typography/__stories__/Link.stories.tsx index 7e70074e9e..35d7fcc099 100644 --- a/packages/mobile/src/typography/__stories__/Link.stories.tsx +++ b/packages/mobile/src/typography/__stories__/Link.stories.tsx @@ -5,7 +5,6 @@ import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { useWebBrowserOpener } from '../../hooks/useWebBrowserOpener'; import { Link } from '../Link'; import { Text } from '../Text'; -import { TextLegal } from '../TextLegal'; const typographies = [ 'display1', @@ -191,11 +190,12 @@ const LinkScreen = function LinkScreen() { Nested link in text {/** refer to this blog about this best practice: https://www.yeti.co/blog/accessibility-first-in-react-native */} - { try { const screenReaderEnabled = await AccessibilityInfo.isScreenReaderEnabled(); @@ -211,7 +211,7 @@ const LinkScreen = function LinkScreen() { Consider a case where you have a block of text with an inline link.{' '} Like so. You may want to write your code like this. - + Multiple nested link in text diff --git a/packages/mobile/src/typography/__stories__/Text.stories.tsx b/packages/mobile/src/typography/__stories__/Text.stories.tsx index 82fa5831d2..c888a98ee0 100644 --- a/packages/mobile/src/typography/__stories__/Text.stories.tsx +++ b/packages/mobile/src/typography/__stories__/Text.stories.tsx @@ -1,52 +1,66 @@ import React from 'react'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; -import { TextBody } from '../TextBody'; -import { TextCaption } from '../TextCaption'; -import { TextDisplay1 } from '../TextDisplay1'; -import { TextDisplay2 } from '../TextDisplay2'; -import { TextDisplay3 } from '../TextDisplay3'; -import { TextHeadline } from '../TextHeadline'; -import { TextLabel1 } from '../TextLabel1'; -import { TextLabel2 } from '../TextLabel2'; -import { TextLegal } from '../TextLegal'; -import { TextTitle1 } from '../TextTitle1'; -import { TextTitle2 } from '../TextTitle2'; -import { TextTitle3 } from '../TextTitle3'; -import { TextTitle4 } from '../TextTitle4'; +import { Text } from '../Text'; const TextScreen = () => { return ( - Display1 - Display2 - Display3 - Title1 - Title2 - Title3 - Title4 - Label1 - Label2 - Headline - Body - Caption - Legal + Display1 + Display2 + Display3 + Title1 + Title2 + Title3 + Title4 + Label1 + Label2 + Headline + Body + Caption + Legal - Display1 - Display2 - Display3 - Title1 - Title2 - Title3 - Title4 - Label1 - Label2 - Headline - Body - Caption - Legal + + Display1 + + + Display2 + + + Display3 + + + Title1 + + + Title2 + + + Title3 + + + Title4 + + + Label1 + + + Label2 + + + Headline + + + Body + + + Caption + + + Legal + ); diff --git a/packages/mobile/src/typography/__tests__/Link.test.tsx b/packages/mobile/src/typography/__tests__/Link.test.tsx index 28473b7d05..39b2f5affe 100644 --- a/packages/mobile/src/typography/__tests__/Link.test.tsx +++ b/packages/mobile/src/typography/__tests__/Link.test.tsx @@ -1,9 +1,12 @@ -import TestRenderer from 'react-test-renderer'; import { fireEvent, render, screen } from '@testing-library/react-native'; +import { useWebBrowserOpener } from '../../hooks/useWebBrowserOpener'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Link, type LinkProps } from '../Link'; +jest.mock('../../hooks/useWebBrowserOpener'); +const mockUseWebBrowserOpener = useWebBrowserOpener as jest.Mock; + const TEST_ID = 'link'; const URL = 'www.coinbase.com'; const variants = [ @@ -24,6 +27,10 @@ const variants = [ ] as LinkProps['font'][]; describe('Link', () => { + beforeEach(() => { + mockUseWebBrowserOpener.mockReturnValue(jest.fn()); + }); + it('passes a11y', () => { render( @@ -89,40 +96,58 @@ describe('Link', () => { expect(spy).toHaveBeenCalled(); }); - it('to prop works as expected', async () => { - const linkRenderer = TestRenderer.create( + it('opens URL when pressed', () => { + const mockOpenUrl = jest.fn(); + mockUseWebBrowserOpener.mockReturnValue(mockOpenUrl); + + render( Child , ); - const linkInstance = await linkRenderer.root.findByProps({ testID: TEST_ID }); - expect(linkInstance.props.to).toEqual(URL); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(mockOpenUrl).toHaveBeenCalledWith(URL, expect.any(Object)); }); - it('can set forceOpenOutsideApp to true', async () => { - const linkRenderer = TestRenderer.create( + it('passes forceOpenOutsideApp option to browser opener', () => { + const mockOpenUrl = jest.fn(); + mockUseWebBrowserOpener.mockReturnValue(mockOpenUrl); + + render( Child , ); - const link = await linkRenderer.root.findByProps({ testID: TEST_ID }); - expect(link.props.forceOpenOutsideApp).toBe(true); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(mockOpenUrl).toHaveBeenCalledWith( + URL, + expect.objectContaining({ forceOpenOutsideApp: true }), + ); }); - it('can set readerMode to true', async () => { - const linkRenderer = TestRenderer.create( + it('passes readerMode option to browser opener', () => { + const mockOpenUrl = jest.fn(); + mockUseWebBrowserOpener.mockReturnValue(mockOpenUrl); + + render( Child , ); - const link = await linkRenderer.root.findByProps({ testID: TEST_ID }); - expect(link.props.readerMode).toBe(true); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(mockOpenUrl).toHaveBeenCalledWith(URL, expect.objectContaining({ readerMode: true })); }); it('removes text style when inherited', () => { diff --git a/packages/mobile/src/utils/testHelpers.tsx b/packages/mobile/src/utils/testHelpers.tsx index 26171df19f..3a9308400d 100644 --- a/packages/mobile/src/utils/testHelpers.tsx +++ b/packages/mobile/src/utils/testHelpers.tsx @@ -29,3 +29,33 @@ export const DefaultThemeProvider = ({ ); }; + +export function flattenStyle(style: unknown): Array> { + if (!style) return []; + if (Array.isArray(style)) return style.flatMap(flattenStyle); + if (typeof style === 'object') return [style as Record]; + return []; +} + +export function treeHasStyleProp( + tree: unknown, + predicate: (style: Record) => boolean, +): boolean { + if (!tree) return false; + + if (Array.isArray(tree)) { + return tree.some((node) => treeHasStyleProp(node, predicate)); + } + + if (typeof tree !== 'object') return false; + + const node = tree as { + props?: { style?: unknown }; + children?: unknown[]; + }; + + const styles = flattenStyle(node.props?.style); + if (styles.some(predicate)) return true; + + return (node.children ?? []).some((child) => treeHasStyleProp(child, predicate)); +} diff --git a/packages/mobile/src/visualizations/ProgressBar.tsx b/packages/mobile/src/visualizations/ProgressBar.tsx index b0890f6747..870809cc0a 100644 --- a/packages/mobile/src/visualizations/ProgressBar.tsx +++ b/packages/mobile/src/visualizations/ProgressBar.tsx @@ -159,7 +159,10 @@ export const ProgressBar = memo( flexShrink={0} height="100%" justifyContent="center" - style={progressStyle} + style={[ + { backgroundColor: !disabled ? theme.color[color] : theme.color.bgLineHeavy }, + progressStyle, + ]} testID="cds-progress-bar" width="100%" /> diff --git a/packages/mobile/src/visualizations/VisualizationContainer.tsx b/packages/mobile/src/visualizations/VisualizationContainer.tsx index aeace396c0..11cc01ead4 100644 --- a/packages/mobile/src/visualizations/VisualizationContainer.tsx +++ b/packages/mobile/src/visualizations/VisualizationContainer.tsx @@ -1,5 +1,5 @@ import React, { memo } from 'react'; -import type { DimensionValue } from '@coinbase/cds-common/types'; +import type { DimensionValue } from 'react-native'; import { useVisualizationDimensions } from '@coinbase/cds-common/visualizations/useVisualizationDimensions'; import { useLayout } from '../hooks/useLayout'; @@ -27,7 +27,7 @@ export const VisualizationContainer: React.FC = mem ({ width, height, children }) => { const [{ width: layoutWidth, height: layoutHeight }, onLayout] = useLayout(); - const dimensions = useVisualizationDimensions({ + const dimensions = useVisualizationDimensions({ userDefinedWidth: width, userDefinedHeight: height, calculatedWidth: layoutWidth, diff --git a/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx b/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx index 5253f33f71..6c3be1bca6 100644 --- a/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx +++ b/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx @@ -1,5 +1,4 @@ import React, { act } from 'react'; -import type { ReactTestInstance } from 'react-test-renderer'; import type { UseCounterParams } from '@coinbase/cds-common/visualizations/useCounter'; import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -16,7 +15,7 @@ jest.mock('@coinbase/cds-common/visualizations/useCounter', () => ({ useCounter: ({ endNum }: UseCounterParams) => endNum, })); -function fireTextEvent(floatLabel: ReactTestInstance) { +function fireTextEvent(floatLabel: Parameters[0]) { fireEvent(floatLabel, 'layout', { nativeEvent: { layout: { @@ -31,7 +30,7 @@ function fireTextEvent(floatLabel: ReactTestInstance) { }); } -function fireTextContainerEvent(floatLabelContainer: ReactTestInstance) { +function fireTextContainerEvent(floatLabelContainer: Parameters[0]) { fireEvent(floatLabelContainer, 'layout', { nativeEvent: { layout: { @@ -54,7 +53,7 @@ describe('ProgressBar test', () => { it('places bar label in correct position if it flows off the left container and passes a11y', async () => { render( - + @@ -78,7 +77,7 @@ describe('ProgressBar test', () => { it('places bar label in correct position in middle', () => { render( - + @@ -104,7 +103,7 @@ describe('ProgressBar test', () => { it('renders fixed labels in correct position', () => { render( - + { it('has correct bar width', () => { render( - + , @@ -146,7 +145,7 @@ describe('ProgressBar test', () => { it('has correct bar height', () => { render( - + , @@ -164,7 +163,7 @@ describe('ProgressBar test', () => { it('handles disabled state for just ProgressBar correctly & passes a11y', () => { render( - + , @@ -185,7 +184,7 @@ describe('ProgressBar test', () => { it('handles disabled state correctly for fixed labels', () => { render( - + { render( - + { render( - + { it('applies custom styles correctly', () => { render( - + { it('applies custom styles to ProgressBarWithFixedLabels', () => { render( - + { it('applies custom styles to ProgressBarWithFloatLabel', () => { render( - + { it('rounds accessibilityValue.now to the nearest integer', () => { render( - + , @@ -378,7 +377,7 @@ describe('ProgressBar test', () => { it('skips mount animation when disableAnimateOnMount is true for ProgressBar', () => { render( - + , @@ -397,7 +396,7 @@ describe('ProgressBar test', () => { it('starts at animation start position when disableAnimateOnMount is not set', () => { render( - + , @@ -416,7 +415,7 @@ describe('ProgressBar test', () => { it('skips mount animation when disableAnimateOnMount is true for ProgressBarWithFixedLabels', () => { render( - + { it('skips mount animation when disableAnimateOnMount is true for ProgressBarWithFloatLabel', () => { render( - + >; }; -const iconButtonHeight = interactableHeight.regular; - export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigatorPropsOptions) { const theme = useTheme(); const { top } = useSafeAreaInsets(); @@ -49,7 +46,11 @@ export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigator const showBackButton = isFocused && canGoBack && !isSearch; const showSearch = routeName === initialRouteName; - const iconButtonPlaceholder = ; + const iconButtonPlaceholder = ( + + + + ); const leftHeaderButton = showSearch ? ( @@ -102,7 +103,9 @@ export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigator value={searchFilter} /> ) : ( - {titleForScene} + + {titleForScene} + )} diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index a071d79cfa..c73c77c50d 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/src/routes.ts @@ -54,6 +54,11 @@ export const routes = [ require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') .default, }, + { + key: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, { key: 'AnimatedCaret', getComponent: () => diff --git a/packages/ui-mobile-visreg/package.json b/packages/ui-mobile-visreg/package.json index e0507343c6..5a5a13eb9e 100644 --- a/packages/ui-mobile-visreg/package.json +++ b/packages/ui-mobile-visreg/package.json @@ -51,7 +51,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@coinbase/cds-mobile": "workspace:^", "@coinbase/cds-mobile-visualization": "workspace:^", diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts index a071d79cfa..c73c77c50d 100644 --- a/packages/ui-mobile-visreg/src/routes.ts +++ b/packages/ui-mobile-visreg/src/routes.ts @@ -54,6 +54,11 @@ export const routes = [ require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') .default, }, + { + key: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, { key: 'AnimatedCaret', getComponent: () => diff --git a/packages/utils/package.json b/packages/utils/package.json index e118e45dea..28da6e5f50 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -35,7 +35,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json index f81cf7463e..540e131e9b 100644 --- a/packages/web-visualization/package.json +++ b/packages/web-visualization/package.json @@ -43,8 +43,8 @@ "@coinbase/cds-utils": "workspace:^", "@coinbase/cds-web": "workspace:^", "framer-motion": "^10.18.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^18.0.0 || ~19.1.2", + "react-dom": "^18.0.0 || ~19.1.2" }, "dependencies": { "d3-color": "^3.1.0", @@ -57,15 +57,18 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@coinbase/cds-common": "workspace:^", "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-utils": "workspace:^", "@coinbase/cds-web": "workspace:^", "@linaria/core": "^3.0.0-beta.22", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "framer-motion": "^10.18.0" + "@testing-library/react": "^16.3.2", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "framer-motion": "^10.18.0", + "react": "19.1.2", + "react-dom": "19.1.2" } } diff --git a/packages/web-visualization/src/chart/Path.tsx b/packages/web-visualization/src/chart/Path.tsx index b47bf113aa..186dc66095 100644 --- a/packages/web-visualization/src/chart/Path.tsx +++ b/packages/web-visualization/src/chart/Path.tsx @@ -106,7 +106,15 @@ const AnimatedPath = memo; + return ( + )} + /> + ); }, ); diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index 6898638742..3f699b07cc 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -199,12 +199,12 @@ export const AreaChart = memo( return ( {showXAxis && } {showYAxis && } diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx index 894b97e07f..b3f9fd4946 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -206,12 +206,12 @@ export const BarChart = memo( return ( {showXAxis && } {showYAxis && } diff --git a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx index 4179f0c35f..43694b2555 100644 --- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx @@ -112,7 +112,6 @@ export const BarStackGroup = memo( return orderedConfigs.map(({ categoryIndex, indexPos, thickness }) => ( ( valueScale={valueScaleComputed} xAxisId={xAxisId} yAxisId={yAxisId} + {...props} /> )); }, diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index 2258a755de..c3aaec4bc5 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -112,7 +112,6 @@ export const DefaultBar = memo( return ( ( enter: enterTransition, update: updateTransition, }} + {...props} /> ); }, diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index ab531b5e4c..6e9eeabdce 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -171,12 +171,12 @@ export const LineChart = memo( return ( {/* Render axes first for grid lines to appear behind everything else */} {showXAxis && } diff --git a/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx index 040ae41f6d..8aca4c2e59 100644 --- a/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx @@ -149,7 +149,7 @@ const DraggableReferenceLine = memo( }: { baselineAmount: number; startAmount: number; - chartRef: React.RefObject; + chartRef: React.RefObject; }) => { const theme = useTheme(); diff --git a/packages/web-visualization/src/chart/point/DefaultPointLabel.tsx b/packages/web-visualization/src/chart/point/DefaultPointLabel.tsx index e978f7524b..c93c3e4488 100644 --- a/packages/web-visualization/src/chart/point/DefaultPointLabel.tsx +++ b/packages/web-visualization/src/chart/point/DefaultPointLabel.tsx @@ -26,11 +26,11 @@ export const DefaultPointLabel = memo( return ( {children} diff --git a/packages/web-visualization/src/chart/point/Point.tsx b/packages/web-visualization/src/chart/point/Point.tsx index aa42a4d395..163fbea0fe 100644 --- a/packages/web-visualization/src/chart/point/Point.tsx +++ b/packages/web-visualization/src/chart/point/Point.tsx @@ -385,7 +385,14 @@ export const Point = memo( return ( | null; + svgRef: React.RefObject | null; }; /** diff --git a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts index 28716d698e..19d31b7ca4 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { defaultTransition, getTransition, usePathTransition } from '../transition'; diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx index 577e61b2e7..d3fdde3c7e 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx @@ -161,6 +161,7 @@ const SparklineInteractiveHeaderStable = memo( }, 500), [], ); + // eslint-disable-next-line react-hooks/use-memo const safelyUpdateSubHeadA11yRef = useCallback(debouncedUpdateMessage, [ debouncedUpdateMessage, ]); diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx index 1d5f1490ff..7e4d698fff 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx @@ -1,6 +1,6 @@ import React, { memo, useCallback } from 'react'; import { cx } from '@coinbase/cds-web'; -import { TextLabel2 } from '@coinbase/cds-web/typography/TextLabel2'; +import { Text } from '@coinbase/cds-web/typography/Text'; import { css } from '@linaria/core'; import { useSparklineInteractiveScrubContext } from './SparklineInteractiveScrubProvider'; @@ -26,11 +26,11 @@ export const SparklineInteractiveHoverDate = memo(() => { ); return ( - + {/* prevent the container vertical jump by stubbing out a date with no opacity */} {dateString} - + ); }); diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverPrice.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverPrice.tsx index bf216c63f8..68406da376 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverPrice.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverPrice.tsx @@ -1,6 +1,6 @@ import React, { memo, useCallback } from 'react'; import { cx } from '@coinbase/cds-web'; -import { TextLabel2 } from '@coinbase/cds-web/typography/TextLabel2'; +import { Text } from '@coinbase/cds-web/typography/Text'; import { css } from '@linaria/core'; import { useSparklineInteractiveScrubContext } from './SparklineInteractiveScrubProvider'; @@ -25,10 +25,10 @@ export const SparklineInteractiveHoverPrice = memo(() => { ); return ( - + {/* prevent the container vertical jump by stubbing out a price with no opacity */} - + ); }); diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx index ca021ec738..c544f4e785 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx @@ -4,7 +4,7 @@ import { fadeDuration } from '@coinbase/cds-common/tokens/sparkline'; import { useDateLookup } from '@coinbase/cds-common/visualizations/useDateLookup'; import { cubicBezier } from '@coinbase/cds-web/animation/convertMotionConfig'; import { HStack } from '@coinbase/cds-web/layout'; -import { TextLabel2 } from '@coinbase/cds-web/typography/TextLabel2'; +import { Text } from '@coinbase/cds-web/typography/Text'; import { css } from '@linaria/core'; import times from 'lodash/times'; @@ -51,9 +51,9 @@ const SparklineInteractiveMarkerDate: React.FunctionComponent< const fallback = -; return ( - + {dateStr || fallback} - + ); }); diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx index 383712cf2b..075f36d548 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx @@ -4,7 +4,7 @@ import { getAccessibleColor } from '@coinbase/cds-common/utils/getAccessibleColo import { useTheme } from '@coinbase/cds-web/hooks/useTheme'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system/Pressable'; -import { TextLabel1 } from '@coinbase/cds-web/typography/TextLabel1'; +import { Text } from '@coinbase/cds-web/typography/Text'; export type SparklineInteractivePeriodSelectorProps = { selectedPeriod: Period; @@ -44,16 +44,17 @@ function SparklineInteractivePeriodWithGeneric({ borderRadius={200} onClick={handleOnClick} > - {period.label} - + ); diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveScrubHandler.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveScrubHandler.tsx index 4f8ffe9237..fa52b4baf2 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveScrubHandler.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveScrubHandler.tsx @@ -146,6 +146,7 @@ const SparklineInteractiveScrubHandlerWithGeneric = ({ [setXPos], ); + // eslint-disable-next-line react-hooks/use-memo const safelyUpdatePosition = useCallback(debouncedUpdatePositionHandler, [ debouncedUpdatePositionHandler, ]); diff --git a/packages/web/babel.config.cjs b/packages/web/babel.config.cjs index d0d6a3df6e..716b0af62f 100644 --- a/packages/web/babel.config.cjs +++ b/packages/web/babel.config.cjs @@ -25,6 +25,7 @@ module.exports = { }, ], ], + // NOTE: To enable the React Compiler, install babel-plugin-react-compiler and react-compiler-runtime // plugins: [ // [ // 'babel-plugin-react-compiler', diff --git a/packages/web/package.json b/packages/web/package.json index e2727bd508..2c210217bb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -203,8 +203,8 @@ ], "peerDependencies": { "framer-motion": "^10.18.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^18.0.0 || ~19.1.2", + "react-dom": "^18.0.0 || ~19.1.2" }, "dependencies": { "@coinbase/cds-common": "workspace:^", @@ -222,27 +222,30 @@ "lodash": "^4.17.21", "lottie-web": "^5.13.0", "react-popper": "^2.2.4", - "react-use-measure": "^2" + "react-use-measure": "^2.1.7" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@coinbase/cds-web-utils": "workspace:^", "@linaria/core": "^3.0.0-beta.22", "@linaria/shaker": "^3.0.0-beta.22", "@storybook/jest": "^0.2.3", - "@storybook/react": "^6.5.17-alpha.0", + "@storybook/react": "^9.1.2", "@storybook/testing-library": "^0.2.2", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.0.4", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", "csstype": "^3.1.3", "framer-motion": "^10.18.0", "glob": "^10.3.10", + "react": "19.1.2", + "react-dom": "19.1.2", "storybook-addon-performance": "^0.16.1", "typescript": "~5.9.2", - "vite": "^7.1.2", "zx": "^8.1.9" } } diff --git a/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx b/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx index c1f8d51bb7..2f95cba1a3 100644 --- a/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx +++ b/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from 'react'; -import type { ComponentStoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../../buttons'; import { VStack } from '../../layout/VStack'; @@ -35,18 +35,18 @@ const MockAppScreen = ({ message, ...rest }: AccessibilityAnnouncerProps) => { ); }; -export default { +const meta = { title: 'Components/AccessibilityAnnouncer', component: MockAppScreen, args: { message: DEFAULT_MESSAGE }, -}; +} satisfies Meta; -export const Default: ComponentStoryObj = { - ...MockAppScreen, -}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; -export const Assertive: ComponentStoryObj = { - ...MockAppScreen, +export const Assertive: Story = { args: { politeness: 'assertive', message: diff --git a/packages/web/src/accordion/AccordionItem.tsx b/packages/web/src/accordion/AccordionItem.tsx index cb949c2ef6..5d84cd7c5a 100644 --- a/packages/web/src/accordion/AccordionItem.tsx +++ b/packages/web/src/accordion/AccordionItem.tsx @@ -9,8 +9,8 @@ import { AccordionPanel, type AccordionPanelBaseProps } from './AccordionPanel'; export type AccordionItemBaseProps = Omit & Pick & { - headerRef?: React.RefObject; - panelRef?: React.RefObject; + headerRef?: React.RefObject; + panelRef?: React.RefObject; style?: React.CSSProperties; }; diff --git a/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx b/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx index 317eabd087..6a0ed29932 100644 --- a/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx +++ b/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx @@ -96,9 +96,11 @@ export const DefaultComboboxControl = memo( }} placeholder={typeof placeholder === 'string' ? placeholder : undefined} style={{ + // unset default padding to let DefaultSelectControl handle layout/spacing paddingLeft: 0, paddingRight: 0, - height: hasValue ? 24 : compact ? 40 : 48, + paddingTop: 0, + paddingBottom: 0, minWidth: 0, flexGrow: 1, width: '100%', @@ -137,8 +139,6 @@ export const DefaultComboboxControl = memo( }, controlValueNode: { ...props.styles?.controlValueNode, - paddingTop: hasValue ? (compact ? 'var(--space-1)' : 'var(--space-1_5)') : 0, - paddingBottom: hasValue ? (compact ? 'var(--space-1)' : 'var(--space-1_5)') : 0, }, }} tabIndex={shouldShowSearchInput ? -1 : 0} diff --git a/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx index 7242dac7ed..4f1d43ba69 100644 --- a/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx +++ b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { type JSX, useRef } from 'react'; import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { ProgressBar, @@ -38,7 +38,7 @@ export const BasicExamples = (): JSX.Element => { thumbnail={exampleThumbnail} title="Progress Bar Card" titleAccessory={ - + ↗ 25.25% } @@ -106,7 +106,7 @@ export const Features = (): JSX.Element => { thumbnail={exampleThumbnail} title="High Progress" titleAccessory={ - + ↗ 25.25% } @@ -181,7 +181,7 @@ export const Interactive = (): JSX.Element => { thumbnail={exampleThumbnail} title="Progress Bar with Button" titleAccessory={ - + ↗ 8.5% } @@ -207,7 +207,7 @@ export const Interactive = (): JSX.Element => { thumbnail={exampleThumbnail} title="Progress Circle with Link" titleAccessory={ - + ↗ 8.5% } @@ -315,7 +315,7 @@ export const MultipleCards = (): JSX.Element => { thumbnail={exampleThumbnail} title="Card 2" titleAccessory={ - + ↗ 25.25% } diff --git a/packages/web/src/alpha/select/DefaultSelectControl.tsx b/packages/web/src/alpha/select/DefaultSelectControl.tsx index 7beafed34b..5ca50877c4 100644 --- a/packages/web/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/web/src/alpha/select/DefaultSelectControl.tsx @@ -7,6 +7,7 @@ import { HelperText } from '../../controls/HelperText'; import { InputLabel } from '../../controls/InputLabel'; import { InputStack } from '../../controls/InputStack'; import { cx } from '../../cx'; +import { useTheme } from '../../hooks/useTheme'; import { HStack } from '../../layout/HStack'; import { VStack } from '../../layout/VStack'; import { AnimatedCaret } from '../../motion/AnimatedCaret'; @@ -21,12 +22,6 @@ import { type SelectType, } from './Select'; -// The height is smaller for the inside label variant since the label takes -// up space above the input. -const LABEL_VARIANT_INSIDE_HEIGHT = 32; -const COMPACT_HEIGHT = 40; -const DEFAULT_HEIGHT = 56; - const noFocusOutlineCss = css` &:focus, &:focus-visible, @@ -103,7 +98,10 @@ const DefaultSelectControlComponent = memo( type ValueType = Type extends 'multi' ? SelectOptionValue | SelectOptionValue[] | null : SelectOptionValue | null; + const theme = useTheme(); const isMultiSelect = type === 'multi'; + // horizontal/inline label is used for compact selesct exepct for multi-selects + // multi-selects render their label outside of the control unless labelVariant is set to 'inside' const shouldShowCompactLabel = compact && label && !isMultiSelect; const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); // Map of options to their values @@ -240,13 +238,12 @@ const DefaultSelectControlComponent = memo( const labelNode = useMemo( () => - labelVariant === 'inside' ? ( + // labelVariant has no effect when compact is true + labelVariant === 'inside' && !compact ? ( setOpen((s) => !s)} tabIndex={-1}> {label} @@ -256,6 +253,8 @@ const DefaultSelectControlComponent = memo( {label} @@ -263,7 +262,15 @@ const DefaultSelectControlComponent = memo( ) : ( label ), - [labelVariant, classNames?.controlLabelNode, styles?.controlLabelNode, label, setOpen], + [ + labelVariant, + compact, + classNames?.controlLabelNode, + styles?.controlLabelNode, + label, + shouldShowCompactLabel, + setOpen, + ], ); const valueNode = useMemo(() => { @@ -357,16 +364,8 @@ const DefaultSelectControlComponent = memo( flexGrow={1} flexShrink={1} focusable={false} - minHeight={ - labelVariant === 'inside' - ? LABEL_VARIANT_INSIDE_HEIGHT - : compact - ? COMPACT_HEIGHT - : DEFAULT_HEIGHT - } minWidth={0} onClick={() => setOpen((s) => !s)} - paddingStart={1} role={role} style={styles?.controlInputNode} tabIndex={tabIndex} @@ -378,14 +377,14 @@ const DefaultSelectControlComponent = memo( height="100%" justifyContent="center" minWidth={0} - paddingX={1} + paddingEnd={2} style={styles?.controlStartNode} > {startNode} )} {shouldShowCompactLabel ? ( - + {labelNode} ) : null} @@ -409,8 +408,6 @@ const DefaultSelectControlComponent = memo( justifyContent="flex-start" minWidth={0} overflow="hidden" - paddingX={1} - paddingY={labelVariant === 'inside' && !isMultiSelect ? 0 : compact ? 1 : 1.5} style={styles?.controlValueNode} > {valueNode} @@ -429,8 +426,6 @@ const DefaultSelectControlComponent = memo( classNames?.controlStartNode, classNames?.controlValueNode, disabled, - labelVariant, - compact, styles?.controlInputNode, styles?.controlStartNode, styles?.controlValueNode, @@ -439,7 +434,6 @@ const DefaultSelectControlComponent = memo( shouldShowCompactLabel, labelNode, align, - isMultiSelect, valueNode, contentNode, setOpen, @@ -455,8 +449,6 @@ const DefaultSelectControlComponent = memo( flexGrow={1} height="100%" justifyContent={labelVariant === 'inside' ? 'flex-end' : undefined} - paddingX={2} - paddingY={compact ? 1 : 1.5} style={styles?.controlEndNode} > {customEndNode ? ( @@ -473,7 +465,6 @@ const DefaultSelectControlComponent = memo( [ classNames?.controlEndNode, labelVariant, - compact, styles?.controlEndNode, customEndNode, open, @@ -482,6 +473,18 @@ const DefaultSelectControlComponent = memo( ], ); + const inputStackStyles: Record = useMemo( + () => ({ + input: { + paddingTop: compact || labelVariant === 'inside' ? theme.space[1] : theme.space[2], + paddingBottom: compact ? theme.space[1] : theme.space[2], + paddingLeft: theme.space[2], + paddingRight: theme.space[2], + }, + }), + [compact, theme.space, labelVariant], + ); + return ( diff --git a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx index 413cb7d036..538c39f2bd 100644 --- a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx +++ b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx @@ -16,6 +16,10 @@ import { DefaultSelectOptionGroup } from './DefaultSelectOptionGroup'; import type { SelectDropdownProps, SelectOption, SelectOptionCustomUI, SelectType } from './Select'; import { defaultAccessibilityRoles, isSelectOptionGroup } from './Select'; +// intentional design decision to set max height to this value +// will cut off an option midway to afford scrolling action +const DEFAULT_SELECT_DROPDOWN_MAX_HEIGHT = 252; + const initialStyle = { opacity: 0, y: 0 }; const animateStyle = { opacity: 1, y: 4 }; @@ -59,6 +63,7 @@ const DefaultSelectDropdownComponent = memo( SelectOptionGroupComponent = DefaultSelectOptionGroup, accessibilityLabel = 'Select dropdown', accessibilityRoles = defaultAccessibilityRoles, + maxHeight = DEFAULT_SELECT_DROPDOWN_MAX_HEIGHT, ...props }: SelectDropdownProps, ref: React.Ref, @@ -264,7 +269,7 @@ const DefaultSelectDropdownComponent = memo( useEffect(() => { if (!controlRef.current) return; const resizeObserver = new ResizeObserver((entries) => { - setContainerWidth(entries[0].contentRect.width); + setContainerWidth(entries[0].target.getBoundingClientRect().width); }); resizeObserver.observe(controlRef.current); return () => resizeObserver.disconnect(); @@ -303,7 +308,7 @@ const DefaultSelectDropdownComponent = memo( borderRadius={400} elevation={2} flexDirection="column" - maxHeight={252} + maxHeight={maxHeight} overflow="auto" > {shouldShowSelectAll && ( diff --git a/packages/web/src/alpha/select/DefaultSelectOption.tsx b/packages/web/src/alpha/select/DefaultSelectOption.tsx index 69ccd05db0..f1b3e75d76 100644 --- a/packages/web/src/alpha/select/DefaultSelectOption.tsx +++ b/packages/web/src/alpha/select/DefaultSelectOption.tsx @@ -42,7 +42,7 @@ const selectOptionCss = css` position: absolute; inset: 0; border-radius: var(--bookendRadius); - border: 2px solid var(--color-bgLinePrimary); + border: var(--borderWidth-200) solid var(--color-bgLinePrimary); } &:first-child::after { @@ -158,7 +158,6 @@ const DefaultSelectOptionComponent = memo( end={end} innerSpacing={selectCellSpacingConfig.innerSpacing} media={media} - minHeight={compact ? 40 : 56} outerSpacing={selectCellSpacingConfig.outerSpacing} priority="end" selected={selected} diff --git a/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx b/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx index 613cf46ada..19a67e6243 100644 --- a/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx +++ b/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx @@ -2,11 +2,10 @@ import { memo, useCallback, useId, useMemo } from 'react'; import { Checkbox } from '../../controls/Checkbox'; import { Radio } from '../../controls/Radio'; -import { cx } from '../../cx'; import { VStack } from '../../layout'; import { Text } from '../../typography/Text'; -import type { SelectOptionGroupProps, SelectOptionProps, SelectType } from './Select'; +import type { SelectOptionGroupProps, SelectType } from './Select'; const DefaultSelectOptionGroupComponent = memo( ({ diff --git a/packages/web/src/alpha/select/Select.tsx b/packages/web/src/alpha/select/Select.tsx index 3fe96ab49f..8a96ccc71d 100644 --- a/packages/web/src/alpha/select/Select.tsx +++ b/packages/web/src/alpha/select/Select.tsx @@ -246,7 +246,7 @@ const SelectBase = memo( return ( } + ref={containerRef as React.RefObject} className={cx(classNames?.root, className)} data-testid={testID} style={rootStyles} diff --git a/packages/web/src/alpha/select/types.ts b/packages/web/src/alpha/select/types.ts index 1c9f826a8f..8b259325ad 100644 --- a/packages/web/src/alpha/select/types.ts +++ b/packages/web/src/alpha/select/types.ts @@ -2,6 +2,7 @@ import type React from 'react'; import type { SharedAccessibilityProps } from '@coinbase/cds-common'; import type { CellBaseProps } from '../../cells/Cell'; +import type { CellAccessoryProps } from '../../cells/CellAccessory'; import type { InputStackBaseProps } from '../../controls/InputStack'; import type { AriaHasPopupType } from '../../hooks/useA11yControlledVisibility'; import type { BoxDefaultElement, BoxProps } from '../../layout/Box'; @@ -142,7 +143,7 @@ export type SelectOptionGroupProps< /** Accessibility role for options */ accessibilityRole?: string; /** Accessory element to display with options */ - accessory?: React.ReactElement; + accessory?: React.ReactElement; /** Media element to display with options */ media?: React.ReactElement; /** End element to display with options */ @@ -282,6 +283,10 @@ export type SelectDropdownProps< setOpen: (open: boolean | ((open: boolean) => boolean)) => void; /** Label displayed above the dropdown */ label?: React.ReactNode; + /** Maximum height of the dropdown container + * @default 252 + */ + maxHeight?: number; /** Whether the dropdown is disabled */ disabled?: boolean; /** Label for the "Select All" option in multi-select mode */ diff --git a/packages/web/src/animation/LottieStatusAnimation.tsx b/packages/web/src/animation/LottieStatusAnimation.tsx index fba777638e..d3be770f60 100644 --- a/packages/web/src/animation/LottieStatusAnimation.tsx +++ b/packages/web/src/animation/LottieStatusAnimation.tsx @@ -1,7 +1,6 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { lottieStatusToAccessibilityLabel } from '@coinbase/cds-common/lottie/statusToAccessibilityLabel'; import { useStatusAnimationPoller } from '@coinbase/cds-common/lottie/useStatusAnimationPoller'; -import type { DimensionValue } from '@coinbase/cds-common/types/DimensionStyles'; import type { LottiePlayer } from '@coinbase/cds-common/types/LottiePlayer'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; @@ -19,11 +18,11 @@ type LottieStatusAnimationBaseProps = { }; type LottieStatusAnimationPropsWithWidth = { - width: DimensionValue; + width: React.CSSProperties['width']; } & LottieStatusAnimationBaseProps; type LottieStatusAnimationPropsWithHeight = { - height: DimensionValue; + height: React.CSSProperties['height']; } & LottieStatusAnimationBaseProps; export type LottieStatusAnimationProps = ( @@ -41,7 +40,7 @@ export const LottieStatusAnimation = memo( ...otherProps }: LottieStatusAnimationProps) => { const [, forceUpdate] = useState(0); - const lottie = useRef(); + const lottie = useRef(undefined); const handlePolling = useStatusAnimationPoller({ status, diff --git a/packages/web/src/animation/__tests__/useLottieHandlers.test.ts b/packages/web/src/animation/__tests__/useLottieHandlers.test.ts index 7a51897043..d1e1eaf528 100644 --- a/packages/web/src/animation/__tests__/useLottieHandlers.test.ts +++ b/packages/web/src/animation/__tests__/useLottieHandlers.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useLottieHandlers } from '../useLottieHandlers'; diff --git a/packages/web/src/animation/__tests__/useLottieListeners.test.ts b/packages/web/src/animation/__tests__/useLottieListeners.test.ts index f157f555a7..5c3983b339 100644 --- a/packages/web/src/animation/__tests__/useLottieListeners.test.ts +++ b/packages/web/src/animation/__tests__/useLottieListeners.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { LottieAnimationRef, LottieListener } from '../types'; import { useLottieListeners } from '../useLottieListeners'; diff --git a/packages/web/src/animation/useLottieLoader.ts b/packages/web/src/animation/useLottieLoader.ts index 3712b256f6..995d7f9137 100644 --- a/packages/web/src/animation/useLottieLoader.ts +++ b/packages/web/src/animation/useLottieLoader.ts @@ -19,7 +19,7 @@ export const useLottieLoader = (null); - const animationRef: LottieAnimationRef = useRef(); + const animationRef: LottieAnimationRef = useRef(undefined); const [, setAnimationLoaded] = useState(false); const preserveAspectRatio = useMemo(() => { diff --git a/packages/web/src/banner/Banner.tsx b/packages/web/src/banner/Banner.tsx index 2301b70491..e35f320ded 100644 --- a/packages/web/src/banner/Banner.tsx +++ b/packages/web/src/banner/Banner.tsx @@ -19,10 +19,12 @@ import type { import { css } from '@linaria/core'; import { Collapsible } from '../collapsible'; +import { cx } from '../cx'; import { Icon } from '../icons/Icon'; import { Box, HStack, type HStackDefaultElement, type HStackProps, VStack } from '../layout'; import type { ResponsiveProps, StaticStyleProps } from '../styles/styleProps'; import { Pressable } from '../system/Pressable'; +import type { StylesAndClassNames } from '../types'; import type { LinkDefaultElement, LinkProps } from '../typography/Link'; import { Link } from '../typography/Link'; import { Text } from '../typography/Text'; @@ -37,6 +39,29 @@ export const contentResponsiveConfig: ResponsiveProps['flexDir desktop: 'row', } as const; +/** + * Static class names for Banner component parts. + * Use these selectors to target specific elements with CSS. + */ +export const bannerClassNames = { + /** Persistent outer wrapper around both dismissible and non-dismissible variants. */ + root: 'cds-Banner', + /** Main content container (`HStack`) for banner body. */ + content: 'cds-Banner-content', + /** Start icon wrapper. */ + start: 'cds-Banner-start', + /** Right-side body wrapper containing middle content and actions. */ + body: 'cds-Banner-body', + /** Middle content wrapper containing title/message/label region. */ + middle: 'cds-Banner-middle', + /** Label text element. */ + label: 'cds-Banner-label', + /** Actions row element. */ + actions: 'cds-Banner-actions', + /** Dismiss button wrapper element. */ + dismiss: 'cds-Banner-dismiss', +} as const; + export type BannerBaseProps = SharedProps & { /** Sets the variant of the banner - which is responsible for foreground and background color assignment */ variant: BannerVariant; @@ -87,6 +112,7 @@ export type BannerBaseProps = SharedProps & { }; export type BannerProps = BannerBaseProps & + StylesAndClassNames & Omit, 'children' | 'title'>; export const Banner = memo( @@ -119,6 +145,8 @@ export const Banner = memo( marginStart, marginEnd, width = '100%', + classNames, + styles, ...props }: BannerProps, ref: React.ForwardedRef, @@ -200,123 +228,143 @@ export const Banner = memo( ); const content = ( - - + + + - {/** Start */} - - - + {/** Middle */} - {/** Middle */} - - - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {typeof children === 'string' ? ( - - {children} - - ) : ( - children - )} - - {typeof label === 'string' ? ( - - {label} + + {typeof title === 'string' ? ( + + {title} ) : ( - label + title + )} + {typeof children === 'string' ? ( + + {children} + + ) : ( + children )} - {/** Actions */} - {(!!clonedPrimaryAction || !!clonedSecondaryAction) && ( - - {clonedPrimaryAction} - {clonedSecondaryAction} - + {label} + + ) : ( + label )} - {/** Dismissable action */} - {showDismiss && ( - - - - - + {/** Actions */} + {(!!clonedPrimaryAction || !!clonedSecondaryAction) && ( + + {clonedPrimaryAction} + {clonedSecondaryAction} + )} - - {styleVariant === 'global' && !showDismiss && borderBox} - + + {/** Dismissable action */} + {showDismiss && ( + + + + + + )} + ); - return showDismiss ? ( + return ( - - {content} - + {showDismiss ? ( + + {content} + + ) : ( + content + )} {styleVariant === 'global' && borderBox} - ) : ( - content ); }, ), diff --git a/packages/web/src/banner/__tests__/Banner.test.tsx b/packages/web/src/banner/__tests__/Banner.test.tsx index 3329eb1de1..b2faaa55fd 100644 --- a/packages/web/src/banner/__tests__/Banner.test.tsx +++ b/packages/web/src/banner/__tests__/Banner.test.tsx @@ -97,6 +97,56 @@ describe('Banner', () => { expect(screen.getByTestId(TEST_ID)).toHaveStyle(customCss); }); + it('applies classNames/styles root and content slots', () => { + render( + + + Banner Content + + , + ); + + expect(screen.getByTestId(TEST_ID).className).toContain('test-banner-content'); + expect(screen.getByTestId(TEST_ID).className).toContain('cds-Banner-content'); + expect(screen.getByTestId(TEST_ID).parentElement?.className).toContain('test-banner-root'); + expect(screen.getByTestId(TEST_ID).parentElement?.className).toContain('cds-Banner'); + }); + + it('keeps a stable top-level wrapper regardless of dismiss state', () => { + const { container, rerender } = render( + + + Banner Content + + , + ); + + expect(container.firstElementChild?.tagName).toBe('DIV'); + + rerender( + + + Banner Content + + , + ); + + expect(container.firstElementChild?.tagName).toBe('DIV'); + }); + it('renders warning banner correctly', () => { render( diff --git a/packages/web/src/buttons/AvatarButton.tsx b/packages/web/src/buttons/AvatarButton.tsx index a138cdcd48..f8ddf8e182 100644 --- a/packages/web/src/buttons/AvatarButton.tsx +++ b/packages/web/src/buttons/AvatarButton.tsx @@ -1,5 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; +import React, { forwardRef, memo } from 'react'; import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; @@ -11,11 +10,100 @@ import type { ButtonBaseProps } from './Button'; export const avatarButtonDefaultElement = 'button'; +type DeprecatedAvatarButtonBorderProps = { + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderBottomLeftRadius?: PressableBaseProps['borderBottomLeftRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderBottomRightRadius?: PressableBaseProps['borderBottomRightRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderTopLeftRadius?: PressableBaseProps['borderTopLeftRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderTopRightRadius?: PressableBaseProps['borderTopRightRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderRadius?: PressableBaseProps['borderRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderWidth?: PressableBaseProps['borderWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderTopWidth?: PressableBaseProps['borderTopWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderEndWidth?: PressableBaseProps['borderEndWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderBottomWidth?: PressableBaseProps['borderBottomWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderStartWidth?: PressableBaseProps['borderStartWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + bordered?: PressableBaseProps['bordered']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedBottom?: PressableBaseProps['borderedBottom']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedEnd?: PressableBaseProps['borderedEnd']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedHorizontal?: PressableBaseProps['borderedHorizontal']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedStart?: PressableBaseProps['borderedStart']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedTop?: PressableBaseProps['borderedTop']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedVertical?: PressableBaseProps['borderedVertical']; +}; + export type AvatarButtonDefaultElement = typeof avatarButtonDefaultElement; export type AvatarButtonBaseProps = Polymorphic.ExtendableProps< Omit, - Pick & + DeprecatedAvatarButtonBorderProps & + Pick & Pick< AvatarBaseProps, 'alt' | 'src' | 'colorScheme' | 'shape' | 'borderColor' | 'name' | 'selected' @@ -36,8 +124,6 @@ const baseCss = css` display: flex; align-items: center; justify-content: center; - width: var(--interactable-height); - height: var(--interactable-height); min-width: unset; `; @@ -53,6 +139,7 @@ export const AvatarButton: AvatarButtonComponent = memo( compact, colorScheme, shape, + borderColor, selected, name, ...props @@ -61,29 +148,24 @@ export const AvatarButton: AvatarButtonComponent = memo( ) => { const Component = (as ?? avatarButtonDefaultElement) satisfies React.ElementType; - const height = compact ? interactableHeight.compact : interactableHeight.regular; - const styles = useMemo( - () => ({ '--interactable-height': `${height}px` }) as React.CSSProperties, - [height], - ); - return ( diff --git a/packages/web/src/buttons/Button.tsx b/packages/web/src/buttons/Button.tsx index 41d570c02d..0fdf264734 100644 --- a/packages/web/src/buttons/Button.tsx +++ b/packages/web/src/buttons/Button.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { forwardRef, memo, useMemo } from 'react'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; import type { ButtonVariant, @@ -10,6 +10,8 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; +import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp'; +import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { Spinner } from '../loaders/Spinner'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; @@ -17,8 +19,12 @@ import { Text } from '../typography/Text'; const COMPONENT_STATIC_CLASSNAME = 'cds-Button'; -const DEFAULT_MIN_WIDTH = 100; +const defaultLoadingSpinnerSize = 24; +/** + * @deprecated This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const spinnerHeight = 2.5; const baseCss = css` @@ -90,24 +96,12 @@ const middleNodeCss = css` position: relative; `; -const flushSpaceCss = css` +const flushCss = css` min-width: unset; - margin-inline-start: var(--space-2); - margin-inline-end: var(--space-2); -`; - -const flushStartCss = css` - margin-inline-start: calc(var(--space-2) * -1); -`; - -const flushEndCss = css` - margin-inline-end: calc(var(--space-2) * -1); `; const spinnerStyle = { - width: '24px', - height: '24px', - border: '2px solid', + border: 'var(--borderWidth-200) solid', borderTopColor: 'var(--color-transparent)', borderRightColor: 'var(--color-transparent)', borderLeftColor: 'var(--color-transparent)', @@ -130,7 +124,11 @@ export type ButtonBaseProps = Polymorphic.ExtendableProps< disabled?: boolean; /** Mark the button as loading and display a spinner. */ loading?: boolean; - /** Mark the background and border as transparent until interacted with. */ + /** The size of the loading spinner in pixels + * @default 24 + */ + loadingSpinnerSize?: number; + /** Set the background to transparent until interacted with. */ transparent?: boolean; /** Change to block and expand to 100% of parent width. */ block?: boolean; @@ -184,6 +182,7 @@ export const Button: ButtonComponent = memo( as, variant = 'primary', loading, + loadingSpinnerSize, transparent, block, compact, @@ -205,20 +204,21 @@ export const Button: ButtonComponent = memo( background, color, className, - // TO DO: get rid of this height and interactableHeight (mobile and web both) - height = compact ? 40 : 56, borderColor, - borderWidth = 100, + borderWidth = 0, // remove Pressable's default transparent border borderRadius = compact ? 700 : 900, accessibilityLabel, padding, paddingX = padding ?? (compact ? 2 : 4), + paddingY = padding ?? (compact ? 1 : 2), margin = 0, - minWidth = compact ? 'auto' : DEFAULT_MIN_WIDTH, + minWidth = 'auto', + style, ...props }: ButtonProps, ref?: Polymorphic.Ref, ) => { + const theme = useTheme(); const Component = (as ?? buttonDefaultElement) satisfies React.ElementType; const iconSize = compact ? 's' : 'm'; const hasIcon = Boolean(startIcon ?? endIcon); @@ -230,6 +230,23 @@ export const Button: ButtonComponent = memo( const backgroundValue = background ?? variantStyle.background; const borderColorValue = borderColor ?? variantStyle.borderColor; + const resolvedPaddingX = useResolveResponsiveProp(paddingX); + + const pressableStyle = useMemo(() => { + if (!flush || !resolvedPaddingX) return style; + const paddingPx = theme.space[resolvedPaddingX]; + return { + ...style, + ...(flush === 'start' + ? { marginInlineStart: -paddingPx, marginInlineEnd: paddingPx } + : { marginInlineStart: paddingPx, marginInlineEnd: -paddingPx }), + }; + }, [flush, resolvedPaddingX, theme.space, style]); + + // due to an odd, legacy implementation detail, the web Spinner uses 10em units to set the width/height of the spinner + // the "size" prop of Spinner is set to the font size of the Spinner element so ultimately its pixel size is 10 x size + const spinnerHeight = (loadingSpinnerSize ?? defaultLoadingSpinnerSize) / 10; + return ( diff --git a/packages/web/src/buttons/IconButton.tsx b/packages/web/src/buttons/IconButton.tsx index 1b2ce5d5a0..8300450fc7 100644 --- a/packages/web/src/buttons/IconButton.tsx +++ b/packages/web/src/buttons/IconButton.tsx @@ -5,17 +5,18 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; +import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { Spinner } from '../loaders/Spinner'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; -import { type ButtonBaseProps, spinnerHeight } from './Button'; +import { type ButtonBaseProps } from './Button'; const COMPONENT_STATIC_CLASSNAME = 'cds-IconButton'; const baseSpinnerCss = css` - border: 2px solid; + border: var(--borderWidth-200) solid; border-top-color: var(--color-transparent); border-right-color: var(--color-transparent); border-left-color: var(--color-transparent); @@ -55,18 +56,9 @@ type IconButtonComponent = ( Polymorphic.ReactReturn) & Polymorphic.ReactNamed; -const flushSpaceCss = css` - min-width: unset; - padding-inline-start: var(--space-2); - padding-inline-end: var(--space-2); -`; - -const flushStartCss = css` - margin-inline-start: calc(var(--space-2) * -1); -`; - -const flushEndCss = css` - margin-inline-end: calc(var(--space-2) * -1); +const baseCss = css` + width: fit-content; + height: fit-content; `; export const IconButton: IconButtonComponent = memo( @@ -81,13 +73,12 @@ export const IconButton: IconButtonComponent = memo( color, borderColor, borderRadius = 1000, - borderWidth = 100, + borderWidth = 0, // remove Pressable's default transparent border alignItems = 'center', justifyContent = 'center', - // TO DO: fix this when removing interactableHeight - height = compact ? 40 : 56, - width = compact ? 40 : 56, className, + style, + padding = compact ? 1.5 : 2, name, iconSize = compact ? 's' : 'm', active, @@ -103,14 +94,20 @@ export const IconButton: IconButtonComponent = memo( const theme = useTheme(); const iconSizeValue = theme.iconSize[iconSize]; + const spinnerSize = iconSizeValue / 10; - const spinnerSizeStyles = useMemo( - () => ({ - width: iconSizeValue, - height: iconSizeValue, - }), - [iconSizeValue], - ); + const resolvedPadding = useResolveResponsiveProp(padding); + + const pressableStyle = useMemo(() => { + if (!flush || !resolvedPadding) return undefined; + const negativeMargin = -theme.space[resolvedPadding]; + return { + ...style, + ...(flush === 'start' + ? { marginInlineStart: negativeMargin } + : { marginInlineEnd: negativeMargin }), + }; + }, [flush, resolvedPadding, theme.space, style]); const variantMap = transparent ? transparentVariants : variants; const variantStyle = variantMap[variant]; @@ -130,31 +127,24 @@ export const IconButton: IconButtonComponent = memo( borderColor={borderColorValue} borderRadius={borderRadius} borderWidth={borderWidth} - className={cx( - COMPONENT_STATIC_CLASSNAME, - flush && flushSpaceCss, - flush === 'start' && flushStartCss, - flush === 'end' && flushEndCss, - className, - )} + className={cx(COMPONENT_STATIC_CLASSNAME, baseCss, className)} color={colorValue} data-compact={compact} data-flush={flush} data-transparent={transparent} data-variant={variant} - height={height} justifyContent={justifyContent} loading={loading} + padding={padding} + style={pressableStyle} transparentWhileInactive={transparent} - width={width} {...props} > {loading ? ( ) : ( diff --git a/packages/web/src/buttons/IconCounterButton.tsx b/packages/web/src/buttons/IconCounterButton.tsx index 0fa2e524c8..07ae007a27 100644 --- a/packages/web/src/buttons/IconCounterButton.tsx +++ b/packages/web/src/buttons/IconCounterButton.tsx @@ -4,6 +4,7 @@ import type { IconSize, ValidateProps } from '@coinbase/cds-common/types'; import { formatCount } from '@coinbase/cds-common/utils/formatCount'; import type { IconName } from '@coinbase/cds-icons'; +import { cx } from '../cx'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; @@ -25,14 +26,32 @@ export type IconCounterButtonBaseProps = { count?: number; /** Color of the icon */ color?: ThemeVars.Color; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `styles.icon`, `classNames.icon`, or `color` to customize icon color. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: string; /** Background color of the overlay (element being interacted with). */ background?: ThemeVars.Color; }; export type IconCounterButtonProps = IconCounterButtonBaseProps & - Omit, 'background'>; + Omit, 'background'> & { + /** Custom inline styles for individual elements of the IconCounterButton component */ + styles?: { + /** Root Pressable element */ + root?: React.CSSProperties; + /** Icon element rendered when `icon` is an icon name */ + icon?: React.CSSProperties; + }; + /** Custom class names for individual elements of the IconCounterButton component */ + classNames?: { + /** Root Pressable element */ + root?: string; + /** Icon element rendered when `icon` is an icon name */ + icon?: string; + }; + }; export const IconCounterButton = memo( forwardRef(function IconCounterButton( @@ -45,6 +64,10 @@ export const IconCounterButton = memo( color = 'fg', dangerouslySetColor, background = 'transparent', + styles, + classNames, + className, + style, ...props }: IconCounterButtonProps, ref: React.Ref, @@ -53,6 +76,8 @@ export const IconCounterButton = memo( > @@ -62,10 +87,12 @@ export const IconCounterButton = memo( {typeof icon === 'string' ? ( ) : ( icon diff --git a/packages/web/src/buttons/Tile.tsx b/packages/web/src/buttons/Tile.tsx index bf8c6dcf5e..cd9cbae0d9 100644 --- a/packages/web/src/buttons/Tile.tsx +++ b/packages/web/src/buttons/Tile.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useMemo, useState } from 'react'; +import React, { type JSX, memo, useCallback, useMemo, useState } from 'react'; import { css } from '@linaria/core'; import { DotCount } from '../dots/DotCount'; diff --git a/packages/web/src/buttons/__stories__/Button.stories.tsx b/packages/web/src/buttons/__stories__/Button.stories.tsx index 0aafa6118d..8a45fe276b 100644 --- a/packages/web/src/buttons/__stories__/Button.stories.tsx +++ b/packages/web/src/buttons/__stories__/Button.stories.tsx @@ -11,14 +11,15 @@ export default { }; const buttonStories: Omit[] = [ - { variant: 'foregroundMuted' }, { variant: 'secondary' }, { variant: 'tertiary' }, { variant: 'positive' }, { variant: 'negative' }, + { variant: 'inverse' }, { variant: 'secondary', transparent: true }, { variant: 'positive', transparent: true }, { variant: 'negative', transparent: true }, + { variant: 'inverse', transparent: true }, { block: true }, { compact: true }, { compact: true, block: true }, diff --git a/packages/web/src/buttons/__stories__/IconButton.stories.tsx b/packages/web/src/buttons/__stories__/IconButton.stories.tsx index 6c9261a94d..c4829097ec 100644 --- a/packages/web/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/IconButton.stories.tsx @@ -9,6 +9,12 @@ const iconName = 'arrowsHorizontal'; const accessibilityLabel = 'Horizontal arrows'; const variants = [ + { + component: (props?: Partial) => ( + + ), + title: 'Non-compact', + }, { component: (props?: Partial) => ( @@ -35,15 +41,15 @@ const variants = [ }, { component: (props?: Partial) => ( - + ), - title: 'ForegroundMuted', + title: 'Primary flush start', }, { component: (props?: Partial) => ( - + ), - title: 'ForegroundMuted transparent', + title: 'Primary flush end', }, ]; @@ -120,7 +126,6 @@ const IconButtonSheet = ({ startIndex, endIndex }: { startIndex: number; endInde - ))} diff --git a/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx b/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx index 838f298bd8..40f245b387 100644 --- a/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx @@ -29,7 +29,11 @@ export const IconCounterButtonExample = () => { - + diff --git a/packages/web/src/buttons/__tests__/AvatarButton.test.tsx b/packages/web/src/buttons/__tests__/AvatarButton.test.tsx index db9ebb6fbc..f77ed746f0 100644 --- a/packages/web/src/buttons/__tests__/AvatarButton.test.tsx +++ b/packages/web/src/buttons/__tests__/AvatarButton.test.tsx @@ -62,4 +62,14 @@ describe('AvatarButton', () => { expect(screen.getByRole('button')).not.toHaveAttribute('onClick'); }); + + it('accepts deprecated border props without changing rendering', () => { + render( + + + , + ); + + expect(screen.getByRole('button')).toBeDefined(); + }); }); diff --git a/packages/web/src/buttons/__tests__/IconButton.test.tsx b/packages/web/src/buttons/__tests__/IconButton.test.tsx index ccc5b9f40c..4d8e169d27 100644 --- a/packages/web/src/buttons/__tests__/IconButton.test.tsx +++ b/packages/web/src/buttons/__tests__/IconButton.test.tsx @@ -158,8 +158,8 @@ describe('IconButton', () => { ); const spinner = screen.getByTestId('icon-button-spinner'); expect(spinner).toBeInTheDocument(); - expect(spinner).toHaveStyle(`width: ${defaultTheme.iconSize.s}px`); - expect(spinner).toHaveStyle(`height: ${defaultTheme.iconSize.s}px`); + // Spinner uses fontSize with CSS width/height of 10em, so fontSize = iconSize / 10 + expect(spinner).toHaveStyle(`font-size: ${defaultTheme.iconSize.s / 10}px`); }); it('renders Spinner with correct size when loading and not compact', () => { @@ -170,8 +170,8 @@ describe('IconButton', () => { ); const spinner = screen.getByTestId('icon-button-spinner'); expect(spinner).toBeInTheDocument(); - expect(spinner).toHaveStyle(`width: ${defaultTheme.iconSize.m}px`); - expect(spinner).toHaveStyle(`height: ${defaultTheme.iconSize.m}px`); + // Spinner uses fontSize with CSS width/height of 10em, so fontSize = iconSize / 10 + expect(spinner).toHaveStyle(`font-size: ${defaultTheme.iconSize.m / 10}px`); }); it('renders Icon with overridden iconSize', () => { @@ -194,8 +194,8 @@ describe('IconButton', () => { ); const spinner = screen.getByTestId('icon-button-spinner'); - expect(spinner).toHaveStyle(`width: ${defaultTheme.iconSize.xs}px`); - expect(spinner).toHaveStyle(`height: ${defaultTheme.iconSize.xs}px`); + // Spinner uses fontSize with CSS width/height of 10em, so fontSize = iconSize / 10 + expect(spinner).toHaveStyle(`font-size: ${defaultTheme.iconSize.xs / 10}px`); }); it('sets data attributes for style variants', () => { diff --git a/packages/web/src/cards/Card.tsx b/packages/web/src/cards/Card.tsx index d26005bddc..98fbc52f03 100644 --- a/packages/web/src/cards/Card.tsx +++ b/packages/web/src/cards/Card.tsx @@ -17,9 +17,17 @@ export type CardBaseProps = Pick & onClick?: MouseEventHandler; }; +/** + * @deprecated Use `ContentCard`, `MediaCard`, `MessagingCard`, or `DataCard` based on your use case. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardProps = CardBaseProps & Omit, 'onClick' | 'onKeyDown' | 'onKeyUp' | 'background'>; +/** + * @deprecated Use `ContentCard`, `MediaCard`, `MessagingCard`, or `DataCard` based on your use case. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const Card = memo(function Card({ children, background = 'bg', @@ -65,7 +73,6 @@ export const Card = memo(function Card({ ), [background, borderRadius, children, elevation, height, linkable, pin, props, testID, width], ); - if (isAnchor) { return ( = { export const CardMedia = memo(function CardMedia({ placement = 'end', ...props }: CardMediaProps) { if (props.type === 'spotSquare') { - return ( - - ); + return ; } if (props.type === 'pictogram') { - return ( - - ); + return ; } if (props.type === 'image') { diff --git a/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx b/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx index a3433d8ead..a3c34ca167 100644 --- a/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx +++ b/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { Button, IconButton, IconCounterButton } from '../../../buttons'; @@ -491,5 +491,12 @@ export default { }; ProductCarousel.parameters = { - a11y: { config: { rules: [{ id: 'scrollable-region-focusable', enabled: false }] } }, + a11y: { + config: { rules: [{ id: 'scrollable-region-focusable', enabled: false }] }, + options: { + rules: { + 'target-size': { enabled: false }, + }, + }, + }, }; diff --git a/packages/web/src/cards/LikeButton.tsx b/packages/web/src/cards/LikeButton.tsx index af5cd3f2da..7e486e1d06 100644 --- a/packages/web/src/cards/LikeButton.tsx +++ b/packages/web/src/cards/LikeButton.tsx @@ -1,8 +1,8 @@ -import React, { memo } from 'react'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; +import { memo, useMemo } from 'react'; import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpacingProps'; +import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; @@ -15,7 +15,7 @@ export type LikeButtonBaseProps = Pick< SharedProps & { liked?: boolean; count?: number; - /** Reduce the inner padding within the button itself. */ + /** Use the compact variant. */ compact?: boolean; /** Ensure the button aligns flush on the left or right. * This prop will translate the entire button left/right, @@ -29,17 +29,24 @@ export type LikeButtonProps = LikeButtonBaseProps & PressableProps ({ lineHeight: `${theme.iconSize[iconSize]}px` }), + [theme.iconSize, iconSize], + ); + return ( - + {count > 0 ? ( - + {count} ) : null} diff --git a/packages/web/src/cards/UpsellCard.tsx b/packages/web/src/cards/UpsellCard.tsx index 256bfb7927..6a1df1a5cc 100644 --- a/packages/web/src/cards/UpsellCard.tsx +++ b/packages/web/src/cards/UpsellCard.tsx @@ -8,13 +8,14 @@ import type { } from '@coinbase/cds-common/types'; import { Button, IconButton } from '../buttons'; -import { HStack, VStack } from '../layout'; +import { HStack, type HStackDefaultElement, type HStackProps, VStack } from '../layout'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system'; import { Text } from '../typography/Text'; export type UpsellCardBaseProps = SharedProps & Pick & - Pick & { + Pick & + Pick, 'className' | 'style'> & { /** Callback fired when the action button is pressed */ onActionPress?: PressableProps['onClick']; /** Callback fired when the dismiss button is pressed */ @@ -37,7 +38,8 @@ export type UpsellCardBaseProps = SharedProps & */ background?: ThemeVars.Color; /** - * @danger This is a migration escape hatch. It is not intended to be used normally. + * @deprecated Use `style`, `className`, or `background` to customize card background. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ dangerouslySetBackground?: string; }; @@ -86,6 +88,8 @@ export const UpsellCard = memo( accessibilityLabel, width = upsellCardDefaultWidth, onClick, + className, + style, }: UpsellCardProps) => { const content = ( diff --git a/packages/web/src/cards/__figma__/UpsellCard.figma.tsx b/packages/web/src/cards/__figma__/UpsellCard.figma.tsx index 288cacf183..4573917c43 100644 --- a/packages/web/src/cards/__figma__/UpsellCard.figma.tsx +++ b/packages/web/src/cards/__figma__/UpsellCard.figma.tsx @@ -40,8 +40,8 @@ figma.connect( return ( ); diff --git a/packages/web/src/cards/__stories__/Card.stories.tsx b/packages/web/src/cards/__stories__/Card.stories.tsx index 7052ee96f1..bf1fdc359f 100644 --- a/packages/web/src/cards/__stories__/Card.stories.tsx +++ b/packages/web/src/cards/__stories__/Card.stories.tsx @@ -6,6 +6,7 @@ import { featureEntryCards } from '@coinbase/cds-common/internal/data/featureEnt import { feedImages } from '@coinbase/cds-common/internal/data/feedImages'; import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum'; import { baseConfig, storyBuilder } from '@coinbase/cds-common/internal/utils/storyBuilder'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../../buttons'; import { Box, VStack } from '../../layout'; @@ -122,7 +123,9 @@ const feedCards = [ } as const, ]; -export const FeedCard = ({ ...props }: FeedCardProps) => { +type Story = StoryObj; + +const FeedCardRender = ({ ...props }: FeedCardProps) => { return ( { ); }; -FeedCard.bind({}); -FeedCard.args = baseConfig.args; -FeedCard.argTypes = baseConfig.argTypes; -FeedCard.parameters = { - ...baseConfig.parameters, - ...cardParameters, +export const FeedCard: Story = { + render: (args) => , + args: baseConfig.args, + argTypes: baseConfig.argTypes, + parameters: { + ...baseConfig.parameters, + ...cardParameters, + }, }; -export const FeedCards = () => { +const FeedCardsRender = () => { return ( {feedCards.map(({ like: getLikeProps, ...item }) => ( @@ -152,10 +157,15 @@ export const FeedCards = () => { ); }; -FeedCards.bind({}); -FeedCards.args = FeedCard.args; -FeedCards.parameters = FeedCard.parameters; -FeedCards.argTypes = FeedCard.argTypes; +export const FeedCards: Story = { + render: () => , + args: baseConfig.args, + parameters: { + ...baseConfig.parameters, + ...cardParameters, + }, + argTypes: baseConfig.argTypes, +}; // below is copied from cardBuilder.tsx const sharedWrapperProps = { @@ -283,7 +293,9 @@ export { PressableColoredCards, }; -export default { +const meta: Meta = { title: 'Components/Cards', - component: FeedCard, + component: FeedCardComponent, }; + +export default meta; diff --git a/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx b/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx index 5cc34f36db..57be356370 100644 --- a/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx +++ b/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { subheadIconSignMap } from '@coinbase/cds-common/tokens/sparkline'; diff --git a/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx b/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx index 1b58d1985f..e83bc87a79 100644 --- a/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx +++ b/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { ethBackground, floatingAssetCardCustomImage, diff --git a/packages/web/src/cards/__stories__/MediaCard.stories.tsx b/packages/web/src/cards/__stories__/MediaCard.stories.tsx index 6f08c754bf..41fb408459 100644 --- a/packages/web/src/cards/__stories__/MediaCard.stories.tsx +++ b/packages/web/src/cards/__stories__/MediaCard.stories.tsx @@ -1,11 +1,11 @@ -import React, { useRef } from 'react'; +import React, { type JSX, useRef } from 'react'; import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { Carousel } from '../../carousel/Carousel'; import { CarouselItem } from '../../carousel/CarouselItem'; import { VStack } from '../../layout/VStack'; import { RemoteImage } from '../../media/RemoteImage'; -import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography'; +import { Text } from '../../typography'; import { MediaCard } from '../MediaCard'; const exampleProps = { @@ -123,14 +123,22 @@ export const TextContent = (): JSX.Element => { /> + Custom description with bold text and italic text - + } media={exampleMedia} - subtitle={Custom Subtitle} + subtitle={ + + Custom Subtitle + + } thumbnail={exampleThumbnail} - title={Custom Title} + title={ + + Custom Title + + } width={320} /> diff --git a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx index bc8d5d01cb..a613bb6004 100644 --- a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx +++ b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { type JSX, useRef, useState } from 'react'; import { coinbaseOneLogo, svgs } from '@coinbase/cds-common/internal/data/assets'; import { Button } from '../../buttons/Button'; diff --git a/packages/web/src/cards/__stories__/UpsellCard.stories.tsx b/packages/web/src/cards/__stories__/UpsellCard.stories.tsx index b43f005296..6634595a70 100644 --- a/packages/web/src/cards/__stories__/UpsellCard.stories.tsx +++ b/packages/web/src/cards/__stories__/UpsellCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { coinbaseOneLogo } from '@coinbase/cds-common/internal/data/assets'; import { Button } from '../../buttons'; @@ -68,12 +68,12 @@ export const CustomTextNodes = (): JSX.Element => { Sign up } - dangerouslySetBackground="rgb(var(--blue80))" description={ Start your free 30 day trial of Coinbase One } + style={{ backgroundColor: 'rgb(var(--blue80))' }} title={ Coinbase One @@ -84,7 +84,7 @@ export const CustomTextNodes = (): JSX.Element => { }; export const CustomBackground = (): JSX.Element => { - return ; + return ; }; export const CustomWidth = (): JSX.Element => ; diff --git a/packages/web/src/cards/__tests__/UpsellCard.test.tsx b/packages/web/src/cards/__tests__/UpsellCard.test.tsx index c369a6b221..edf6f9e13a 100644 --- a/packages/web/src/cards/__tests__/UpsellCard.test.tsx +++ b/packages/web/src/cards/__tests__/UpsellCard.test.tsx @@ -46,10 +46,10 @@ describe('UpsellCard', () => { expect(screen.getByTestId('media')).toBeInTheDocument(); }); - it('renders dangerouslySetBackground', () => { + it('renders custom background via style prop', () => { render( - + , ); expect(screen.getByTestId(exampleProps.testID as string)).toHaveStyle({ diff --git a/packages/web/src/carousel/Carousel.tsx b/packages/web/src/carousel/Carousel.tsx index 48a6ddfae0..f3c02366a1 100644 --- a/packages/web/src/carousel/Carousel.tsx +++ b/packages/web/src/carousel/Carousel.tsx @@ -68,7 +68,7 @@ const animationConfig: Transition = { mass: 4, }; -export type CarouselItemRenderChildren = React.FC<{ isVisible: boolean }>; +export type CarouselItemRenderChildren = (args: { isVisible: boolean }) => React.ReactNode; export type CarouselItemBaseProps = Omit & { /** @@ -162,10 +162,10 @@ export type CarouselPaginationComponentBaseProps = { paginationAccessibilityLabel?: string | ((pageIndex: number) => string); /** * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead + * When omitted, the default pagination component renders the current dot-style design. + * @default 'dot' + * @deprecated `pill` is deprecated. Prefer the default dot pagination or provide a custom `PaginationComponent`. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ variant?: 'pill' | 'dot'; }; @@ -234,6 +234,11 @@ export type CarouselBaseProps = SharedProps & * Hides the pagination indicators (dots/bars showing current page). */ hidePagination?: boolean; + /** + * @deprecated Use the default dot pagination, or provide a custom `PaginationComponent` if you need custom visuals. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + paginationVariant?: CarouselPaginationComponentBaseProps['variant']; /** * Custom component to render navigation arrows. * @default DefaultCarouselNavigation @@ -311,14 +316,6 @@ export type CarouselBaseProps = SharedProps & * @default 3000 (3 seconds) */ autoplayInterval?: number; - /** - * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead - */ - paginationVariant?: CarouselPaginationComponentBaseProps['variant']; }; export type CarouselProps = Omit, 'title'> & @@ -715,6 +712,7 @@ export const Carousel = memo( title, hideNavigation, hidePagination, + paginationVariant, drag = 'snap', snapMode = 'page', NavigationComponent = DefaultCarouselNavigation, @@ -735,7 +733,6 @@ export const Carousel = memo( loop, autoplay, autoplayInterval = 3000, - paginationVariant, ...props }: CarouselProps, ref: React.ForwardedRef, @@ -1323,7 +1320,12 @@ export const Carousel = memo( {(title || !hideNavigation) && ( {typeof title === 'string' ? ( - + {title} ) : ( @@ -1379,32 +1381,35 @@ export const Carousel = memo( {pageChangeAccessibilityLabel(activePageIndex, totalPages)} )} + {/* // TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} )} > {childrenWithClones} diff --git a/packages/web/src/carousel/DefaultCarouselPagination.tsx b/packages/web/src/carousel/DefaultCarouselPagination.tsx index 2d804f69ff..a0dcc63eae 100644 --- a/packages/web/src/carousel/DefaultCarouselPagination.tsx +++ b/packages/web/src/carousel/DefaultCarouselPagination.tsx @@ -76,6 +76,7 @@ type PaginationIndicatorProps = PressableProps<'button'> & { const PaginationPill = memo(function PaginationPill({ isActive, + className, ...props }: PaginationIndicatorProps) { return ( @@ -83,6 +84,8 @@ const PaginationPill = memo(function PaginationPill({ aria-current={isActive ? 'true' : undefined} background={isActive ? 'bgPrimary' : 'bgLine'} borderColor="transparent" + borderWidth={0} + className={cx(pillCss, className)} data-active={isActive} {...props} /> @@ -180,7 +183,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination style, styles, testID = 'carousel-pagination', - variant = 'pill', + variant = 'dot', }: DefaultCarouselPaginationProps) { const isDot = variant === 'dot'; @@ -195,9 +198,11 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination return ( {totalPages > 0 ? ( Array.from({ length: totalPages }, (_, index) => @@ -215,7 +220,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination onClickPage?.(index)} style={styles?.dot} @@ -229,6 +234,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination aria-hidden="true" background="bgLine" borderColor="transparent" + borderWidth={0} className={cx(isDot ? dotCss : pillCss, classNames?.dot)} style={{ opacity: 0, diff --git a/packages/web/src/carousel/__figma__/Carousel.figma.tsx b/packages/web/src/carousel/__figma__/Carousel.figma.tsx index cb248f5434..a7609a0044 100644 --- a/packages/web/src/carousel/__figma__/Carousel.figma.tsx +++ b/packages/web/src/carousel/__figma__/Carousel.figma.tsx @@ -19,7 +19,7 @@ figma.connect( }), }, example: ({ title, hidePagination }) => ( - + {/* Item content */} {/* Item content */} {/* Item content */} diff --git a/packages/web/src/carousel/__stories__/Carousel.stories.tsx b/packages/web/src/carousel/__stories__/Carousel.stories.tsx index 8c5b433c92..effe60e7c0 100644 --- a/packages/web/src/carousel/__stories__/Carousel.stories.tsx +++ b/packages/web/src/carousel/__stories__/Carousel.stories.tsx @@ -165,7 +165,7 @@ const SquareAssetCard = ({ const BasicExamples = () => ( - + {sampleItems.map((item, index) => ( ( - + {sampleItems.map((item, index) => ( ( loop NavigationComponent={SeeAllComponent} drag="free" - paginationVariant="dot" snapMode="item" styles={overflowStyles} title="Square Items Carousel" @@ -222,7 +215,6 @@ const BasicExamples = () => ( ( {({ isVisible }) => } - + {sampleItems.slice(0, 4).map((item, index) => ( {item} @@ -300,14 +287,14 @@ const CustomComponentsExample = () => { disabled={!canGoPrevious} name="caretLeft" onClick={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -437,7 +424,7 @@ const CustomStylesExample = () => { zIndex: 1, }, }} - variant="foregroundMuted" + variant="secondary" /> )} styles={{ @@ -582,13 +569,7 @@ const AnimatedPaginationExample = () => { const AutoplayExample = () => ( - + {Object.values(assets).map((asset) => ( {({ isVisible }) => ( @@ -608,13 +589,7 @@ const AutoplayExample = () => ( const LoopingExamples = () => ( - + {sampleItems.map((item, index) => ( ( autoplay loop drag="snap" - paginationVariant="dot" snapMode="item" styles={overflowStyles} title="Looping with Autoplay - Snap Item" @@ -651,7 +625,6 @@ const LoopingExamples = () => ( { render(); - expect(mockNavigation).toHaveBeenCalledWith( + expect(mockNavigation).toHaveBeenCalled(); + expect(mockNavigation.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ onGoNext: expect.any(Function), onGoPrevious: expect.any(Function), disableGoNext: expect.any(Boolean), disableGoPrevious: expect.any(Boolean), }), - {}, ); }); + + it('does not pass a pagination variant by default', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render(); + + await waitFor(() => { + expect( + mockPagination.mock.calls.some((call) => { + const props = call[0]; + return props !== undefined && props.variant === undefined; + }), + ).toBe(true); + }); + }); + + it('forwards deprecated paginationVariant to custom pagination components', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render( + , + ); + + await waitFor(() => { + expect(mockPagination.mock.calls.some((call) => call[0]?.variant === 'pill')).toBe(true); + }); + }); }); describe('Accessibility', () => { diff --git a/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx b/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx index 5798e87193..fd4dc9ab10 100644 --- a/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx +++ b/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { domMax, LazyMotion } from 'framer-motion'; import { DefaultThemeProvider } from '../../utils/test'; import { CarouselAutoplayContext } from '../CarouselContext'; @@ -9,17 +10,9 @@ import { DefaultCarouselPagination } from '../DefaultCarouselPagination'; jest.mock('framer-motion', () => { const realFramerMotion = jest.requireActual('framer-motion'); - const createMockMotionValue = (initialValue: number) => ({ - get: jest.fn(() => initialValue), - set: jest.fn(), - on: jest.fn(() => () => {}), - onChange: jest.fn(() => () => {}), - clearListeners: jest.fn(), - }); - return { ...realFramerMotion, - motion: realFramerMotion.motion, + motion: realFramerMotion.m, useTransform: (value: { get: () => number }, transformer: (v: number) => string) => { const transformedValue = transformer(value.get()); return transformedValue; @@ -46,18 +39,34 @@ const mockAutoplayContext = { const renderPagination = (props: Partial>) => render( - - - + + + + + , ); describe('DefaultCarouselPagination', () => { + describe('variant', () => { + it('defaults to the dot variant', () => { + renderPagination({ totalPages: 3 }); + + expect(screen.getByTestId('carousel-pagination')).toHaveAttribute('data-variant', 'dot'); + }); + + it('switches to the pill variant when requested', () => { + renderPagination({ totalPages: 3, variant: 'pill' }); + + expect(screen.getByTestId('carousel-pagination')).toHaveAttribute('data-variant', 'pill'); + }); + }); + describe('paginationAccessibilityLabel', () => { it('uses default function that includes page number when not provided', () => { renderPagination({ totalPages: 3 }); diff --git a/packages/web/src/cells/Cell.tsx b/packages/web/src/cells/Cell.tsx index 99bbe51e29..5023f21e01 100644 --- a/packages/web/src/cells/Cell.tsx +++ b/packages/web/src/cells/Cell.tsx @@ -202,7 +202,24 @@ export const Cell: CellComponent = memo( accessory, accessoryNode, alignItems = 'center', + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, borderRadius = 200, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, children, style, styles, @@ -259,11 +276,56 @@ export const Cell: CellComponent = memo( const isButton = Boolean(onClick ?? onKeyDown ?? onKeyUp); const linkable = isAnchor || isButton; const contentTruncationStyle = cx(baseCss, shouldTruncate && truncationCss); + // Border props must be applied to the internal Pressable wrapper for correct visual rendering. + // The outer Box was only meant to create padding outside the Pressable area; this behavior + // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell. + const borderProps = useMemo( + () => ({ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + }), + [ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + ], + ); const content = useMemo(() => { // props for the entire inner container that wraps the top content // (media, children, intermediary, detail, accessory) and the bottom content const contentContainerProps = { - borderRadius, + ...borderProps, className: cx(contentClassName, classNames?.contentContainer), testID, ...(selected ? { background } : {}), @@ -365,7 +427,7 @@ export const Cell: CellComponent = memo( ); }, [ - borderRadius, + borderProps, contentClassName, classNames?.contentContainer, classNames?.topContent, @@ -414,7 +476,7 @@ export const Cell: CellComponent = memo( accessibilityLabel, accessibilityLabelledBy, background: 'bg' as const, - borderRadius, + ...borderProps, className: cx(pressCss, insetFocusRingCss, classNames?.pressable), disabled, marginX: innerSpacingMarginX, @@ -444,7 +506,7 @@ export const Cell: CellComponent = memo( accessibilityHint, accessibilityLabel, accessibilityLabelledBy, - borderRadius, + borderProps, classNames?.pressable, disabled, innerSpacingMarginX, diff --git a/packages/web/src/cells/CellAccessory.tsx b/packages/web/src/cells/CellAccessory.tsx index fb809f0fff..b3b36766b0 100644 --- a/packages/web/src/cells/CellAccessory.tsx +++ b/packages/web/src/cells/CellAccessory.tsx @@ -48,7 +48,7 @@ export const CellAccessory = memo( } return ( - + {icon} ); diff --git a/packages/web/src/cells/ContentCellFallback.tsx b/packages/web/src/cells/ContentCellFallback.tsx index ce1c5c95be..a1ef19c32a 100644 --- a/packages/web/src/cells/ContentCellFallback.tsx +++ b/packages/web/src/cells/ContentCellFallback.tsx @@ -55,6 +55,10 @@ const fullWidthStyle = { width: '100%', display: 'block' } as const; const floatStyle = { float: 'right', width: '30%' } as const; +/** + * @deprecated Use `ListCellFallback` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const ContentCellFallback = memo(function ContentCellFallback({ accessory, accessoryNode, diff --git a/packages/web/src/cells/ListCell.tsx b/packages/web/src/cells/ListCell.tsx index 5e9f5db6bb..d23c1ffd50 100644 --- a/packages/web/src/cells/ListCell.tsx +++ b/packages/web/src/cells/ListCell.tsx @@ -29,6 +29,7 @@ export const condensedInnerSpacing = { paddingY: 1, marginX: 0, } as const satisfies CellSpacing; + // no padding outside of the pressable area export const condensedOuterSpacing = { paddingX: 0, @@ -234,18 +235,22 @@ export const ListCell: ListCellComponent = memo( style, subtitle, subtitleNode, + minHeight: minHeightProp, ...props }: ListCellProps, ref?: Polymorphic.Ref, ) => { const Component = (as ?? listCellDefaultElement) satisfies React.ElementType; + // we need to maintain fixed min-heights for the different cell style variants until they are dropped in a breaking change + // see CDS-1620 const minHeight = - spacingVariant === 'compact' + minHeightProp ?? + (spacingVariant === 'compact' ? compactListHeight : spacingVariant === 'normal' ? listHeight - : undefined; + : undefined); const accessoryType = selected && !disableSelectionAccessory ? 'selected' : accessory; diff --git a/packages/web/src/cells/MediaFallback.tsx b/packages/web/src/cells/MediaFallback.tsx index 414be4ad6c..bf0db24e46 100644 --- a/packages/web/src/cells/MediaFallback.tsx +++ b/packages/web/src/cells/MediaFallback.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import { memo } from 'react'; import { imageSize, mediaSize } from '@coinbase/cds-common/tokens/cell'; import { Fallback, type fallbackDefaultElement, type FallbackProps } from '../layout/Fallback'; @@ -14,8 +14,8 @@ export const MediaFallback = memo(function MediaFallback({ ...fallbackProps }: MediaFallbackProps) { if (type === 'image') { - return ; + return ; } - return ; + return ; }); diff --git a/packages/web/src/cells/__stories__/ListCell.stories.tsx b/packages/web/src/cells/__stories__/ListCell.stories.tsx index 81f8284b84..c014afd5ec 100644 --- a/packages/web/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/web/src/cells/__stories__/ListCell.stories.tsx @@ -887,48 +887,93 @@ const WithHelperText = () => ( ); -const SpacingVariant = () => ( - - {/* Preferred (new design) */} - } - media={} - onClick={onClickConsole} - spacingVariant="condensed" - subdetail="+1.23%" - title="Condensed" - variant="positive" - /> +const BorderCustomization = () => { + const [isCondensed, setIsCondensed] = useState(true); + const spacingVariant = isCondensed ? 'condensed' : 'normal'; - {/* Deprecated options kept for backward compatibility */} - } - media={} - onClick={onClickConsole} - spacingVariant="compact" - subdetail="+1.23%" - title="Compact" - variant="positive" - /> - } - media={} - onClick={onClickConsole} - spacingVariant="normal" - subdetail="+1.23%" - title="Normal" - variant="positive" - /> - -); + return ( + + setIsCondensed(event.currentTarget.checked)} + > + Spacing variant: {spacingVariant} + + + + + + + + + + + ); +}; const CondensedListCell = () => { return ( @@ -1238,7 +1283,236 @@ const UseCaseShowcase = () => { ); }; +/** + * This story shows all 3 spacing variants side by side for easy comparison. + * Each column represents a different variant, and each row shows the same content configuration. + * + * This is useful for: + * - Comparing visual differences between variants + * - Understanding the impact of removing fixed min-height values + * - Testing before/after changes to spacing or height behavior + * + * Current min-height values: + * - normal: 80px + * - compact (deprecated): 40px + * - condensed: undefined (no min-height) + */ +const SpacingVariantsComparison = () => { + const spacingVariants = ['normal', 'compact', 'condensed'] as const; + + const renderCell = ( + spacingVariant: 'normal' | 'compact' | 'condensed', + props: Partial>, + ) => ( + + ); + + return ( + + {/* Header row */} + + {spacingVariants.map((variant) => ( + + {variant} + + {variant === 'normal' + ? 'min-height: 80px' + : variant === 'compact' + ? 'min-height: 40px' + : 'min-height: none'} + + + ))} + + + {/* Row 1: Title only - shows min-height impact most clearly */} + + + Title only (min-height impact most visible) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { title: 'Title' })} + + ))} + + + + {/* Row 2: Title + Detail */} + + + Title + Detail + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + detail: '$12,345.00', + subdetail: '+1.23%', + variant: 'positive', + })} + + ))} + + + + {/* Row 3: Title + Description */} + + + Title + Description + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description text here', + })} + + ))} + + + + {/* Row 4: Full content - Title + Description + Detail + Subdetail */} + + + Full content (Title + Description + Detail + Subdetail) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + variant: 'positive', + })} + + ))} + + + + {/* Row 5: With Media */} + + + With Media (Avatar) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + media: , + variant: 'positive', + })} + + ))} + + + + {/* Row 6: With Media + Accessory */} + + + With Media + Accessory + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + media: , + accessory: 'arrow', + variant: 'positive', + })} + + ))} + + + + {/* Row 7: Pressable */} + + + Pressable (onClick) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + media: , + accessory: 'arrow', + onClick: onClickConsole, + variant: 'positive', + })} + + ))} + + + + {/* Row 8: Long title - tests text wrapping at different heights */} + + + Long title (tests numberOfLines behavior) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'This is a very long title that should wrap to multiple lines', + detail: '$12,345.00', + })} + + ))} + + + + {/* Row 9: Mixed list simulation - shows how lists look with varying content */} + + + Mixed content list (shows height inconsistency without min-height) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { title: 'Short' })} + {renderCell(variant, { + title: 'With description', + description: 'Has more content', + })} + {renderCell(variant, { title: 'Short again' })} + {renderCell(variant, { + title: 'Full content', + description: 'Description here', + detail: '$100.00', + subdetail: '+5%', + variant: 'positive', + })} + + ))} + + + + ); +}; + export { + BorderCustomization, CompactContentDeprecated, CompactPressableContentDeprecated, CondensedListCell, @@ -1248,7 +1522,7 @@ export { LongContent, PressableContent, PriorityContent, - SpacingVariant, + SpacingVariantsComparison, UseCaseShowcase, WithAccessory, WithActions, diff --git a/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx b/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx index c38ff0c649..24ca54c248 100644 --- a/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx +++ b/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx @@ -38,7 +38,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 0), width: 50, }, - {}, + undefined, ); }); @@ -55,7 +55,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 1), width: 45, }, - {}, + undefined, ); }); @@ -73,7 +73,7 @@ describe('ContentCellFallback', () => { paddingTop: 0.5, width: 35, }, - {}, + undefined, ); }); @@ -91,7 +91,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 3), width: 65, }, - {}, + undefined, ); }); }); diff --git a/packages/web/src/chips/ChipProps.ts b/packages/web/src/chips/ChipProps.ts index a463720149..efc9054da0 100644 --- a/packages/web/src/chips/ChipProps.ts +++ b/packages/web/src/chips/ChipProps.ts @@ -1,8 +1,4 @@ -import type { - DimensionValue, - SharedAccessibilityProps, - SharedProps, -} from '@coinbase/cds-common/types'; +import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import type { PressableBaseProps, diff --git a/packages/web/src/coachmark/Coachmark.tsx b/packages/web/src/coachmark/Coachmark.tsx index 13864d78e6..4f82d48541 100644 --- a/packages/web/src/coachmark/Coachmark.tsx +++ b/packages/web/src/coachmark/Coachmark.tsx @@ -1,5 +1,5 @@ import React, { forwardRef, memo } from 'react'; -import { type DimensionValue, type SharedProps } from '@coinbase/cds-common'; +import { type SharedProps } from '@coinbase/cds-common'; import { IconButton } from '../buttons/IconButton'; import { @@ -42,7 +42,7 @@ export type CoachmarkBaseProps = SharedProps & /** * Desired width of the Coachmark with respect to max width of windowWidth - spacing2 * 2 */ - width?: DimensionValue; + width?: React.CSSProperties['width']; /** * a11y label of the close button */ @@ -72,7 +72,6 @@ export const Coachmark = memo( return ( {media} {!!onClose && ( diff --git a/packages/web/src/collapsible/Collapsible.tsx b/packages/web/src/collapsible/Collapsible.tsx index 96b5e75bac..c5bb1543d9 100644 --- a/packages/web/src/collapsible/Collapsible.tsx +++ b/packages/web/src/collapsible/Collapsible.tsx @@ -110,16 +110,19 @@ export const Collapsible = memo( }, [visibility, motionStyle]); return ( + // TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility )} > ` wrapper element. */ + label: 'cds-Control-label', + /** Interactable icon wrapper element. */ + icon: 'cds-Control-icon', + /** Native input element. */ + input: 'cds-Control-input', +} as const; const pointerCss = css` &:not(:disabled), @@ -62,7 +75,14 @@ export type ControlBaseProps = FilteredHTMLAttribut Partial< Pick< InteractableBaseProps, - 'background' | 'borderColor' | 'borderRadius' | 'borderWidth' | 'color' | 'elevation' + | 'background' + | 'borderColor' + | 'borderRadius' + | 'borderWidth' + | 'color' + | 'elevation' + | 'className' + | 'style' > > & { /** Label for the control option. */ @@ -85,10 +105,11 @@ export type ControlBaseProps = FilteredHTMLAttribut labelStyle?: React.CSSProperties; }; -export type ControlProps = ControlBaseProps & { - label?: React.ReactNode; - children: React.ReactNode; -}; +export type ControlProps = ControlBaseProps & + StylesAndClassNames & { + label?: React.ReactNode; + children: React.ReactNode; + }; const ControlWithRef = forwardRef(function ControlWithRef( { @@ -111,6 +132,10 @@ const ControlWithRef = forwardRef(function ControlWithRef, ref: React.ForwardedRef, @@ -125,7 +150,7 @@ const ControlWithRef = forwardRef(function ControlWithRef(); + const internalInputRef = useRef(undefined); const inputRef = useMergeRefs(ref, internalInputRef); const iconElement = useMemo( @@ -137,10 +162,10 @@ const ControlWithRef = forwardRef(function ControlWithRef {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */} @@ -150,12 +175,13 @@ const ControlWithRef = forwardRef(function ControlWithRef @@ -209,10 +239,29 @@ const ControlWithRef = forwardRef(function ControlWithRef ); - }, [label, iconElement, inputId, labelStyle, color, disabled, readOnly, labelId]); + }, [ + label, + iconElement, + inputId, + labelStyle, + color, + disabled, + readOnly, + labelId, + classNames?.label, + styles?.label, + ]); // If no label is provided, consumer should wrap the checkbox with diff --git a/packages/web/src/controls/InputIcon.tsx b/packages/web/src/controls/InputIcon.tsx index 101d285ccd..38998df44a 100644 --- a/packages/web/src/controls/InputIcon.tsx +++ b/packages/web/src/controls/InputIcon.tsx @@ -39,10 +39,10 @@ export const InputIcon = memo( return ( ); diff --git a/packages/web/src/controls/InputIconButton.tsx b/packages/web/src/controls/InputIconButton.tsx index be03a3713d..58e2562c10 100644 --- a/packages/web/src/controls/InputIconButton.tsx +++ b/packages/web/src/controls/InputIconButton.tsx @@ -15,7 +15,7 @@ export const variantTransformMap: Record = { negative: 'primary', foreground: 'primary', primary: 'primary', - foregroundMuted: 'foregroundMuted', + foregroundMuted: 'secondary', secondary: 'secondary', }; diff --git a/packages/web/src/controls/InputStack.tsx b/packages/web/src/controls/InputStack.tsx index b40695a31c..29d1181fe4 100644 --- a/packages/web/src/controls/InputStack.tsx +++ b/packages/web/src/controls/InputStack.tsx @@ -147,7 +147,27 @@ export type InputStackProps = Omit< BoxProps, 'width' | 'height' | 'borderRadius' > & - InputStackBaseProps; + InputStackBaseProps & { + classNames?: { + /** Root container element */ + root?: string; + /** + * Input horizontal container. + * Contains the input node, inner label and start/end nodes. + */ + inputContainer?: string; + /** Interactable input element */ + input?: string; + }; + styles?: { + /** Root container element */ + root?: React.CSSProperties; + /** Input horizontal container element */ + inputContainer?: React.CSSProperties; + /** Interactable input element */ + input?: React.CSSProperties; + }; + }; export const InputStack = memo( forwardRef( @@ -174,6 +194,10 @@ export const InputStack = memo( labelVariant = 'outside', blendStyles, inputBackground = 'bg', + className, + style, + classNames, + styles, ...props }, ref, @@ -230,10 +254,14 @@ export const InputStack = memo( }; }, [borderColorUnfocused, borderColorFocused, focusedBorderWidth, inputBorderRadius]); + const rootStyles = useMemo(() => ({ style, ...styles?.root }), [style, styles?.root]); + return ( {labelNode} : labelNode)} {!!prependNode && <>{prependNode}} -
+
{!!focused && !!enableColorSurge && ( diff --git a/packages/web/src/controls/NativeInput.tsx b/packages/web/src/controls/NativeInput.tsx index 79444bae44..07485387d5 100644 --- a/packages/web/src/controls/NativeInput.tsx +++ b/packages/web/src/controls/NativeInput.tsx @@ -83,6 +83,10 @@ const compactContainerPaddingCss = css` `; export type NativeInputProps = { + /** + * Decreases the padding within the input element + * @default false + */ compact?: boolean; /** Custom container spacing if needed. This will add to the existing spacing */ containerSpacing?: string; diff --git a/packages/web/src/controls/Radio.tsx b/packages/web/src/controls/Radio.tsx index 27fc005015..622eddcf39 100644 --- a/packages/web/src/controls/Radio.tsx +++ b/packages/web/src/controls/Radio.tsx @@ -17,10 +17,10 @@ import { Control, type ControlBaseProps } from './Control'; const DotSvg = ({ color = 'black', - width = 20, + width, }: { color?: React.CSSProperties['color']; - width?: number; + width: number; }) => { return ( @@ -45,9 +45,9 @@ const baseCss = css` } &:focus-visible { outline-style: solid; - outline-width: 2px; + outline-width: var(--borderWidth-200); outline-color: var(--color-bgPrimary); - outline-offset: 2px; + outline-offset: var(--borderWidth-200); } `; diff --git a/packages/web/src/controls/SearchInput.tsx b/packages/web/src/controls/SearchInput.tsx index c8037d2606..869d9bb6d2 100644 --- a/packages/web/src/controls/SearchInput.tsx +++ b/packages/web/src/controls/SearchInput.tsx @@ -1,27 +1,21 @@ import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; import type { IconName } from '@coinbase/cds-common/types'; -import { css } from '@linaria/core'; -import { cx } from '../cx'; import { Box } from '../layout/Box'; import { InputIcon } from './InputIcon'; import { InputIconButton } from './InputIconButton'; import { TextInput, type TextInputBaseProps } from './TextInput'; +/** + * @deprecated Use local constants or the `compact` prop instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const scales = { regular: 56, compact: 40, -}; - -const baseCss = css` - height: ${scales.regular}px; -`; - -const compactCss = css` - height: ${scales.compact}px; -`; +} as const; type HTMLElementProps = React.InputHTMLAttributes & Required>; @@ -150,7 +144,6 @@ export const SearchInput = memo( export type SelectOptionProps = SelectOptionBaseProps; +/** + * @deprecated This component is deprecated along with old Select component. Please use the new Select alpha component instead. If you are using this component outside of Select, we recommend replacing it with ListCell. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectOption = memo( ({ title, diff --git a/packages/web/src/controls/Switch.tsx b/packages/web/src/controls/Switch.tsx index 96155b4803..6b279c2436 100644 --- a/packages/web/src/controls/Switch.tsx +++ b/packages/web/src/controls/Switch.tsx @@ -4,13 +4,28 @@ import { switchTransitionConfig } from '@coinbase/cds-common/motion/switch'; import { css } from '@linaria/core'; import { m as motion } from 'framer-motion'; +import { cx } from '../cx'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout/Box'; import { convertTransition } from '../motion/utils'; +import type { StylesAndClassNames } from '../types'; import { Control, type ControlBaseProps } from './Control'; -const COMPONENT_STATIC_CLASSNAME = 'cds-Switch'; +/** + * Static class names for Switch component parts. + * Use these selectors to target specific elements with CSS. + */ +export const switchClassNames = { + /** Persistent outer wrapper across all variants. */ + root: 'cds-Switch', + /** Underlying `Control` wrapper element. */ + control: 'cds-Switch-control', + /** Track wrapper element. */ + track: 'cds-Switch-track', + /** Thumb wrapper element. */ + thumb: 'cds-Switch-thumb', +} as const; const trackCss = css` width: var(--controlSize-switchWidth); @@ -37,12 +52,22 @@ const thumbCss = css` left: 1px; `; -export type SwitchProps = ControlBaseProps & { - /** Sets the checked/active color of the control. - * @default bgPrimary - */ - controlColor?: ThemeVars.Color; -}; +export type SwitchProps = ControlBaseProps & + StylesAndClassNames & { + /** + * Label content rendered next to the switch control. + * + * @example + * ```tsx + * Dark mode + * ``` + */ + children?: React.ReactNode; + /** Sets the checked/active color of the control. + * @default bgPrimary + */ + controlColor?: ThemeVars.Color; + }; const MotionBox = motion(Box); @@ -67,6 +92,10 @@ const SwitchWithRef = forwardRef(function SwitchW borderRadius = 1000, borderWidth, value, + className, + style, + classNames, + styles, ...props }, ref, @@ -78,9 +107,11 @@ const SwitchWithRef = forwardRef(function SwitchW ref={ref} borderRadius={1000} checked={checked} + className={cx(switchClassNames.control, classNames?.control)} disabled={disabled} label={children} role="switch" + style={{ ...style, ...styles?.control }} type="checkbox" value={value} {...props} @@ -91,19 +122,21 @@ const SwitchWithRef = forwardRef(function SwitchW borderColor={borderColor} borderRadius={borderRadius} borderWidth={borderWidth} - className={trackCss} + className={cx(trackCss, switchClassNames.track, classNames?.track)} data-filled={checked} justifyContent="flex-start" + style={styles?.track} testID="switch-track" > (function SwitchW ); - return children ? ( + return ( {switchNode} - ) : ( - switchNode ); }); diff --git a/packages/web/src/controls/TextInput.tsx b/packages/web/src/controls/TextInput.tsx index 3bade57ae7..3d5453376a 100644 --- a/packages/web/src/controls/TextInput.tsx +++ b/packages/web/src/controls/TextInput.tsx @@ -8,12 +8,12 @@ import React, { useState, } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; import { usePrefixedId } from '@coinbase/cds-common/hooks/usePrefixedId'; import type { InputVariant, SharedInputProps } from '@coinbase/cds-common/types/InputBaseProps'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; import type { TextAlignProps } from '@coinbase/cds-common/types/TextBaseProps'; +import { mergeReactElementRef, mergeRefs } from '@coinbase/cds-common/utils/mergeRefs'; import { css } from '@linaria/core'; import { cx } from '../cx'; @@ -76,12 +76,15 @@ export type TextInputBaseProps = { */ onClick?: React.MouseEventHandler; /** - * Customize the element which the input area will be rendered as. Adds ability to render the input area - * as a ``, `` etc... - * By default, the input area will be rendered as an ``. + * Customize the element which the input area will be rendered as. + * Adds ability to render the input area as a ``, `` etc... + * By default, TextInput renders an ``. * @danger Use this at your own risk, and don't use unless ABSOLUTELY NECESSARY. You may see weird UI when focusing etc.. * Our default input handles all of the UI/Accessibility needs for your out of the box, but inputNode will not include * those. + * + * If you need a ref to the underlying input element, prefer using `ref` on the `TextInput` component. + * Supplying a `ref` on the `inputNode` element is redundant; if present, it will be merged with the component's ref. * */ inputNode?: React.ReactElement; /** @@ -198,8 +201,8 @@ export const TextInput = memo( ) { const [focused, setFocused] = useState(false); const focusedVariant = useInputVariant(focused, variant); - const internalRef = useRef(); - const refs = useMergeRefs(ref, internalRef); + const internalRef = useRef(null); + const refs = useMemo(() => mergeRefs(ref, internalRef), [ref]); // Only generate a helperTextId if helperText is defined, otherwise // set it to undefined @@ -252,15 +255,21 @@ export const TextInput = memo( const inputElement = useMemo(() => { /** Ensures that the renderedInput has the blurring, focusing, disabled features */ if (inputNode) { - const clonedElm = cloneElement(inputNode, { - onFocus: handleOnFocus, - onBlur: handleOnBlur, - ref: refs, - 'aria-describedby': shouldSetHelperTextId && helperTextId, - 'aria-invalid': variant === 'negative', - id: shouldSetLabelId ? labelId : undefined, - disabled, - }); + const clonedElm = cloneElement( + inputNode as React.ReactElement< + React.InputHTMLAttributes & React.RefAttributes + >, + + { + onFocus: handleOnFocus, + onBlur: handleOnBlur, + ref: mergeReactElementRef(inputNode, refs), + 'aria-describedby': shouldSetHelperTextId ? helperTextId : undefined, + 'aria-invalid': variant === 'negative', + id: shouldSetLabelId ? labelId : undefined, + disabled, + }, + ); return clonedElm; } diff --git a/packages/web/src/controls/__stories__/HelperText.stories.tsx b/packages/web/src/controls/__stories__/HelperText.stories.tsx index 2808fe01f9..0a6333be45 100644 --- a/packages/web/src/controls/__stories__/HelperText.stories.tsx +++ b/packages/web/src/controls/__stories__/HelperText.stories.tsx @@ -49,7 +49,10 @@ export const TextAlign = () => { export const CustomColor = () => { return (
- + Test message
diff --git a/packages/web/src/controls/__stories__/InputIconButton.stories.tsx b/packages/web/src/controls/__stories__/InputIconButton.stories.tsx index 3601e4c035..d93590dce6 100644 --- a/packages/web/src/controls/__stories__/InputIconButton.stories.tsx +++ b/packages/web/src/controls/__stories__/InputIconButton.stories.tsx @@ -21,7 +21,7 @@ export const AddCustomColor = () => { disableInheritFocusStyle accessibilityLabel="Add" name="add" - variant="foregroundMuted" + variant="secondary" /> } /> @@ -38,7 +38,7 @@ export const AddCustomColorEnd = () => { transparent accessibilityLabel="Add" name="add" - variant="foregroundMuted" + variant="secondary" /> } label="Label" @@ -53,9 +53,7 @@ export const Basic = () => { - } + start={} variant={variant} /> ))} @@ -82,7 +80,7 @@ export const DefaultsToPrimary = () => { export const InvalidPlacement = () => { return ( - + ); }; diff --git a/packages/web/src/controls/__stories__/Select.stories.tsx b/packages/web/src/controls/__stories__/Select.stories.tsx index d5eb96ddca..0de2a7ef2e 100644 --- a/packages/web/src/controls/__stories__/Select.stories.tsx +++ b/packages/web/src/controls/__stories__/Select.stories.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState } from 'react'; import type { AssetKey } from '@coinbase/cds-common/internal/data/assets'; import { assets } from '@coinbase/cds-common/internal/data/assets'; import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum'; +import type { Meta, StoryObj } from '@storybook/react'; import { DotSymbol } from '../../dots'; import { Box } from '../../layout/Box'; @@ -11,11 +12,7 @@ import { InputIcon } from '../InputIcon'; import { Select, type SelectProps } from '../Select'; import { SelectOption } from '../SelectOption'; -const exampleOptions = ['Option 1', 'Option 2', 'Option 3', 'Option 4', 'Option 5', 'Option 6']; - -const assetKeys = Object.keys(assets) as AssetKey[]; - -export default { +const meta: Meta = { title: 'Components/Select/Select', component: Select, parameters: { @@ -23,6 +20,13 @@ export default { }, }; +export default meta; +type Story = StoryObj; + +const exampleOptions = ['Option 1', 'Option 2', 'Option 3', 'Option 4', 'Option 5', 'Option 6']; + +const assetKeys = Object.keys(assets) as AssetKey[]; + const Default = ({ variant, label, @@ -169,7 +173,7 @@ const InputStackOptions = () => { ); }; -const Disabled = () => { +const DisabledRender = () => { const [value, setValue] = useState(''); return ( @@ -196,6 +200,21 @@ const Disabled = () => { ); }; +const Disabled: Story = { + render: () => , + parameters: { + a11y: { + config: { + /** + * Color contrast ratio doesn't need to meet 4.5:1, as the element is disabled + * @link https://dequeuniversity.com/rules/axe/4.3/color-contrast + */ + rules: [{ id: 'color-contrast', enabled: false }], + }, + }, + }, +}; + const Compact = () => { const [value, setValue] = useState(''); @@ -322,17 +341,3 @@ export { LongTextSelect, Variants, }; - -Disabled.bind({}); -/** TODO: convert to CSF (Component Story Format v3) */ -Disabled.parameters = { - a11y: { - config: { - /** - * Color contrast ratio doesn't need to meet 4.5:1, as the element is disabled - * @link https://dequeuniversity.com/rules/axe/4.3/color-contrast - */ - rules: [{ id: 'color-contrast', enabled: false }], - }, - }, -}; diff --git a/packages/web/src/controls/__stories__/TextInput.stories.tsx b/packages/web/src/controls/__stories__/TextInput.stories.tsx index df28ee49c4..adacd2c65f 100644 --- a/packages/web/src/controls/__stories__/TextInput.stories.tsx +++ b/packages/web/src/controls/__stories__/TextInput.stories.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { InputVariant } from '@coinbase/cds-common/types/InputBaseProps'; import { css } from '@linaria/core'; +import type { Meta, StoryObj } from '@storybook/react'; import { Icon } from '../../icons/Icon'; import { Box } from '../../layout/Box'; @@ -17,11 +18,14 @@ import { InputLabel } from '../InputLabel'; import { NativeTextArea } from '../NativeTextArea'; import { TextInput } from '../TextInput'; -export default { +const meta: Meta = { title: 'Components/Inputs/TextInput', component: TextInput, }; +export default meta; +type Story = StoryObj; + const nativeInputCustomCss = css` &:focus { outline-style: none; @@ -219,7 +223,7 @@ export const Borderless = function Borderless() { ); }; -export const Disabled = function Disabled() { +const DisabledRender = () => { return ( <> @@ -232,15 +236,17 @@ export const Disabled = function Disabled() { ); }; -Disabled.bind({}); -Disabled.parameters = { - a11y: { - config: { - /** - * Color contrast ratio doesn't need to meet 4.5:1, as the element is disabled - * @link https://dequeuniversity.com/rules/axe/4.3/color-contrast - */ - rules: [{ id: 'color-contrast', enabled: false }], +export const Disabled: Story = { + render: () => , + parameters: { + a11y: { + config: { + /** + * Color contrast ratio doesn't need to meet 4.5:1, as the element is disabled + * @link https://dequeuniversity.com/rules/axe/4.3/color-contrast + */ + rules: [{ id: 'color-contrast', enabled: false }], + }, }, }, }; @@ -482,7 +488,7 @@ export const RenderInputDefault = () => { ); }; -export const RenderInputDisabled = () => { +const RenderInputDisabledRender = () => { const [inputText, setInputText] = useState('Test'); const ref = useRef(null); @@ -506,12 +512,14 @@ export const RenderInputDisabled = () => { ); }; -RenderInputDisabled.bind({}); -RenderInputDisabled.parameters = { - a11y: { - options: { - rules: { - 'color-contrast': { enabled: false }, +export const RenderInputDisabled: Story = { + render: () => , + parameters: { + a11y: { + options: { + rules: { + 'color-contrast': { enabled: false }, + }, }, }, }, diff --git a/packages/web/src/controls/__tests__/Control.test.tsx b/packages/web/src/controls/__tests__/Control.test.tsx index 6986d000d7..9a4976a0a9 100644 --- a/packages/web/src/controls/__tests__/Control.test.tsx +++ b/packages/web/src/controls/__tests__/Control.test.tsx @@ -49,4 +49,56 @@ describe('Control', () => { expect(console.warn).toHaveBeenCalledTimes(1); process.env.NODE_ENV = 'test'; }); + + it('keeps a stable root wrapper regardless of label presence', () => { + const { rerender } = render( + + +
test children
+
+
, + ); + + expect(screen.getByRole('checkbox').closest('.cds-Control')).toBeTruthy(); + + rerender( + + +
test children
+
+
, + ); + + expect(screen.getByRole('checkbox').closest('.cds-Control')).toBeTruthy(); + }); + + it('applies classNames/styles to root and slots', () => { + render( + + +
test children
+
+
, + ); + + expect(screen.getByRole('checkbox').closest('.test-control-root')).toBeTruthy(); + expect(screen.getByRole('checkbox').closest('.cds-Control')).toBeTruthy(); + expect(screen.getByRole('checkbox').className).toContain('test-control-input'); + expect(screen.getByRole('checkbox').className).toContain('cds-Control-input'); + expect(screen.getByText('test children').parentElement?.className).toContain( + 'test-control-icon', + ); + expect(screen.getByText('test children').parentElement?.className).toContain( + 'cds-Control-icon', + ); + }); }); diff --git a/packages/web/src/controls/__tests__/HelperText.test.tsx b/packages/web/src/controls/__tests__/HelperText.test.tsx index 0d29657299..0ca277a0cf 100644 --- a/packages/web/src/controls/__tests__/HelperText.test.tsx +++ b/packages/web/src/controls/__tests__/HelperText.test.tsx @@ -23,8 +23,8 @@ describe('HelperText.test', () => { expect(screen.getByTestId('error-icon').className).toContain('fgNegative'); }); - it('renders custom color via dangerouslySetColor', () => { - render(Test text); + it('renders custom color via style', () => { + render(Test text); expect(screen.getByText('Test text')).toHaveStyle({ color: '#FF0000', @@ -48,6 +48,30 @@ describe('HelperText.test', () => { }); }); + it('renders custom root and icon slots with styles and classNames', () => { + render( + + + Test text + + , + ); + + expect(screen.getByText('Test text')).toHaveStyle({ + color: '#00FF00', + }); + expect(screen.getByText('Test text').className).toContain('helper-root'); + expect(screen.getByTestId('error-icon')).toHaveStyle({ + color: '#0000FF', + }); + expect(screen.getByTestId('error-icon').className).toContain('helper-icon'); + }); + it('renders custom padding', () => { render( diff --git a/packages/web/src/controls/__tests__/InputIconButton.test.tsx b/packages/web/src/controls/__tests__/InputIconButton.test.tsx index a68e4e91a8..5c5ad442c0 100644 --- a/packages/web/src/controls/__tests__/InputIconButton.test.tsx +++ b/packages/web/src/controls/__tests__/InputIconButton.test.tsx @@ -28,23 +28,20 @@ describe('Test InputIconButton inheritFocusedVariant interaction', () => { }); describe('InputIconButton', () => { - it(`Can override focused color provided by context.`, () => { - const variant = 'foregroundMuted'; - + it(`Uses context variant over its own variant when disableInheritFocusStyle is false`, () => { render( } + start={} testID="text-input" - variant={variant as InputVariant} + variant="foregroundMuted" /> , ); fireEvent.click(screen.getByRole('textbox')); - expect(screen.getByTestId('icon-base-glyph')).toHaveStyle(`color: var(--${variant})`); + expect(screen.getByTestId('icon-base-glyph')).toHaveStyle(`color: var(--secondary)`); }); }); diff --git a/packages/web/src/controls/__tests__/InputStack.test.tsx b/packages/web/src/controls/__tests__/InputStack.test.tsx index 5deb864953..2cb0cd0808 100644 --- a/packages/web/src/controls/__tests__/InputStack.test.tsx +++ b/packages/web/src/controls/__tests__/InputStack.test.tsx @@ -1,42 +1,70 @@ -import TestRenderer from 'react-test-renderer'; +import { render, screen } from '@testing-library/react'; import { DefaultThemeProvider } from '../../utils/test'; import type { InputStackProps } from '../InputStack'; import { InputStack } from '../InputStack'; const TEST_ID = 'input'; +const input = ; -function expectAttribute< - K extends keyof Pick, ->(prop: K, values: readonly NonNullable[]) { - const input = ; - - values.forEach((value) => { - it(`will set "${value}" for \`${prop}\` prop`, async () => { - const inputRenderer = TestRenderer.create( - - - , - ); +function renderInputStack(props: Partial = {}) { + return render( + + + , + ); +} + +describe('InputStack', () => { + describe('width', () => { + it.each(['10%', '50%', '100%'] as const)('renders with width="%s"', (width) => { + renderInputStack({ width }); - const inputInstance = await inputRenderer.root.findByType(InputStack); - expect(inputInstance.props[prop]).toEqual(value); + const container = screen.getByTestId(TEST_ID); + expect(container).toBeInTheDocument(); }); }); -} -describe('width', () => { - expectAttribute('width', ['10%', '50%', '100%']); -}); + describe('height', () => { + it.each(['10%', '50%', '100%', 56, 40] as const)('renders with height="%s"', (height) => { + renderInputStack({ height }); -describe('height', () => { - expectAttribute('height', ['10%', '50%', '100%', 56, 40]); -}); + const interactable = screen.getByTestId('input-interactable-area'); + expect(interactable).toBeInTheDocument(); + }); + }); -describe('disabled', () => { - expectAttribute('disabled', [false, true]); -}); + describe('disabled', () => { + it('renders without disabled state when disabled=false', () => { + renderInputStack({ disabled: false }); -describe('variant', () => { - expectAttribute('variant', ['foreground', 'foregroundMuted', 'negative', 'positive', 'primary']); + const interactable = screen.getByTestId('input-interactable-area'); + expect(interactable).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('renders with disabled state when disabled=true', () => { + renderInputStack({ disabled: true }); + + const interactable = screen.getByTestId('input-interactable-area'); + expect(interactable).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + describe('variant', () => { + it.each([ + ['foreground', 'var(--color-bgInverse)'], + ['foregroundMuted', 'var(--color-bgLineHeavy)'], + ['negative', 'var(--color-bgNegative)'], + ['positive', 'var(--color-bgPositive)'], + ['primary', 'var(--color-bgPrimary)'], + ] as const)('applies variant="%s" border color styling', (variant, expectedBorderColor) => { + renderInputStack({ variant }); + + const interactable = screen.getByTestId('input-interactable-area'); + // The variant affects the CSS custom property --border-color-unfocused via inline style + expect(interactable.style.getPropertyValue('--border-color-unfocused')).toBe( + expectedBorderColor, + ); + }); + }); }); diff --git a/packages/web/src/controls/__tests__/Switch.test.tsx b/packages/web/src/controls/__tests__/Switch.test.tsx index 71c922134b..c2c0e7bb29 100644 --- a/packages/web/src/controls/__tests__/Switch.test.tsx +++ b/packages/web/src/controls/__tests__/Switch.test.tsx @@ -92,6 +92,59 @@ describe('Switch.test', () => { expect(screen.getByTestId('test-test-id')).toBeTruthy(); }); + it('keeps a stable root wrapper regardless of label presence', () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByRole('switch').closest('.cds-Switch')).toBeTruthy(); + + rerender( + + with label + , + ); + + expect(screen.getByRole('switch').closest('.cds-Switch')).toBeTruthy(); + }); + + it('applies classNames/styles to root and slots', () => { + render( + + + label + + , + ); + + expect(screen.getByRole('switch').closest('.test-switch-root')).toBeTruthy(); + expect(screen.getByRole('switch').closest('.cds-Switch')).toBeTruthy(); + expect(screen.getByTestId('switch-track').className).toContain('test-switch-track'); + expect(screen.getByTestId('switch-track').className).toContain('cds-Switch-track'); + expect(screen.getByTestId('switch-thumb').className).toContain('test-switch-thumb'); + expect(screen.getByTestId('switch-thumb').className).toContain('cds-Switch-thumb'); + expect(screen.getByRole('switch').closest('.test-switch-control')).toBeTruthy(); + expect(screen.getByRole('switch').closest('.cds-Switch-control')).toBeTruthy(); + expect(screen.getByRole('switch').closest('.cds-Control')).toHaveStyle({ + borderLeftWidth: '4px', + }); + expect(screen.getByRole('switch').closest('.cds-Control')).toHaveStyle({ + borderRightWidth: '5px', + }); + }); + it('has default color', () => { render( diff --git a/packages/web/src/controls/selectContext.ts b/packages/web/src/controls/selectContext.ts index 5df6e411a2..de69c161b9 100644 --- a/packages/web/src/controls/selectContext.ts +++ b/packages/web/src/controls/selectContext.ts @@ -2,6 +2,10 @@ import { createContext, useContext } from 'react'; import type { SelectBaseProps } from './Select'; +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type SelectContextType = { handleCloseMenu?: () => void; } & Pick; @@ -12,7 +16,20 @@ export const defaultContext = { handleCloseMenu: undefined, }; +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectContext = createContext(defaultContext); + +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectProvider = SelectContext.Provider; +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const useSelectContext = () => useContext(SelectContext); diff --git a/packages/web/src/dates/DateInput.tsx b/packages/web/src/dates/DateInput.tsx index 8cfa99f531..bb4bca2a27 100644 --- a/packages/web/src/dates/DateInput.tsx +++ b/packages/web/src/dates/DateInput.tsx @@ -105,7 +105,6 @@ export const DateInput = memo( ); diff --git a/packages/web/src/dates/DatePicker.tsx b/packages/web/src/dates/DatePicker.tsx index da62fe5be0..1751240bbb 100644 --- a/packages/web/src/dates/DatePicker.tsx +++ b/packages/web/src/dates/DatePicker.tsx @@ -289,7 +289,6 @@ export const DatePicker = memo( const dateInput = useMemo( () => ( ), [ diff --git a/packages/web/src/dots/DotCount.tsx b/packages/web/src/dots/DotCount.tsx index 6a993dcd1b..8b4d2be686 100644 --- a/packages/web/src/dots/DotCount.tsx +++ b/packages/web/src/dots/DotCount.tsx @@ -36,14 +36,11 @@ const dotCountContentCss = css` align-items: center; justify-content: center; display: flex; - border-width: 1px; - min-width: ${dotCountSize}px; - height: ${dotCountSize}px; - border-radius: 16px; - padding-top: 3px; - padding-bottom: 3px; - padding-inline-start: 6px; - padding-inline-end: 6px; + border-style: solid; + border-width: var(--borderWidth-100); + border-radius: var(--borderRadius-400); + padding-inline-start: var(--space-0_75); + padding-inline-end: var(--space-0_75); `; const variantColorMap: Record = { @@ -75,6 +72,18 @@ export type DotCountBaseProps = SharedProps & children?: React.ReactNode; /** Indicates what shape Dot is overlapping */ overlap?: DotOverlap; + /** + * An optional fixed height of the DotCount component. + * Width grows based on content length. + * @default 24 + * */ + height?: number; + /** + * An optional fixed width of the DotCount component. + * By default, width grows based on content length. + * @default auto + * */ + width?: number; }; export type DotCountProps = DotCountBaseProps & { @@ -107,6 +116,8 @@ export const DotCount = memo( variant = 'negative', count, max, + height = dotCountSize, + width, testID, accessibilityLabel, overlap, @@ -122,12 +133,15 @@ export const DotCount = memo( const containerStyles = useMemo(() => { const variantColor = variantColorMap[variant]; return { + height, + minWidth: height, + width, backgroundColor: color[variantColor], borderColor: color.bgSecondary, ...pinStyles, ...styles?.container, }; - }, [color, pinStyles, styles?.container, variant]); + }, [height, width, color, pinStyles, styles?.container, variant]); const motionProps = useMotionProps({ enterConfigs: [dotOpacityEnterConfig, dotScaleEnterConfig], @@ -154,11 +168,14 @@ export const DotCount = memo( {children} {count > 0 && ( + // TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility )} > {children} {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} diff --git a/packages/web/src/dropdown/DropdownContent.tsx b/packages/web/src/dropdown/DropdownContent.tsx index e0960da2e1..d34f172ff2 100644 --- a/packages/web/src/dropdown/DropdownContent.tsx +++ b/packages/web/src/dropdown/DropdownContent.tsx @@ -6,19 +6,18 @@ import { animateDropdownTransformOutConfig, } from '@coinbase/cds-common/animation/dropdown'; import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; -import type { DimensionValue } from '@coinbase/cds-common/types'; -import type { Placement } from '@popperjs/core'; import { m as motion } from 'framer-motion'; import { VStack } from '../layout/VStack'; import { useMotionProps } from '../motion/useMotionProps'; +import type { Placement } from '../overlays/popover/PopoverProps'; import type { DropdownProps } from './DropdownProps'; const dropdownStaticClassName = 'cds-dropdown'; export type DropdownContentProps = { - height?: DimensionValue; + height?: React.CSSProperties['height']; placement?: Placement; } & Pick; diff --git a/packages/web/src/dropdown/useResponsiveHeight.ts b/packages/web/src/dropdown/useResponsiveHeight.ts index dfde5ee1dd..495f553dc1 100644 --- a/packages/web/src/dropdown/useResponsiveHeight.ts +++ b/packages/web/src/dropdown/useResponsiveHeight.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import type React from 'react'; import type { RectReadOnly } from 'react-use-measure'; -import type { DimensionValue } from '@coinbase/cds-common'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useIsoEffect } from '../hooks/useIsoEffect'; @@ -30,7 +30,9 @@ export function useResponsiveHeight({ const bottomGutter = space[BOTTOM_GUTTER_SPACE]; const calculatedGap = space[gap ?? 0]; - const [dropdownHeight, setDropdownHeight] = useState(maxHeight); + const [dropdownHeight, setDropdownHeight] = useState< + React.CSSProperties['maxHeight'] | undefined + >(maxHeight); // the following calculates the window height on resize changes and stores it in state const [windowHeight, setWindowHeight] = useState( diff --git a/packages/web/src/hooks/__tests__/useA11yControlledVisibility.test.ts b/packages/web/src/hooks/__tests__/useA11yControlledVisibility.test.ts index a5c2240db9..b9a2c57a96 100644 --- a/packages/web/src/hooks/__tests__/useA11yControlledVisibility.test.ts +++ b/packages/web/src/hooks/__tests__/useA11yControlledVisibility.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useA11yControlledVisibility } from '../useA11yControlledVisibility'; diff --git a/packages/web/src/hooks/__tests__/useA11yLabels.test.ts b/packages/web/src/hooks/__tests__/useA11yLabels.test.ts index cb83373cbe..d88068669d 100644 --- a/packages/web/src/hooks/__tests__/useA11yLabels.test.ts +++ b/packages/web/src/hooks/__tests__/useA11yLabels.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useA11yLabels } from '../useA11yLabels'; @@ -9,7 +9,7 @@ describe('useA11yLabels', () => { it('generates default props when no options are passed', () => { const { result } = renderHook(() => useA11yLabels()); - expect(result.current.labelledBySource).toMatch(/:r[0-9].*/); + expect(result.current.labelledBySource).toMatch(/«r[0-9]+»/); expect(result.current.labelledBy).toBe(result.current.labelledBySource); expect(result.current.label).toBeUndefined(); }); diff --git a/packages/web/src/hooks/__tests__/useBreakpoints.test.tsx b/packages/web/src/hooks/__tests__/useBreakpoints.test.tsx index fef43f5f10..1875dd4190 100644 --- a/packages/web/src/hooks/__tests__/useBreakpoints.test.tsx +++ b/packages/web/src/hooks/__tests__/useBreakpoints.test.tsx @@ -1,4 +1,5 @@ -import { act, renderHook } from '@testing-library/react-hooks'; +import { act } from 'react'; +import { renderHook } from '@testing-library/react'; import { media } from '../../styles/media'; import { MediaQueryContext } from '../../system/MediaQueryProvider'; @@ -23,8 +24,9 @@ describe('useBreakpoints hook', () => { }); it('throws an error if used outside of MediaQueryProvider', () => { - const { result } = renderHook(() => useBreakpoints()); - expect(() => result.current).toThrow('useBreakpoints must be used within a MediaQueryProvider'); + expect(() => renderHook(() => useBreakpoints())).toThrow( + 'useBreakpoints must be used within a MediaQueryProvider', + ); }); it('returns initial matches based on getSnapshot', () => { diff --git a/packages/web/src/hooks/__tests__/useCellSpacing.test.ts b/packages/web/src/hooks/__tests__/useCellSpacing.test.ts index b1c711fe34..080f2c9bdd 100644 --- a/packages/web/src/hooks/__tests__/useCellSpacing.test.ts +++ b/packages/web/src/hooks/__tests__/useCellSpacing.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { innerDefaults, outerDefaults, useCellSpacing } from '../useCellSpacing'; diff --git a/packages/web/src/hooks/__tests__/useDimensions.test.ts b/packages/web/src/hooks/__tests__/useDimensions.test.ts index 2c045cb83a..7e2da23256 100644 --- a/packages/web/src/hooks/__tests__/useDimensions.test.ts +++ b/packages/web/src/hooks/__tests__/useDimensions.test.ts @@ -1,5 +1,5 @@ import { act } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { Event as ResizeEvent, Options } from '../useDimensions'; import { borderBoxWarn, observerErr, useDimensions } from '../useDimensions'; diff --git a/packages/web/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts b/packages/web/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts index 6c4f778250..4402e01d9c 100644 --- a/packages/web/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts +++ b/packages/web/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts @@ -1,4 +1,5 @@ -import { act, renderHook } from '@testing-library/react-hooks'; +import { act } from 'react'; +import { renderHook } from '@testing-library/react'; import throttle from 'lodash/throttle'; import { useHorizontalScrollToTarget } from '../useHorizontalScrollToTarget'; @@ -96,7 +97,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 5 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 10; throttledFn(); @@ -110,7 +110,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 5 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 490; // Near max scroll (maxScroll = 500, so 490 < 495 = true) throttledFn(); @@ -124,7 +123,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 5 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 0; throttledFn(); @@ -138,7 +136,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 5 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 500; // Max scroll throttledFn(); @@ -152,7 +149,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 10 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 5; // Below threshold throttledFn(); @@ -165,7 +161,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = null; throttledFn(); }); @@ -208,7 +203,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; result.current.handleScroll(); }); @@ -226,7 +220,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 100; Object.defineProperty(mockActiveTarget, 'offsetLeft', { value: 50, writable: true }); // Offscreen left @@ -249,7 +242,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 0; Object.defineProperty(mockActiveTarget, 'offsetLeft', { value: 600, writable: true }); // Offscreen right @@ -272,7 +264,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 100; Object.defineProperty(mockActiveTarget, 'offsetLeft', { value: 200, writable: true }); // Visible @@ -297,7 +288,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 100; Object.defineProperty(mockActiveTarget, 'offsetLeft', { value: 50, writable: true }); @@ -325,7 +315,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; mockScrollElement.scrollLeft = 100; Object.defineProperty(mockActiveTarget, 'offsetLeft', { value: 10, writable: true }); @@ -344,7 +333,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ activeTarget: null })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; }); @@ -357,7 +345,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = null; }); @@ -370,7 +357,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; Object.defineProperty(mockScrollElement, 'scrollWidth', { value: 500 }); Object.defineProperty(mockScrollElement, 'clientWidth', { value: 500 }); @@ -386,7 +372,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollElement; Object.defineProperty(mockScrollElement, 'scrollWidth', { value: 300 }); Object.defineProperty(mockScrollElement, 'clientWidth', { value: 500 }); diff --git a/packages/web/src/hooks/__tests__/useIsBrowser.test.ts b/packages/web/src/hooks/__tests__/useIsBrowser.test.ts index b2c960ff11..43fee4e5d2 100644 --- a/packages/web/src/hooks/__tests__/useIsBrowser.test.ts +++ b/packages/web/src/hooks/__tests__/useIsBrowser.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useIsBrowser } from '../useIsBrowser'; diff --git a/packages/web/src/hooks/__tests__/useMediaQuery.test.tsx b/packages/web/src/hooks/__tests__/useMediaQuery.test.tsx index 606cb261bc..48421acefb 100644 --- a/packages/web/src/hooks/__tests__/useMediaQuery.test.tsx +++ b/packages/web/src/hooks/__tests__/useMediaQuery.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { ReactNode } from 'react'; import { isDevelopment } from '@coinbase/cds-utils'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { MediaQueryProvider } from '../../system/MediaQueryProvider'; import { useMediaQuery } from '../useMediaQuery'; @@ -38,8 +38,9 @@ describe('useMediaQuery', () => { }); it('throws error when used outside MediaQueryProvider', () => { - const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); - expect(() => result.current).toThrow('useMediaQuery must be used within a MediaQueryProvider'); + expect(() => renderHook(() => useMediaQuery('(min-width: 768px)'))).toThrow( + 'useMediaQuery must be used within a MediaQueryProvider', + ); }); it('warns about complex queries in development', () => { mockMatchMedia(true); @@ -67,12 +68,8 @@ describe('useMediaQuery', () => { const initialQuery = '(min-width: 768px)'; const updatedQuery = '(max-width: 500px)'; - const DynamicWrapper: React.FunctionComponent<{ children?: ReactNode; query: string }> = ({ - children, - }) => {children}; - const { result, rerender } = renderHook(({ query }) => useMediaQuery(query), { - wrapper: DynamicWrapper, + wrapper: ({ children }) => {children}, initialProps: { query: initialQuery }, }); diff --git a/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx b/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx new file mode 100644 index 0000000000..d289c66f3c --- /dev/null +++ b/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; + +import { media } from '../../styles/media'; +import { MediaQueryContext } from '../../system/MediaQueryProvider'; +import { useResolveResponsiveProp } from '../useResolveResponsiveProp'; + +const createMediaContext = (width: number) => ({ + subscribe: () => () => {}, + getServerSnapshot: (query: string) => { + if (query === media.phone) return width <= 767; + if (query === media.tablet) return width >= 768 && width <= 1279; + if (query === media.desktop) return width >= 1280; + return false; + }, + getSnapshot: (query: string) => { + if (query === media.phone) return width <= 767; + if (query === media.tablet) return width >= 768 && width <= 1279; + if (query === media.desktop) return width >= 1280; + return false; + }, +}); + +const responsiveValue = { + base: 'base', + phone: 'phone', + tablet: 'tablet', + desktop: 'desktop', +} as const; + +const wrapper = (width: number) => + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; + +describe('useResolveResponsiveProp', () => { + it('returns scalar value unchanged', () => { + const { result } = renderHook(() => useResolveResponsiveProp('scalar'), { + wrapper: wrapper(500), + }); + expect(result.current).toBe('scalar'); + }); + + it('returns undefined for undefined input', () => { + const { result } = renderHook(() => useResolveResponsiveProp(undefined), { + wrapper: wrapper(500), + }); + expect(result.current).toBeUndefined(); + }); + + it.each([ + ['phone', 500, 'phone'], + ['tablet', 900, 'tablet'], + ['desktop', 1400, 'desktop'], + ])('resolves responsive object for %s viewport', (_, width, expected) => { + const { result } = renderHook(() => useResolveResponsiveProp(responsiveValue), { + wrapper: wrapper(width), + }); + expect(result.current).toBe(expected); + }); + + it('falls back to base when outside MediaQueryProvider', () => { + const { result } = renderHook(() => useResolveResponsiveProp(responsiveValue)); + expect(result.current).toBe('base'); + }); +}); diff --git a/packages/web/src/hooks/useDimensions.ts b/packages/web/src/hooks/useDimensions.ts index b65f04a23e..0fdf835d34 100644 --- a/packages/web/src/hooks/useDimensions.ts +++ b/packages/web/src/hooks/useDimensions.ts @@ -28,7 +28,7 @@ type ShouldUpdate = { type Breakpoints = Record; export type Options = { - ref?: RefObject | null; + ref?: RefObject | null; useBorderBoxSize?: boolean; breakpoints?: Breakpoints; updateOnBreakpointChange?: boolean; @@ -38,7 +38,7 @@ export type Options = { polyfill?: any; }; type Return = { - ref: RefObject; + ref: RefObject; entry?: ResizeObserverEntry; } & Omit, 'entry'>; @@ -105,7 +105,7 @@ export const useDimensions = ({ x?: number; y?: number; }>({}); - const prevBreakpointRef = useRef(); + const prevBreakpointRef = useRef(undefined); const observerRef = useRef(null); const onResizeRef = useRef | null>(null); const shouldUpdateRef = useRef(null); diff --git a/packages/web/src/hooks/useResolveResponsiveProp.ts b/packages/web/src/hooks/useResolveResponsiveProp.ts new file mode 100644 index 0000000000..e144b36374 --- /dev/null +++ b/packages/web/src/hooks/useResolveResponsiveProp.ts @@ -0,0 +1,39 @@ +import { useContext } from 'react'; + +import { media } from '../styles/media'; +import type { ResponsiveProp, ResponsiveValue } from '../styles/styleProps'; +import { MediaQueryContext } from '../system/MediaQueryProvider'; + +const isResponsiveValue = (value: ResponsiveProp): value is ResponsiveValue => + typeof value === 'object' && + value !== null && + ('base' in value || 'phone' in value || 'tablet' in value || 'desktop' in value); + +/** + * Resolves a ResponsiveProp to a single value based on the current viewport. + * + * Use this when you need the resolved value in JavaScript (e.g., passing to a child + * component or using in conditional logic). For applying responsive styles via CSS, + * use getStyles from styleProps instead—it handles responsive objects via + * media-query CSS variables. + * + * Reads getSnapshot from MediaQueryContext when within MediaQueryProvider. + * Without it, returns the first defined value (base ?? phone ?? tablet ?? desktop). + * + * @param value - A scalar value or responsive object with base/phone/tablet/desktop keys + * @returns The resolved value for the current breakpoint + */ +export const useResolveResponsiveProp = ( + value: ResponsiveProp | undefined, +): T | undefined => { + const context = useContext(MediaQueryContext); + const getSnapshot = context?.getSnapshot; + + if (!value || !isResponsiveValue(value)) return value; + const fallback = value.base ?? value.phone ?? value.tablet ?? value.desktop; + if (!getSnapshot) return fallback; + if (typeof value.phone !== 'undefined' && getSnapshot(media.phone)) return value.phone; + if (typeof value.tablet !== 'undefined' && getSnapshot(media.tablet)) return value.tablet; + if (typeof value.desktop !== 'undefined' && getSnapshot(media.desktop)) return value.desktop; + return fallback; +}; diff --git a/packages/web/src/icons/Icon.tsx b/packages/web/src/icons/Icon.tsx index 72294bb691..29698c7853 100644 --- a/packages/web/src/icons/Icon.tsx +++ b/packages/web/src/icons/Icon.tsx @@ -39,7 +39,10 @@ export type IconBaseProps = SharedProps & * @default false */ active?: boolean; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style`, `styles.root`, `className`, `classNames.root`, or the `color` prop to customize icon color. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: string; }; diff --git a/packages/web/src/layout/Box.tsx b/packages/web/src/layout/Box.tsx index 784c1b28fe..b43058f51d 100644 --- a/packages/web/src/layout/Box.tsx +++ b/packages/web/src/layout/Box.tsx @@ -37,7 +37,10 @@ export type BoxBaseProps = StyleProps & borderedHorizontal?: boolean; /** Add a border to the top and bottom sides of the box. */ borderedVertical?: boolean; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style` or `className` to set custom background colors. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetBackground?: string; }; diff --git a/packages/web/src/layout/Grid.tsx b/packages/web/src/layout/Grid.tsx index 21c92a697b..db3615cb4b 100644 --- a/packages/web/src/layout/Grid.tsx +++ b/packages/web/src/layout/Grid.tsx @@ -1,5 +1,4 @@ import React, { forwardRef, useMemo } from 'react'; -import { type DimensionValue } from '@coinbase/cds-common/types/DimensionStyles'; import type { Polymorphic } from '../core/polymorphism'; @@ -34,7 +33,7 @@ export type GridBaseProps = Polymorphic.ExtendableProps< * Grid can take a minimum column dimension that will clamp it to be no less than the value * @note `columnMin` cannot be used in conjunction with `columns` or `templateColumns` */ - columnMin?: DimensionValue; + columnMin?: React.CSSProperties['minWidth']; /** * if neither `columns` or `templateColumns` are declared, Grid will implicitly lay out grid lines based on available space * You can cap the maximum width of each column by passing `columnMax` @@ -44,7 +43,7 @@ export type GridBaseProps = Polymorphic.ExtendableProps< * @default 1fr * @note `columnMax` cannot be used in conjunction with `columns` or `templateColumns` */ - columnMax?: DimensionValue; + columnMax?: React.CSSProperties['maxWidth']; } >; diff --git a/packages/web/src/layout/Group.tsx b/packages/web/src/layout/Group.tsx index 205b61155b..db753cb097 100644 --- a/packages/web/src/layout/Group.tsx +++ b/packages/web/src/layout/Group.tsx @@ -32,11 +32,11 @@ export type GroupBaseProps = Omit & { */ renderItem?: (info: { Wrapper: React.ComponentType>; - item: React.ReactChild; + item: React.ReactElement | number | string; index: number; isFirst: boolean; isLast: boolean; - }) => React.ReactChild; + }) => React.ReactElement | number | string; }; export type RenderGroupItem = GroupBaseProps>['renderItem']; @@ -50,7 +50,7 @@ const fallbackRenderItem: RenderGroupItem = ({ item, index, }: { - item: React.ReactChild; + item: React.ReactElement | number | string; index: number; }) => { return {item}; diff --git a/packages/web/src/layout/__stories__/Divider.stories.tsx b/packages/web/src/layout/__stories__/Divider.stories.tsx index 2af95954d6..e621d9b16d 100644 --- a/packages/web/src/layout/__stories__/Divider.stories.tsx +++ b/packages/web/src/layout/__stories__/Divider.stories.tsx @@ -1,11 +1,12 @@ -import type { ComponentStory } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Box } from '../Box'; +import type { DividerProps } from '../Divider'; import { Divider } from '../Divider'; import { HStack } from '../HStack'; import { VStack } from '../VStack'; -export default { +const meta = { title: 'Components/Divider', component: Divider, argTypes: { @@ -14,46 +15,52 @@ export default { control: { type: 'radio' }, }, }, -}; + render: ({ direction, ...rest }: DividerProps) => { + if (direction === 'horizontal') { + return ( + + + + + + ); + } -const Template: ComponentStory = ({ direction, ...rest }) => { - if (direction === 'horizontal') { return ( - - + + - - + + ); - } - - return ( - - - - - - ); -}; + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; -export const HorizontalDirection = Template.bind({}); -HorizontalDirection.args = { - direction: 'horizontal', +export const HorizontalDirection: Story = { + args: { + direction: 'horizontal', + }, }; -export const VerticalDirection = Template.bind({}); -VerticalDirection.args = { - direction: 'vertical', +export const VerticalDirection: Story = { + args: { + direction: 'vertical', + }, }; -export const LightColor = Template.bind({}); -LightColor.args = { - direction: 'horizontal', - color: 'bgLine', +export const LightColor: Story = { + args: { + direction: 'horizontal', + color: 'bgLine', + }, }; -export const HeavyColor = Template.bind({}); -HeavyColor.args = { - direction: 'horizontal', - color: 'bgLineHeavy', +export const HeavyColor: Story = { + args: { + direction: 'horizontal', + color: 'bgLineHeavy', + }, }; diff --git a/packages/web/src/layout/__stories__/Responsive.stories.tsx b/packages/web/src/layout/__stories__/Responsive.stories.tsx index 606c9026f9..7726f96598 100644 --- a/packages/web/src/layout/__stories__/Responsive.stories.tsx +++ b/packages/web/src/layout/__stories__/Responsive.stories.tsx @@ -120,7 +120,7 @@ export const ResponsiveCard = () => { + } avatar="https://images.ctfassets.net/q5ulk4bp65r7/3rv8jr1B1Z1dZ2EhHqo7dp/e74ddbf1cd4836b83d34fe5cec351d78/Alt-Coin.png?w=768&fm=png" description="Earn crypto" diff --git a/packages/web/src/loaders/Spinner.tsx b/packages/web/src/loaders/Spinner.tsx index 3718329db6..1ba52624f8 100644 --- a/packages/web/src/loaders/Spinner.tsx +++ b/packages/web/src/loaders/Spinner.tsx @@ -8,7 +8,8 @@ const COMPONENT_STATIC_CLASSNAME = 'cds-Spinner'; export type SpinnerBaseProps = { /** - * The font size of the spinner in pixels - used to calculate the width, height, and borderWidth. Width and height are 10em while borderWidth is 1.1em. + * The font size of the spinner in pixels - used to calculate the width, height, and borderWidth. + * Width and height are 10em while borderWidth is 1.1em. */ size: number; }; diff --git a/packages/web/src/media/Avatar.tsx b/packages/web/src/media/Avatar.tsx index 66221fdfc3..0b9362b1c2 100644 --- a/packages/web/src/media/Avatar.tsx +++ b/packages/web/src/media/Avatar.tsx @@ -174,7 +174,7 @@ export const Avatar = memo( { id={hexagonAvatarClipId} transform={`scale(${1 / viewBoxSize} ${1 / viewBoxSize})`} > - + @@ -109,7 +110,7 @@ export const HexagonBorder = memo( + ).props.shape; // dynamically apply uniform sizing and shape to all RemoteImage children elements const clonedChild = React.cloneElement(child as React.ReactElement, { diff --git a/packages/web/src/motion/__tests__/Pulse.test.tsx b/packages/web/src/motion/__tests__/Pulse.test.tsx index e681833da0..8bae8fe6ea 100644 --- a/packages/web/src/motion/__tests__/Pulse.test.tsx +++ b/packages/web/src/motion/__tests__/Pulse.test.tsx @@ -68,7 +68,7 @@ describe('Pulse', () => { const ref = { current: null } as React.RefObject<{ play: () => Promise; stop: () => Promise; - }>; + } | null>; render(
Children
diff --git a/packages/web/src/motion/__tests__/Shake.test.tsx b/packages/web/src/motion/__tests__/Shake.test.tsx index cd0fee8ec2..6476aed83a 100644 --- a/packages/web/src/motion/__tests__/Shake.test.tsx +++ b/packages/web/src/motion/__tests__/Shake.test.tsx @@ -67,7 +67,7 @@ describe('Shake', () => { it('exposes imperative handlers that start the animation', () => { const ref = { current: null } as React.RefObject<{ play: () => Promise; - }>; + } | null>; render(
Children
diff --git a/packages/web/src/motion/__tests__/useMotionProps.test.tsx b/packages/web/src/motion/__tests__/useMotionProps.test.tsx index bd173bdf77..04dcf2cee4 100644 --- a/packages/web/src/motion/__tests__/useMotionProps.test.tsx +++ b/packages/web/src/motion/__tests__/useMotionProps.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { MotionConfigs } from '../types'; import { useMotionProps } from '../useMotionProps'; diff --git a/packages/web/src/navigation/NavigationTitleSelect.tsx b/packages/web/src/navigation/NavigationTitleSelect.tsx index 76a9ef1ab3..4055fb7b2a 100644 --- a/packages/web/src/navigation/NavigationTitleSelect.tsx +++ b/packages/web/src/navigation/NavigationTitleSelect.tsx @@ -1,6 +1,7 @@ import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { selectCellSpacingConfig } from '@coinbase/cds-common/tokens/select'; -import { SelectOption } from '../controls/SelectOption'; +import { Cell } from '../cells/Cell'; import { Dropdown } from '../dropdown/Dropdown'; import type { DropdownRef } from '../dropdown/DropdownProps'; import { useA11yControlledVisibility } from '../hooks/useA11yControlledVisibility'; @@ -44,11 +45,28 @@ export const NavigationTitleSelect = memo( setVisible(true); }, []); + const handleOptionClick = useCallback( + (id: string) => { + onChange(id); + dropdownRef.current?.closeMenu(); + }, + [onChange], + ); + const dropdownContent = useMemo(() => { - return options.map((option) => ( - + return options.map(({ id, label: title }) => ( + handleOptionClick(id)} + selected={value === id} + {...selectCellSpacingConfig} + > + + {title} + + )); - }, [options]); + }, [handleOptionClick, options, value]); const label = useMemo(() => { return options.find((option) => option.id === value)?.label; diff --git a/packages/web/src/navigation/SidebarItem.tsx b/packages/web/src/navigation/SidebarItem.tsx index 36d357dadc..b5970dc784 100644 --- a/packages/web/src/navigation/SidebarItem.tsx +++ b/packages/web/src/navigation/SidebarItem.tsx @@ -2,11 +2,13 @@ import React, { forwardRef, memo, useMemo } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { css } from '@linaria/core'; +import { cx } from '../cx'; import { Icon, type IconProps } from '../icons'; import { Box } from '../layout'; import { Tooltip } from '../overlays/tooltip/Tooltip'; import type { TooltipProps } from '../overlays/tooltip/TooltipProps'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; +import type { StylesAndClassNames } from '../types'; import { Text } from '../typography'; import { useSidebarContext } from './SidebarContext'; @@ -29,6 +31,21 @@ type ManagedPressableProps = Pick< 'background' | 'width' | 'transparentWhileInactive' | 'className' | 'borderWidth' >; +/** + * Static class names for SidebarItem component parts. + * Use these selectors to target specific elements with CSS. + */ +export const sidebarItemClassNames = { + /** Persistent outer wrapper across tooltip/non-tooltip variants. */ + root: 'cds-SidebarItem', + /** Default content wrapper element. */ + content: 'cds-SidebarItem-content', + /** Icon wrapper element. */ + icon: 'cds-SidebarItem-icon', + /** Title text element. */ + title: 'cds-SidebarItem-title', +} as const; + export type SidebarItemProps = { /** * The Navigation Icon this item represents @@ -59,7 +76,10 @@ export type SidebarItemProps = { * The component must implement the CustomSidebarItemProps props interface */ Component?: React.ElementType; -} & Omit, keyof ManagedPressableProps> & + className?: string; + style?: React.CSSProperties; +} & StylesAndClassNames & + Omit, keyof ManagedPressableProps> & Pick; export const SidebarItem = memo( @@ -75,6 +95,10 @@ export const SidebarItem = memo( borderRadius, accessibilityLabel = title, Component, + className, + style, + classNames, + styles, ...pressableProps }: SidebarItemProps, ref: React.ForwardedRef, @@ -87,20 +111,31 @@ export const SidebarItem = memo( () => ( - + {(variant === 'condensed' || !isCollapsed) && ( {title} @@ -108,22 +143,36 @@ export const SidebarItem = memo( )} ), - [active, color, icon, isCollapsed, isDefaultVariant, title, variant], + [ + active, + color, + icon, + isCollapsed, + isDefaultVariant, + title, + variant, + classNames?.content, + classNames?.icon, + classNames?.title, + styles?.content, + styles?.icon, + styles?.title, + ], ); const content = useMemo( () => ( {Component ? ( - {content} - - ) : ( - content + return ( + + {tooltipContent && isCollapsed ? ( + + {content} + + ) : ( + content + )} + ); }, ), diff --git a/packages/web/src/navigation/__tests__/SidebarItem.test.tsx b/packages/web/src/navigation/__tests__/SidebarItem.test.tsx index 8327eb1d5d..6bfe385d68 100644 --- a/packages/web/src/navigation/__tests__/SidebarItem.test.tsx +++ b/packages/web/src/navigation/__tests__/SidebarItem.test.tsx @@ -86,4 +86,34 @@ describe('SidebarItem', () => { // super brittle way to test that a linaria class was applied for borderRadius style prop value of 0 expect(screen.getAllByTestId('sidebar-item')[0].className).toContain('_0-'); }); + + it('applies classNames to root and slots', () => { + render( + + + + + + , + ); + + expect( + screen.getByTestId('sidebar-item-with-classnames').closest('.test-sidebar-root'), + ).toBeTruthy(); + expect( + screen.getByTestId('sidebar-item-with-classnames').closest('.cds-SidebarItem'), + ).toBeTruthy(); + expect(screen.getByText('Assets').className).toContain('test-sidebar-title'); + expect(screen.getByText('Assets').className).toContain('cds-SidebarItem-title'); + }); }); diff --git a/packages/web/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx b/packages/web/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx index a02dad4a9c..18f9b945af 100644 --- a/packages/web/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx +++ b/packages/web/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx @@ -145,7 +145,9 @@ export const DefaultRollingNumberDigit: RollingNumberDigitComponent = memo( (digit: number) => ( void (numberRefs.current[digit] = r)} + ref={(r) => { + void (numberRefs.current[digit] = r); + }} className={digitSpanCss} > {digit} diff --git a/packages/web/src/numbers/RollingNumber/RollingNumber.tsx b/packages/web/src/numbers/RollingNumber/RollingNumber.tsx index cb0d905db3..2f560bae4c 100644 --- a/packages/web/src/numbers/RollingNumber/RollingNumber.tsx +++ b/packages/web/src/numbers/RollingNumber/RollingNumber.tsx @@ -715,12 +715,15 @@ export const RollingNumber: RollingNumberComponent = memo( > {/* render screen reader only section for accessibility */} {screenReaderOnlySection} + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} )} > {prefixSection} {formattedValue ? formattedValueValueSection : intlPartsValueSection} diff --git a/packages/web/src/overlays/Alert.tsx b/packages/web/src/overlays/Alert.tsx index fb66956dcb..dd2e988703 100644 --- a/packages/web/src/overlays/Alert.tsx +++ b/packages/web/src/overlays/Alert.tsx @@ -2,7 +2,6 @@ import { forwardRef, memo, useCallback, useMemo } from 'react'; import type { ButtonVariant, IllustrationPictogramNames, - PositionStyles, SharedProps, ValidateProps, } from '@coinbase/cds-common/types'; @@ -11,6 +10,7 @@ import { Button } from '../buttons'; import { useA11yLabels } from '../hooks/useA11yLabels'; import { Pictogram } from '../illustrations'; import { Box } from '../layout/Box'; +import type { PositionStyles } from '../styles/styleProps'; import { Text } from '../typography/Text'; import { Modal, type ModalBaseProps, type ModalRefBaseProps } from './modal/Modal'; @@ -194,9 +194,9 @@ export const Alert = memo(
{dismissAction} diff --git a/packages/web/src/overlays/FocusTrap.tsx b/packages/web/src/overlays/FocusTrap.tsx index 56238fa1b9..987e743633 100644 --- a/packages/web/src/overlays/FocusTrap.tsx +++ b/packages/web/src/overlays/FocusTrap.tsx @@ -1,13 +1,13 @@ import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import type { ReactElement, RefObject } from 'react'; -import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; import { FOCUSABLE_ELEMENTS } from '@coinbase/cds-common/tokens/overlays'; import { debounce } from '@coinbase/cds-common/utils/debounce'; +import { mergeReactElementRef } from '@coinbase/cds-common/utils/mergeRefs'; import { getBrowserGlobals } from '../utils/browser'; export type FocusTrapProps = { - children: ReactElement & { ref?: React.Ref }; + children: ReactElement; onEscPress?: () => void; /** * Use for editable Search Input components to ensure focus is correctly applied @@ -140,7 +140,7 @@ export const FocusTrap = memo(function FocusTrap({ // trap focus for accessibility const handleKeyboardNavigation = useCallback( - (event: KeyboardEvent, element: RefObject['current']) => { + (event: KeyboardEvent, element: RefObject['current']) => { if (event.defaultPrevented) return; const document = getBrowserGlobals()?.document; const activeElement = document?.activeElement as HTMLElement; @@ -382,11 +382,11 @@ export const FocusTrap = memo(function FocusTrap({ // only works for single child const onlyChild = React.Children.only(children); - const mergedRef = useMergeRefs(childrenRef, children?.ref); - if (!onlyChild) { return <>{children}; } - return React.cloneElement(children, { ref: mergedRef }); + return React.cloneElement(children as React.ReactElement>, { + ref: mergeReactElementRef(children, childrenRef), + }); }); diff --git a/packages/web/src/overlays/Portal.tsx b/packages/web/src/overlays/Portal.tsx index 98c51773d1..2259434c41 100644 --- a/packages/web/src/overlays/Portal.tsx +++ b/packages/web/src/overlays/Portal.tsx @@ -6,17 +6,19 @@ import { ThemeProvider } from '../system/ThemeProvider'; import { isSSR } from '../utils/browser'; export type PortalProps = { - /** - * Disable React portal integration - */ + /** When true, renders children in place without creating a React portal. */ disablePortal?: boolean; - /** - * Portal container element id - */ + /** The DOM element ID to render the portal content into. */ containerId?: string; children: React.ReactNode; }; +/** + * Internal component used by CDS overlay components (Modal, Toast, Alert, etc.) to + * render content into the container elements created by PortalProvider. Wraps + * `createPortal` and automatically re-establishes the current theme in the portal's + * DOM tree via an isolated ThemeProvider. + */ export const Portal = memo(function Portal({ disablePortal, children, @@ -29,7 +31,7 @@ export const Portal = memo(function Portal({ } return createPortal( - + {children} , document.getElementById(containerId) as HTMLElement, diff --git a/packages/web/src/overlays/PortalProvider.tsx b/packages/web/src/overlays/PortalProvider.tsx index 9b268e412d..435b5fb943 100644 --- a/packages/web/src/overlays/PortalProvider.tsx +++ b/packages/web/src/overlays/PortalProvider.tsx @@ -29,6 +29,11 @@ export const trayContainerId = 'trayContainer'; const safeDocument = getBrowserGlobals()?.document; +/** + * Internal component that creates the DOM container elements for overlay components. + * Appends a root div to `document.body` containing separate containers for each overlay + * type (modals, toasts, alerts, tooltips, trays), each with its own z-index layer. + */ export const PortalHost: React.FC = memo(() => { const portalRoot = useMemo( // prevent duplicate portal root @@ -94,6 +99,13 @@ export const PortalHost: React.FC = memo(() => { ); }); +/** + * Required root-level provider that enables CDS overlay components (Modal, Toast, Alert, + * Tooltip, Tray). Creates the DOM containers these components render into via React portals + * and provides the context for managing overlay state and toast queuing. + * + * Must be rendered once near the root of your application, alongside ThemeProvider. + */ export const PortalProvider: React.FC> = memo( ({ children, toastBottomOffset = 0, renderPortals = true }) => { const portalState = usePortalState(); @@ -117,6 +129,11 @@ export const PortalProvider: React.FC { const { nodes } = usePortal(); return ( diff --git a/packages/web/src/overlays/Toast.tsx b/packages/web/src/overlays/Toast.tsx index b1690af83d..2a198017a6 100644 --- a/packages/web/src/overlays/Toast.tsx +++ b/packages/web/src/overlays/Toast.tsx @@ -122,7 +122,14 @@ export const Toast = memo( return ( - + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} + )} + > )} diff --git a/packages/web/src/overlays/__stories__/FullscreenModal.stories.tsx b/packages/web/src/overlays/__stories__/FullscreenModal.stories.tsx index f93001de38..79d13bafa0 100644 --- a/packages/web/src/overlays/__stories__/FullscreenModal.stories.tsx +++ b/packages/web/src/overlays/__stories__/FullscreenModal.stories.tsx @@ -154,7 +154,7 @@ export const Example = () => { description="Amp is an Ethereum token that can be used as collateral to provide instant settlement assurance any time value is transferred." headerAction={{ name: 'more', - variant: 'foregroundMuted', + variant: 'secondary', }} image="https://images.ctfassets.net/q5ulk4bp65r7/3rv8jr1B1Z1dZ2EhHqo7dp/e74ddbf1cd4836b83d34fe5cec351d78/Alt-Coin.png?w=768&fm=png" mediaPlacement="above" diff --git a/packages/web/src/overlays/__stories__/FullscreenModalLayout.stories.tsx b/packages/web/src/overlays/__stories__/FullscreenModalLayout.stories.tsx index aab75612e4..9cb6a4c29e 100644 --- a/packages/web/src/overlays/__stories__/FullscreenModalLayout.stories.tsx +++ b/packages/web/src/overlays/__stories__/FullscreenModalLayout.stories.tsx @@ -282,7 +282,7 @@ export const Example = () => { description="Amp is an Ethereum token that can be used as collateral to provide instant settlement assurance any time value is transferred." headerAction={{ name: 'more', - variant: 'foregroundMuted', + variant: 'secondary', }} image="https://images.ctfassets.net/q5ulk4bp65r7/3rv8jr1B1Z1dZ2EhHqo7dp/e74ddbf1cd4836b83d34fe5cec351d78/Alt-Coin.png?w=768&fm=png" mediaPlacement="above" diff --git a/packages/web/src/overlays/__stories__/Modal.stories.tsx b/packages/web/src/overlays/__stories__/Modal.stories.tsx index 5a7ab2023a..8910f1a9b1 100644 --- a/packages/web/src/overlays/__stories__/Modal.stories.tsx +++ b/packages/web/src/overlays/__stories__/Modal.stories.tsx @@ -23,7 +23,7 @@ export default { }; type ModalA11yProps = { - triggerRef?: React.RefObject; + triggerRef?: React.RefObject; enableBackButton?: boolean; visible?: boolean; }; diff --git a/packages/web/src/overlays/__stories__/ModalInteractive.stories.tsx b/packages/web/src/overlays/__stories__/ModalInteractive.stories.tsx index 4ae0fb06f4..f3863d90e3 100644 --- a/packages/web/src/overlays/__stories__/ModalInteractive.stories.tsx +++ b/packages/web/src/overlays/__stories__/ModalInteractive.stories.tsx @@ -8,7 +8,7 @@ import { ModalFooter } from '../modal/ModalFooter'; import { ModalHeader } from '../modal/ModalHeader'; type ModalA11yProps = { - triggerRef?: React.RefObject; + triggerRef?: React.RefObject; focusTrigger?: () => void; accessibilityLabelledBy?: string; accessibilityLabel?: string; diff --git a/packages/web/src/overlays/__stories__/Tooltip.stories.tsx b/packages/web/src/overlays/__stories__/Tooltip.stories.tsx index 2c35d22168..2a39f8eecf 100644 --- a/packages/web/src/overlays/__stories__/Tooltip.stories.tsx +++ b/packages/web/src/overlays/__stories__/Tooltip.stories.tsx @@ -1,9 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { assets } from '@coinbase/cds-common/internal/data/assets'; -import type { - ComponentMeta, - ComponentStory, -} from '@storybook/react/dist/ts3.9/client/preview/types-6-3'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../../buttons/Button'; import { IconButton } from '../../buttons/IconButton'; @@ -16,13 +13,15 @@ import { PortalProvider } from '../PortalProvider'; import { Tooltip } from '../tooltip/Tooltip'; import type { TooltipProps } from '../tooltip/TooltipProps'; -export default { +const meta: Meta = { title: 'Components/Tooltip/Tooltip', component: Tooltip, parameters: { layout: 'padded', }, -} as ComponentMeta; +}; + +export default meta; type BasicTooltipProps = { content: TooltipProps['content']; @@ -195,23 +194,23 @@ const BasicTooltip = ({ content, openDelay, closeDelay }: BasicTooltipProps) => ); }; -const Template: ComponentStory = (args: BasicTooltipProps) => ( - -); - -export const Default = Template.bind({}); - -Default.args = { - content: 'This is the tooltip Content', -}; - -export const TooltipLongContent = Template.bind({}); +type Story = StoryObj; const longContent = 'This is the tooltip Content. This is just really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really Long.'; -TooltipLongContent.args = { - content: longContent, +export const Default: Story = { + render: (args) => , + args: { + content: 'This is the tooltip Content', + }, +}; + +export const TooltipLongContent: Story = { + render: (args) => , + args: { + content: longContent, + }, }; export const DelayedVisibility = ({ diff --git a/packages/web/src/overlays/__stories__/TooltipContent.stories.tsx b/packages/web/src/overlays/__stories__/TooltipContent.stories.tsx index 5b09fad87e..c632b9c293 100644 --- a/packages/web/src/overlays/__stories__/TooltipContent.stories.tsx +++ b/packages/web/src/overlays/__stories__/TooltipContent.stories.tsx @@ -1,8 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import type { - ComponentMeta, - ComponentStory, -} from '@storybook/react/dist/ts3.9/client/preview/types-6-3'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../../buttons/Button'; import { HStack } from '../../layout/HStack'; @@ -11,13 +8,16 @@ import { PortalProvider } from '../PortalProvider'; import { TooltipContent } from '../tooltip/TooltipContent'; import type { PopperTooltipProps } from '../tooltip/TooltipProps'; -export default { +const meta: Meta = { title: 'Components/Tooltip/TooltipContent', component: TooltipContent, parameters: { layout: 'padded', }, -} as ComponentMeta; +}; + +export default meta; +type Story = StoryObj; const BasicTooltipContent = ({ content }: PopperTooltipProps) => { const ref = useRef(null); @@ -39,36 +39,12 @@ const BasicTooltipContent = ({ content }: PopperTooltipProps) => { ); }; -const Template: ComponentStory = (args: PopperTooltipProps) => ( - -); - -export const Default = Template.bind({}); - -Default.args = { - content: 'This is the tooltip Content', -}; - -export const TooltipLongContent = Template.bind({}); - const longContent = 'This is the tooltip Content. This is just really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really Long.'; -TooltipLongContent.args = { - content: longContent, -}; - -export const TooltipLongWordContent = Template.bind({}); - const longWordContent = 'ThisisReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyLongWordContent. This is just really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really Long.'; -TooltipLongWordContent.args = { - content: longWordContent, -}; - -export const VStackNodeContent = Template.bind({}); - const VStackNode = ( @@ -77,12 +53,6 @@ const VStackNode = ( ); -VStackNodeContent.args = { - content: VStackNode, -}; - -export const HStackNodeContent = Template.bind({}); - const HStackNode = ( @@ -91,6 +61,37 @@ const HStackNode = ( ); -HStackNodeContent.args = { - content: HStackNode, +export const Default: Story = { + render: (args) => , + args: { + content: 'This is the tooltip Content', + }, +}; + +export const TooltipLongContent: Story = { + render: (args) => , + args: { + content: longContent, + }, +}; + +export const TooltipLongWordContent: Story = { + render: (args) => , + args: { + content: longWordContent, + }, +}; + +export const VStackNodeContent: Story = { + render: (args) => , + args: { + content: VStackNode, + }, +}; + +export const HStackNodeContent: Story = { + render: (args) => , + args: { + content: HStackNode, + }, }; diff --git a/packages/web/src/overlays/__tests__/Alert.test.tsx b/packages/web/src/overlays/__tests__/Alert.test.tsx index 12e589fd7a..396b5d623b 100644 --- a/packages/web/src/overlays/__tests__/Alert.test.tsx +++ b/packages/web/src/overlays/__tests__/Alert.test.tsx @@ -95,8 +95,8 @@ describe('Alert', () => { const modal = screen.getByRole('alertdialog'); expect(modal).toHaveAttribute('aria-modal', 'true'); - expect(modal).toHaveAttribute('aria-labelledby', expect.stringMatching(/:r[0-9].*/)); - expect(screen.getByText(TITLE)).toHaveAttribute('id', expect.stringMatching(/:r[0-9].*/)); + expect(modal).toHaveAttribute('aria-labelledby', expect.stringMatching(/«r[0-9]+»/)); + expect(screen.getByText(TITLE)).toHaveAttribute('id', expect.stringMatching(/«r[0-9]+»/)); }); it('overrides default a11y attrs when accessibilityLabelledBy is provided', () => { diff --git a/packages/web/src/overlays/modal/FullscreenModalLayout.tsx b/packages/web/src/overlays/modal/FullscreenModalLayout.tsx index 9f3813c98f..53c6962446 100644 --- a/packages/web/src/overlays/modal/FullscreenModalLayout.tsx +++ b/packages/web/src/overlays/modal/FullscreenModalLayout.tsx @@ -101,10 +101,17 @@ export const FullscreenModalLayout = memo( visible={visible} zIndex={zIndex} > - + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} + )}> - + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} + )} + > & { /** Component to render as the Modal content */ - children?: React.ReactNode | React.FC; + children?: React.ReactNode | ((props: ModalChildrenRenderProps) => React.ReactNode); /** * Callback fired after the component is closed. */ @@ -98,7 +98,7 @@ export type ModalBaseProps = SharedProps & * Set the position for the modal dialogue * @danger This is a migration escape hatch. It is not intended to be used normally. */ - dangerouslySetPosition?: Position; + dangerouslySetPosition?: React.CSSProperties['position']; /** * If `true`, the focus trap will restore focus to the previously focused element when it unmounts. * diff --git a/packages/web/src/overlays/modal/ModalHeader.tsx b/packages/web/src/overlays/modal/ModalHeader.tsx index c7bfa9d2c7..3caee6e993 100644 --- a/packages/web/src/overlays/modal/ModalHeader.tsx +++ b/packages/web/src/overlays/modal/ModalHeader.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useModalContext } from '@coinbase/cds-common/overlays/ModalContext'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types'; import { IconButton } from '../../buttons/IconButton'; @@ -74,9 +73,10 @@ export const ModalHeader = ({ if (!title && !onBackButtonClick && !onRequestClose) return null; - // use empty placeholder which has the same size as IconButton to maintain horizontal position const emptyPlaceholder = ( - + + + ); return ( diff --git a/packages/web/src/overlays/modal/__tests__/FullscreenModal.test.tsx b/packages/web/src/overlays/modal/__tests__/FullscreenModal.test.tsx index 6f921bcdc9..32f5c3cab1 100644 --- a/packages/web/src/overlays/modal/__tests__/FullscreenModal.test.tsx +++ b/packages/web/src/overlays/modal/__tests__/FullscreenModal.test.tsx @@ -88,8 +88,8 @@ describe('FullscreenModal', () => { const modal = screen.getByRole('dialog'); expect(modal).toHaveAttribute('aria-modal', 'true'); - expect(modal).toHaveAttribute('aria-labelledby', expect.stringMatching(/:r[0-9].*/)); - expect(screen.getByText(TITLE)).toHaveAttribute('id', expect.stringMatching(/:r[0-9].*/)); + expect(modal).toHaveAttribute('aria-labelledby', expect.stringMatching(/«r[0-9]+»/)); + expect(screen.getByText(TITLE)).toHaveAttribute('id', expect.stringMatching(/«r[0-9]+»/)); }); it('overrides default a11y attrs when accessibilityLabelledBy is provided', () => { diff --git a/packages/web/src/overlays/modal/__tests__/Modal.test.tsx b/packages/web/src/overlays/modal/__tests__/Modal.test.tsx index 714b189383..724883748c 100644 --- a/packages/web/src/overlays/modal/__tests__/Modal.test.tsx +++ b/packages/web/src/overlays/modal/__tests__/Modal.test.tsx @@ -77,7 +77,7 @@ const LoremIpsum = ({ title, concise, repeat }: LoremIpsumProps) => { }; type MockModalProps = { - triggerRef?: React.RefObject; + triggerRef?: React.RefObject; focusTrigger?: () => void; onBackButtonClick?: () => void; }; @@ -205,8 +205,8 @@ describe('Modal', () => { const modal = screen.getByRole('dialog'); expect(modal).toHaveAttribute('aria-modal', 'true'); - expect(modal).toHaveAttribute('aria-labelledby', expect.stringMatching(/:r[0-9].*/)); - expect(screen.getByText(TITLE)).toHaveAttribute('id', expect.stringMatching(/:r[0-9].*/)); + expect(modal).toHaveAttribute('aria-labelledby', expect.stringMatching(/«r[0-9]+»/)); + expect(screen.getByText(TITLE)).toHaveAttribute('id', expect.stringMatching(/«r[0-9]+»/)); expect(modal).not.toHaveAttribute('aria-label'); }); diff --git a/packages/web/src/overlays/overlay/OverlayContent.tsx b/packages/web/src/overlays/overlay/OverlayContent.tsx index efd212c20d..bb75d70d68 100644 --- a/packages/web/src/overlays/overlay/OverlayContent.tsx +++ b/packages/web/src/overlays/overlay/OverlayContent.tsx @@ -27,7 +27,7 @@ export const OverlayContent = forwardRef( }); const content = ( - + ); return animated ? ( diff --git a/packages/web/src/overlays/popover/Popover.tsx b/packages/web/src/overlays/popover/Popover.tsx index b0b38d2df9..503f85b2b5 100644 --- a/packages/web/src/overlays/popover/Popover.tsx +++ b/packages/web/src/overlays/popover/Popover.tsx @@ -1,10 +1,21 @@ /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ import React, { memo, useCallback, useMemo } from 'react'; import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; +import { + autoPlacement, + autoUpdate, + flip, + limitShift, + offset, + type Placement as FloatingPlacement, + shift, + useFloating, +} from '@floating-ui/react-dom'; import { css } from '@linaria/core'; import { NewAnimatePresence } from '../../animation/NewAnimatePresence'; import { cx } from '../../cx'; +import { useTheme } from '../../hooks/useTheme'; import { Box } from '../../layout/Box'; import { InvertedThemeProvider } from '../../system/ThemeProvider'; import { FocusTrap } from '../FocusTrap'; @@ -13,7 +24,6 @@ import { Portal } from '../Portal'; import { tooltipContainerId } from '../PortalProvider'; import type { PopoverContentPositionConfig, PopoverProps } from './PopoverProps'; -import { usePopper } from './usePopper'; const subjectCss = css` background-color: transparent; @@ -66,7 +76,47 @@ export const Popover = memo( restoreFocusOnUnmount, controlledElementAccessibilityProps, }: PopoverProps) => { - const { setSubject, setPopper, popperStyles, popperAttributes } = usePopper(contentPosition); + const theme = useTheme(); + const { + placement: rawPlacement = 'bottom', + skid = 0, + gap = 0, + offsetGap, + strategy, + } = contentPosition; + + const computedSkid = theme.space[skid]; + const computedGap = theme.space[gap]; + const getOffsetGap = offsetGap && gap - offsetGap; + + const isAutoPlacement = typeof rawPlacement === 'string' && rawPlacement.startsWith('auto'); + + const middleware = useMemo(() => { + const middlewareList = [ + offset({ + crossAxis: computedSkid, + mainAxis: getOffsetGap ?? computedGap, + }), + ]; + + if (isAutoPlacement) { + const alignment = + rawPlacement === 'auto-start' ? 'start' : rawPlacement === 'auto-end' ? 'end' : undefined; + middlewareList.push(autoPlacement(alignment ? { alignment } : undefined)); + } else { + middlewareList.push(flip()); + middlewareList.push(shift({ crossAxis: true, limiter: limitShift() })); + } + + return middlewareList; + }, [computedSkid, getOffsetGap, computedGap, isAutoPlacement, rawPlacement]); + + const { refs, floatingStyles } = useFloating({ + placement: isAutoPlacement ? undefined : (rawPlacement as FloatingPlacement), + strategy, + middleware, + whileElementsMounted: autoUpdate, + }); // We use this to infer that hover events are triggering the mounting/dismounting of the content const hasHoverInteractions = !!onMouseEnter && !!onMouseLeave && !onPressSubject; @@ -85,13 +135,12 @@ export const Popover = memo( const memoizedContent = useMemo( () => (
- {/* Box with Horizontal padding to ensure proper margins but still rely on popper for layout. */} ), [ - setPopper, - popperStyles.popper, - popperAttributes.popper, + refs.setFloating, + floatingStyles, handleCaptureEvents, autoFocusDelay, disableAutoFocus, @@ -159,7 +206,7 @@ export const Popover = memo( onMouseLeave={disabled ? undefined : onMouseLeave} >
>, { + 'aria-describedby': tooltipId, + }); }, [children, tooltipId]); const contentPosition = useMemo( diff --git a/packages/web/src/overlays/tooltip/TooltipProps.ts b/packages/web/src/overlays/tooltip/TooltipProps.ts index d2eadc6d5f..0007817424 100644 --- a/packages/web/src/overlays/tooltip/TooltipProps.ts +++ b/packages/web/src/overlays/tooltip/TooltipProps.ts @@ -1,6 +1,5 @@ import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { BaseTooltipPlacement, ElevationProps, SharedProps } from '@coinbase/cds-common/types'; -import type { PositionStyles } from '@coinbase/cds-common/types/BoxBaseProps'; import type { PopoverProps } from '../popover/PopoverProps'; @@ -55,7 +54,7 @@ export type TooltipBaseProps = SharedProps & * Typically only used when disablePortal is set to true to adjust zIndex of tooltip. When using portal this value should remain as default. * @default 4 * */ - zIndex?: PositionStyles['zIndex']; + zIndex?: React.CSSProperties['zIndex']; /** * A unique ID used to ensure tooltips are accessible */ diff --git a/packages/web/src/overlays/tooltip/__figma__/Tooltip.figma.tsx b/packages/web/src/overlays/tooltip/__figma__/Tooltip.figma.tsx index 9a2dec0722..4f0968984d 100644 --- a/packages/web/src/overlays/tooltip/__figma__/Tooltip.figma.tsx +++ b/packages/web/src/overlays/tooltip/__figma__/Tooltip.figma.tsx @@ -1,7 +1,7 @@ import { figma } from '@figma/code-connect'; import { Button } from '../../../buttons'; -import { TextBody, TextHeadline } from '../../../typography'; +import { Text } from '../../../typography'; import { Tooltip } from '../Tooltip'; figma.connect( @@ -78,8 +78,12 @@ figma.connect( - {content.title} - {content.description} + + {content.title} + + + {content.description} + } > diff --git a/packages/web/src/overlays/tray/Tray.tsx b/packages/web/src/overlays/tray/Tray.tsx index 6ae8501674..155dbe6f30 100644 --- a/packages/web/src/overlays/tray/Tray.tsx +++ b/packages/web/src/overlays/tray/Tray.tsx @@ -128,7 +128,7 @@ export const trayClassNames = { closeButton: 'cds-Tray-closeButton', } as const; -export type TrayRenderChildren = React.FC<{ handleClose: () => void }>; +export type TrayRenderChildren = (args: { handleClose: () => void }) => React.ReactElement; export type TrayBaseProps = Pick< FocusTrapProps, diff --git a/packages/web/src/overlays/useModal.ts b/packages/web/src/overlays/useModal.ts index c9dd8af227..56c221bf4e 100644 --- a/packages/web/src/overlays/useModal.ts +++ b/packages/web/src/overlays/useModal.ts @@ -1,7 +1,7 @@ import { useModal } from '@coinbase/cds-common/overlays/useModal'; /** - * @deprecated Use the visible and onRequestClose props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started. This will be removed in a future major release. + * @deprecated Use the `visible` and `onRequestClose` props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started. This will be removed in a future major release. * @deprecationExpectedRemoval v7 */ export { useModal }; diff --git a/packages/web/src/page/PageFooter.tsx b/packages/web/src/page/PageFooter.tsx index 3a46801b22..f09e04292d 100644 --- a/packages/web/src/page/PageFooter.tsx +++ b/packages/web/src/page/PageFooter.tsx @@ -1,11 +1,11 @@ import React, { forwardRef, memo } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageFooterHeight } from '@coinbase/cds-common/tokens/page'; -import type { PositionStyles, SharedProps } from '@coinbase/cds-common/types'; +import type { SharedProps } from '@coinbase/cds-common/types'; import type { Polymorphic } from '../core/polymorphism'; import { Box, type BoxDefaultElement, type BoxProps } from '../layout/Box'; -import type { ResponsiveProps, StaticStyleProps } from '../styles/styleProps'; +import type { PositionStyles, ResponsiveProps, StaticStyleProps } from '../styles/styleProps'; export type PageFooterBaseProps = SharedProps & PositionStyles & { diff --git a/packages/web/src/page/PageHeader.tsx b/packages/web/src/page/PageHeader.tsx index ef2aa9bf0b..b64887a248 100644 --- a/packages/web/src/page/PageHeader.tsx +++ b/packages/web/src/page/PageHeader.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, memo, useMemo } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageHeaderHeight } from '@coinbase/cds-common/tokens/page'; -import type { PositionStyles, SharedProps } from '@coinbase/cds-common/types'; +import type { SharedProps } from '@coinbase/cds-common/types'; import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; @@ -9,7 +9,7 @@ import { cx } from '../cx'; import { Box } from '../layout/Box'; import { Grid, type GridDefaultElement, type GridProps } from '../layout/Grid'; import { media } from '../styles/media'; -import type { ResponsiveProps, StaticStyleProps } from '../styles/styleProps'; +import type { PositionStyles, ResponsiveProps, StaticStyleProps } from '../styles/styleProps'; import { Text } from '../typography/Text'; const gridStylesMobileTitleCss = css` diff --git a/packages/web/src/page/__stories__/PageFooter.stories.tsx b/packages/web/src/page/__stories__/PageFooter.stories.tsx index da4f7e5cc0..852c696493 100644 --- a/packages/web/src/page/__stories__/PageFooter.stories.tsx +++ b/packages/web/src/page/__stories__/PageFooter.stories.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { Story } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button, ButtonGroup, IconButton } from '../../buttons'; import { useBreakpoints } from '../../hooks/useBreakpoints'; @@ -70,16 +70,17 @@ const exampleProps = { ), }; -const Template: Story = (args) => ; +type Story = StoryObj; -export const InteractiveFooter = Template.bind({}); - -InteractiveFooter.args = { - background: 'bg', - action: 'endButtons', +export const InteractiveFooter: Story = { + render: (args) => , + args: { + background: 'bg', + action: 'endButtons', + }, }; -export const Examples = () => { +const ExamplesRender = () => { const { isPhone } = useBreakpoints(); const setEndButtonMobile = isPhone ? exampleProps.endButtonsBlock2 : exampleProps.endButtons2; @@ -95,22 +96,25 @@ export const Examples = () => { ); }; -Examples.parameters = { - a11y: { - config: { - /** - * It is expected to include multiple PageFooter with same landmark in this story - * @link https://dequeuniversity.com/rules/axe/4.6/landmark-no-duplicate-contentinfo?application=axeAPI - */ - rules: [ - { id: 'landmark-no-duplicate-contentinfo', enabled: false }, - { id: 'landmark-unique', enabled: false }, - ], +export const Examples: Story = { + render: () => , + parameters: { + a11y: { + config: { + /** + * It is expected to include multiple PageFooter with same landmark in this story + * @link https://dequeuniversity.com/rules/axe/4.6/landmark-no-duplicate-contentinfo?application=axeAPI + */ + rules: [ + { id: 'landmark-no-duplicate-contentinfo', enabled: false }, + { id: 'landmark-unique', enabled: false }, + ], + }, }, }, }; -export const PageFooterInPage = () => { +const PageFooterInPageRender = () => { const { isPhone } = useBreakpoints(); const setEndButtonMobile = isPhone ? exampleProps.endButtonsBlock2 : exampleProps.endButtons2; @@ -119,24 +123,28 @@ export const PageFooterInPage = () => { Primary Content - - + + ); }; -export default { +export const PageFooterInPage: Story = { + render: () => , +}; + +const meta: Meta = { title: 'Components/PageFooter', component: PageFooter, argTypes: { @@ -182,3 +190,5 @@ export default { }, }, }; + +export default meta; diff --git a/packages/web/src/page/__stories__/PageHeader.stories.tsx b/packages/web/src/page/__stories__/PageHeader.stories.tsx index 14186abfd3..bd0c88b5ab 100644 --- a/packages/web/src/page/__stories__/PageHeader.stories.tsx +++ b/packages/web/src/page/__stories__/PageHeader.stories.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { assets } from '@coinbase/cds-common/internal/data/assets'; -import type { Story } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button, ButtonGroup, IconButton } from '../../buttons'; import { useBreakpoints } from '../../hooks/useBreakpoints'; @@ -73,7 +73,7 @@ const exampleProps = { ), intermediary2: ( - + Hello there. This is a rather long text sentence since I do not have lorem ipsum handy. Hello there. This is a rather long text sentence since I do not have lorem ipsum handy. @@ -166,19 +166,20 @@ const exampleProps = { ), }; -const Template: Story = (args) => ; +type Story = StoryObj; -export const InteractiveHeader = Template.bind({}); - -InteractiveHeader.args = { - background: 'bg', - start: 'logoMark2', - title: 'title1', - end: 'end2', +export const InteractiveHeader: Story = { + render: (args) => , + args: { + background: 'bg', + start: 'logoMark2', + title: 'title1', + end: 'end2', + }, }; -export const Examples = () => { - return ( +export const Examples: Story = { + render: () => ( { title={exampleProps.intermediary2} /> - ); -}; - -Examples.parameters = { - a11y: { - config: { - /** - * It is expected to include multiple PageHeaders with same landmark in this story - * @link https://dequeuniversity.com/rules/axe/4.6/landmark-no-duplicate-banner?application=axeAPI - */ - rules: [ - { id: 'landmark-no-duplicate-banner', enabled: false }, - { id: 'landmark-unique', enabled: false }, - ], + ), + parameters: { + a11y: { + config: { + /** + * It is expected to include multiple PageHeaders with same landmark in this story + * @link https://dequeuniversity.com/rules/axe/4.6/landmark-no-duplicate-banner?application=axeAPI + */ + rules: [ + { id: 'landmark-no-duplicate-banner', enabled: false }, + { id: 'landmark-unique', enabled: false }, + ], + }, }, }, }; -export const PageHeaderInErrorEmptyState = () => { - return ( +export const PageHeaderInErrorEmptyState: Story = { + render: () => ( @@ -305,10 +305,10 @@ export const PageHeaderInErrorEmptyState = () => { - ); + ), }; -export const PageHeaderInPage = () => { +const PageHeaderInPageRender = () => { const { isPhone } = useBreakpoints(); const setEndButtonMobile = isPhone ? exampleProps.endButtonsBlock3 : exampleProps.endButtons3; @@ -340,25 +340,29 @@ export const PageHeaderInPage = () => { Primary Content - - + + ); }; -export default { +export const PageHeaderInPage: Story = { + render: () => , +}; + +const meta: Meta = { title: 'Components/PageHeader', component: PageHeader, argTypes: { @@ -433,3 +437,5 @@ export default { }, }, }; + +export default meta; diff --git a/packages/web/src/stepper/DefaultStepperHeaderHorizontal.tsx b/packages/web/src/stepper/DefaultStepperHeaderHorizontal.tsx index fde90953b8..6deaec2243 100644 --- a/packages/web/src/stepper/DefaultStepperHeaderHorizontal.tsx +++ b/packages/web/src/stepper/DefaultStepperHeaderHorizontal.tsx @@ -1,12 +1,13 @@ -import { memo, useMemo } from 'react'; -import { animated, useSpring } from '@react-spring/web'; +import { memo } from 'react'; +import { curves, durations } from '@coinbase/cds-common/motion/tokens'; +import { m as motion } from 'framer-motion'; import { HStack } from '../layout/HStack'; import { Text } from '../typography/Text'; import type { StepperHeaderComponent } from './Stepper'; -const AnimatedHStack = animated(HStack); +const MotionHStack = motion(HStack); const displayStyle = { phone: 'flex', @@ -14,6 +15,12 @@ const displayStyle = { desktop: 'none', } as const; +const headerTransition = { + type: 'tween' as const, + duration: durations.slow2 / 1000, + ease: curves.global, +}; + export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( function DefaultStepperHeaderHorizontal({ activeStep, @@ -22,6 +29,7 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( className, style, display = displayStyle, + disableAnimateOnMount, width = '100%', paddingBottom = 1.5, font = 'caption', @@ -32,26 +40,22 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( textTransform, ...props }) { - const spring = useSpring({ - from: { opacity: 0 }, - to: { opacity: 1 }, - reset: true, - }); - - const styles = useMemo(() => ({ ...style, ...spring }), [style, spring]); const flatStepIndex = activeStep ? flatStepIds.indexOf(activeStep.id) : -1; const emptyText = <> ; return ( - @@ -82,7 +86,7 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( )} - + ); }, ); diff --git a/packages/web/src/stepper/DefaultStepperLabelHorizontal.tsx b/packages/web/src/stepper/DefaultStepperLabelHorizontal.tsx index c09a4d9f13..f45b1889ec 100644 --- a/packages/web/src/stepper/DefaultStepperLabelHorizontal.tsx +++ b/packages/web/src/stepper/DefaultStepperLabelHorizontal.tsx @@ -1,14 +1,14 @@ import { memo, useMemo } from 'react'; import useMeasure from 'react-use-measure'; -import { animated, useSpring } from '@react-spring/web'; +import { m as motion } from 'framer-motion'; import { Box } from '../layout/Box'; import { HStack } from '../layout/HStack'; import { Text } from '../typography/Text'; -import type { StepperLabelComponent } from './Stepper'; +import { defaultProgressTimingConfig, type StepperLabelComponent } from './Stepper'; -const AnimatedBox = animated(Box); +const MotionBox = motion(Box); const displayStyle = { phone: 'none', @@ -30,6 +30,7 @@ export const DefaultStepperLabelHorizontal: StepperLabelComponent = memo( className, style, completedStepAccessibilityLabel, + progressTimingConfig = defaultProgressTimingConfig, setActiveStepLabelElement, defaultColor = 'fgMuted', activeColor = 'fg', @@ -45,18 +46,13 @@ export const DefaultStepperLabelHorizontal: StepperLabelComponent = memo( fontWeight = font, lineHeight = font, textTransform, + disableAnimateOnMount, ...props }) { const isStepGroupActive = active || isDescendentActive; + const showPagination = isStepGroupActive && !complete && !visited; const [paginationRef, { width: paginationWidth }] = useMeasure(); - const paginationSpring = useSpring({ - opacity: isStepGroupActive && !complete && !visited ? 1 : 0, - }); - const labelSpring = useSpring({ - left: isStepGroupActive && !complete && !visited ? paginationWidth : 0, - }); - const flatStepIndex = flatStepIds.indexOf(step.id); const fontProps = useMemo( @@ -127,17 +123,23 @@ export const DefaultStepperLabelHorizontal: StepperLabelComponent = memo( width={width} {...props} > - + {paginationText} - - + {labelElement} - + ); }, diff --git a/packages/web/src/stepper/DefaultStepperProgressHorizontal.tsx b/packages/web/src/stepper/DefaultStepperProgressHorizontal.tsx index b6719a4267..f97a267814 100644 --- a/packages/web/src/stepper/DefaultStepperProgressHorizontal.tsx +++ b/packages/web/src/stepper/DefaultStepperProgressHorizontal.tsx @@ -1,9 +1,9 @@ -import { memo } from 'react'; -import { animated } from '@react-spring/web'; +import { memo, useMemo } from 'react'; +import { m as motion } from 'framer-motion'; import { Box } from '../layout/Box'; -import type { StepperProgressComponent } from './Stepper'; +import { defaultProgressTimingConfig, type StepperProgressComponent } from './Stepper'; export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( function DefaultStepperProgressHorizontal({ @@ -17,7 +17,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( flatStepIds, complete, isDescendentActive, - progressSpringConfig, + progressTimingConfig, activeStepLabelElement, animate, disableAnimateOnMount, @@ -34,6 +34,14 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( height = 4, ...props }) { + const transition = useMemo( + () => + animate + ? (progressTimingConfig ?? defaultProgressTimingConfig) + : { type: 'tween' as const, duration: 0 }, + [animate, progressTimingConfig], + ); + return ( - `${p * 100}%`), + height: '100%', }} + transition={transition} /> diff --git a/packages/web/src/stepper/DefaultStepperProgressVertical.tsx b/packages/web/src/stepper/DefaultStepperProgressVertical.tsx index e993419fed..69771c3abb 100644 --- a/packages/web/src/stepper/DefaultStepperProgressVertical.tsx +++ b/packages/web/src/stepper/DefaultStepperProgressVertical.tsx @@ -1,11 +1,10 @@ -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { defaultRect } from '@coinbase/cds-common/types/Rect'; -import { animated, to, useSpring } from '@react-spring/web'; +import { m as motion } from 'framer-motion'; -import { useHasMounted } from '../hooks/useHasMounted'; import { Box } from '../layout/Box'; -import type { StepperProgressComponent } from './Stepper'; +import { defaultProgressTimingConfig, type StepperProgressComponent } from './Stepper'; export const DefaultStepperProgressVertical: StepperProgressComponent = memo( function DefaultStepperProgressVertical({ @@ -21,7 +20,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( className, style, activeStepLabelElement, - progressSpringConfig, + progressTimingConfig = defaultProgressTimingConfig, animate = true, disableAnimateOnMount, background = 'bgLine', @@ -35,62 +34,58 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( progress, ...props }) { - const hasMounted = useHasMounted(); const containerRef = useRef(null); - const [renderTick, setRenderTick] = useState(0); + const [fillHeight, setFillHeight] = useState(0); + const [hasReceivedFillHeight, setHasReceivedFillHeight] = useState(false); const isStepGroupActive = active || isDescendentActive; const isLastStep = flatStepIds[flatStepIds.length - 1] === step.id; - useEffect(() => { - if (!containerRef.current) return; - const observer = new window.ResizeObserver((entries) => { - setRenderTick((prev) => prev + 1); - }); - - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); - - const getFillHeight = useCallback(() => { + const recalculateFillHeight = useCallback(() => { + const container = containerRef.current; + if (!container) return; const hasSubSteps = Boolean(step.subSteps?.length); - const containerRect = containerRef.current?.getBoundingClientRect() ?? defaultRect; + const containerRect = container.getBoundingClientRect(); - // Complete progress fill - if (complete || (visited && !isStepGroupActive) || (!hasSubSteps && active)) - return containerRect.height; - // Partial progress fill - if (hasSubSteps && isDescendentActive) { + let height: number; + if (complete || (visited && !isStepGroupActive) || (!hasSubSteps && active)) { + height = containerRect.height; + } else if (hasSubSteps && isDescendentActive) { const activeStepLabelRect = activeStepLabelElement?.getBoundingClientRect() ?? defaultRect; const lastSubstep = step.subSteps?.[step.subSteps.length - 1]; const isLastSubstepActive = activeStepId === lastSubstep?.id; const activeStepLabelBottom = activeStepLabelRect.y + activeStepLabelRect.height; const halfLabelHeight = isLastSubstepActive ? 0 : 0.5 * activeStepLabelRect.height; - return activeStepLabelBottom - containerRect.y - halfLabelHeight; + height = activeStepLabelBottom - containerRect.y - halfLabelHeight; + } else { + height = 0; } - return 0; - // renderTick is used to force a new height calculation when it changes by the observer - // eslint-disable-next-line react-hooks/exhaustive-deps + setFillHeight(height); + if (height) setHasReceivedFillHeight(true); }, [ step.subSteps, complete, visited, isStepGroupActive, active, - renderTick, isDescendentActive, activeStepLabelElement, activeStepId, ]); - const [{ fillHeight }] = useSpring( - () => ({ - fillHeight: getFillHeight(), - config: progressSpringConfig, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - }), - [getFillHeight, animate, disableAnimateOnMount, hasMounted], + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const observer = new window.ResizeObserver(recalculateFillHeight); + observer.observe(container); + return () => observer.disconnect(); + }, [recalculateFillHeight]); + + const animatedHeight = progress * fillHeight; + const transition = useMemo( + () => (animate ? progressTimingConfig : { type: 'tween' as const, duration: 0 }), + [animate, progressTimingConfig], ); if (depth > 0 || isLastStep) return null; @@ -124,14 +119,27 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( : defaultFill } > - `${p * f}px`), - }} - /> + {disableAnimateOnMount && !hasReceivedFillHeight ? ( +
+ ) : ( + + )} ); diff --git a/packages/web/src/stepper/DefaultStepperStepHorizontal.tsx b/packages/web/src/stepper/DefaultStepperStepHorizontal.tsx index 844d171239..d1a68d0981 100644 --- a/packages/web/src/stepper/DefaultStepperStepHorizontal.tsx +++ b/packages/web/src/stepper/DefaultStepperStepHorizontal.tsx @@ -28,7 +28,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( progress, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepHorizontal, @@ -112,7 +112,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -127,9 +127,11 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( complete={complete} completedStepAccessibilityLabel={completedStepAccessibilityLabel} depth={depth} + disableAnimateOnMount={disableAnimateOnMount} flatStepIds={flatStepIds} isDescendentActive={isDescendentActive} parentStep={parentStep} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={step} style={styles?.label} @@ -180,7 +182,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/web/src/stepper/DefaultStepperStepVertical.tsx b/packages/web/src/stepper/DefaultStepperStepVertical.tsx index 1ead8b0558..cfaaff653d 100644 --- a/packages/web/src/stepper/DefaultStepperStepVertical.tsx +++ b/packages/web/src/stepper/DefaultStepperStepVertical.tsx @@ -29,7 +29,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( progress, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepVertical, @@ -111,7 +111,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -153,7 +153,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( style={styles?.substepContainer} visited={visited} > - {step.subSteps.map((subStep, index) => { + {step.subSteps.map((subStep) => { const RenderedStepComponent = subStep.Component ?? StepperStepComponent; const isDescendentActive = activeStepId ? containsStep({ @@ -183,7 +183,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/web/src/stepper/Stepper.tsx b/packages/web/src/stepper/Stepper.tsx index 4233bfbb1c..99c75f7f83 100644 --- a/packages/web/src/stepper/Stepper.tsx +++ b/packages/web/src/stepper/Stepper.tsx @@ -1,12 +1,11 @@ -import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; +import { curves, durations } from '@coinbase/cds-common/motion/tokens'; import { containsStep, flattenSteps, isStepVisited } from '@coinbase/cds-common/stepper/utils'; import type { IconName } from '@coinbase/cds-common/types'; -import { type SpringConfig, type SpringValue, useSprings } from '@react-spring/web'; +import type { Transition } from 'framer-motion'; import { cx } from '../cx'; -import { useHasMounted } from '../hooks/useHasMounted'; import type { IconProps } from '../icons/Icon'; import { Box, type BoxDefaultElement, type BoxProps } from '../layout/Box'; import { VStack, type VStackBaseProps, type VStackProps } from '../layout/VStack'; @@ -73,13 +72,13 @@ export type StepperStepProps = Record & BoxProps & { /** - * An animated SpringValue between 0 and 1. - * You can use this to animate your own custom Progress subcomponent. + * A value between 0 and 1 representing the step's progress. + * Progress bar subcomponents animate to this value with the given progressTimingConfig. */ - progress: SpringValue; + progress: number; activeStepLabelElement: HTMLElement | null; setActiveStepLabelElement: (element: HTMLElement) => void; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: Transition; animate?: boolean; disableAnimateOnMount?: boolean; completedStepAccessibilityLabel?: string; @@ -118,6 +117,7 @@ export type StepperHeaderProps = Record activeStep: StepperValue | null; flatStepIds: string[]; complete?: boolean; + disableAnimateOnMount?: boolean; className?: string; style?: React.CSSProperties; }; @@ -126,21 +126,23 @@ export type StepperLabelProps = Record< StepperSubcomponentProps & BoxProps & { setActiveStepLabelElement: (element: HTMLElement) => void; + progressTimingConfig?: Transition; defaultColor?: ResponsiveProp; activeColor?: ResponsiveProp; descendentActiveColor?: ResponsiveProp; visitedColor?: ResponsiveProp; completeColor?: ResponsiveProp; completedStepAccessibilityLabel?: string; + disableAnimateOnMount?: boolean; }; export type StepperProgressProps< Metadata extends Record = Record, > = StepperSubcomponentProps & BoxProps & { - progress: SpringValue; + progress: number; activeStepLabelElement: HTMLElement | null; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: Transition; animate?: boolean; disableAnimateOnMount?: boolean; defaultFill?: ResponsiveProp; @@ -224,9 +226,9 @@ export type StepperBaseProps = Record | null; /** An optional component to render in place of the default Header subcomponent. Set to null to render nothing in this slot. */ StepperHeaderComponent?: StepperHeaderComponent | null; - /** The spring config to use for the progress spring. */ - progressSpringConfig?: SpringConfig; - /** Whether to animate the progress spring. + /** The Framer Motion transition config for progress bar animations (e.g. duration in seconds, ease). */ + progressTimingConfig?: Transition; + /** Whether to animate the progress bar. * @default true */ animate?: boolean; @@ -237,6 +239,8 @@ export type StepperBaseProps = Record = Record> = VStackProps & StepperBaseProps & { @@ -282,7 +286,12 @@ export const horizontalStepGap = { desktop: 1, } as const; -export const defaultProgressSpringConfig = { friction: 0, tension: 100, clamp: true }; +/** Default progress transition: tween with CDS duration (seconds) and global curve. */ +export const defaultProgressTimingConfig: Transition = { + type: 'tween', + duration: durations.slow2 / 1000, + ease: curves.global, +}; type StepperComponent = = Record>( props: StepperProps & { ref?: React.Ref }, @@ -321,14 +330,13 @@ const StepperBase = memo( StepperHeaderComponent = direction === 'vertical' ? null : (DefaultStepperHeaderHorizontal as StepperHeaderComponent), - progressSpringConfig = defaultProgressSpringConfig, + progressTimingConfig = defaultProgressTimingConfig, animate = true, disableAnimateOnMount, ...props }: StepperProps, ref: React.Ref, ) => { - const hasMounted = useHasMounted(); const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); // Derive activeStep from activeStepId @@ -362,92 +370,68 @@ const StepperBase = memo( : -1; }, [activeStepId, steps]); - const previousComplete = usePreviousValue(complete) ?? false; - const previousActiveStepIndex = usePreviousValue(activeStepIndex) ?? -1; + // the target step index for the cascade animation + const cascadeTargetIndex = useMemo( + () => (complete ? steps.length - 1 : activeStepIndex), + [complete, steps, activeStepIndex], + ); + // reference to the previous cascade targetIndex + const cascadeTargetIndexRef = useRef(cascadeTargetIndex); + // the index of the last filled step + const [filledStepIndex, setFilledStepIndex] = useState( + disableAnimateOnMount ? cascadeTargetIndex : -1, + ); - const [progressSprings, progressSpringsApi] = useSprings(steps.length, (index) => ({ - progress: complete ? 1 : 0, - config: progressSpringConfig, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - })); + const filledStepTimeoutRef = useRef | null>(null); useEffect(() => { - // update the previous values for next render - let stepsToAnimate: number[] = []; - let isAnimatingForward = false; - - // Case when going from not-complete to complete - if (Boolean(complete) !== previousComplete) { - if (complete) { - // Going to complete: animate remaining steps to filled. - // Use previousActiveStepIndex to determine which steps are already filled before the completion state update, - const lastFilledIndex = Math.max(activeStepIndex, previousActiveStepIndex); - stepsToAnimate = Array.from( - { length: steps.length - lastFilledIndex - 1 }, - (_, i) => lastFilledIndex + 1 + i, - ); - isAnimatingForward = true; + if (filledStepTimeoutRef.current) { + clearTimeout(filledStepTimeoutRef.current); + filledStepTimeoutRef.current = null; + } + if (!animate) { + if (filledStepIndex !== cascadeTargetIndex) setFilledStepIndex(cascadeTargetIndex); + } else if (cascadeTargetIndex < filledStepIndex) { + if (cascadeTargetIndexRef.current !== cascadeTargetIndex) { + setFilledStepIndex((prev) => prev - 1); + cascadeTargetIndexRef.current = cascadeTargetIndex; } else { - // Going from complete: animate from end down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: steps.length - activeStepIndex - 1 }, - (_, i) => steps.length - 1 - i, + filledStepTimeoutRef.current = setTimeout( + () => setFilledStepIndex((prev) => prev - 1), + cascadeStaggerMs, ); - isAnimatingForward = false; } - } - - // Case for normal step navigation (e.g. step 1 => step 2) - else if (activeStepIndex !== previousActiveStepIndex) { - if (activeStepIndex > previousActiveStepIndex) { - // Forward: animate from previousActiveStepIndex+1 to activeStepIndex - stepsToAnimate = Array.from( - { length: activeStepIndex - previousActiveStepIndex }, - (_, i) => previousActiveStepIndex + 1 + i, - ); - isAnimatingForward = true; + } else if (cascadeTargetIndex > filledStepIndex) { + if (cascadeTargetIndexRef.current !== cascadeTargetIndex) { + setFilledStepIndex((prev) => prev + 1); + cascadeTargetIndexRef.current = cascadeTargetIndex; } else { - // Backward: animate from previousActiveStepIndex down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: previousActiveStepIndex - activeStepIndex }, - (_, i) => previousActiveStepIndex - i, + filledStepTimeoutRef.current = setTimeout( + () => setFilledStepIndex((prev) => prev + 1), + cascadeStaggerMs, ); - isAnimatingForward = false; } } - - const animateNextStep = () => { - if (stepsToAnimate.length === 0) return; - const stepIndex = stepsToAnimate.shift(); - if (stepIndex === undefined) return; - - progressSpringsApi.start((index) => - index === stepIndex - ? { - progress: isAnimatingForward ? 1 : 0, - config: progressSpringConfig, - onRest: animateNextStep, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - } - : {}, - ); + return () => { + if (filledStepTimeoutRef.current) { + clearTimeout(filledStepTimeoutRef.current); + filledStepTimeoutRef.current = null; + } }; - - // start the animation loop for relevant springs (stepsToAnimate) - animateNextStep(); - }, [ - progressSpringsApi, - complete, - steps.length, - steps, - activeStepIndex, - previousActiveStepIndex, - previousComplete, - progressSpringConfig, - animate, - disableAnimateOnMount, - hasMounted, - ]); + }, [animate, cascadeTargetIndex, filledStepIndex]); + + const getStepProgress = useCallback( + (index: number) => { + if (!animate) { + // if animation is disabled, return 0 if the step index is less than the active step index, otherwise return 1 + if (filledStepIndex < 0) return 0; + return index <= activeStepIndex ? 1 : 0; + } + if (filledStepIndex < 0) return 0; + return index <= filledStepIndex ? 1 : 0; + }, + [animate, activeStepIndex, filledStepIndex], + ); return ( @@ -498,8 +483,8 @@ const StepperBase = memo( flatStepIds={flatStepIds} isDescendentActive={isDescendentActive} parentStep={null} - progress={progressSprings[index].progress} - progressSpringConfig={progressSpringConfig} + progress={getStepProgress(index)} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={step} styles={stepStyles} diff --git a/packages/web/src/stepper/__stories__/StepperHorizontal.stories.tsx b/packages/web/src/stepper/__stories__/StepperHorizontal.stories.tsx index 1c30c4af50..b177afee72 100644 --- a/packages/web/src/stepper/__stories__/StepperHorizontal.stories.tsx +++ b/packages/web/src/stepper/__stories__/StepperHorizontal.stories.tsx @@ -298,6 +298,91 @@ export const NestedSteps = () => ( ); +// ------------------------------------------------------------ +// Disable animate on mount +// ------------------------------------------------------------ +export const DisableAnimateOnMount = () => ( + + + + + + + + +); + +// ------------------------------------------------------------ +// Animate false +// ------------------------------------------------------------ +export const AnimateFalse = () => ( + + + + + + + +); + // ------------------------------------------------------------ // Null Components // ------------------------------------------------------------ diff --git a/packages/web/src/stepper/__stories__/StepperVertical.stories.tsx b/packages/web/src/stepper/__stories__/StepperVertical.stories.tsx index 8f979544e1..1c4a93d012 100644 --- a/packages/web/src/stepper/__stories__/StepperVertical.stories.tsx +++ b/packages/web/src/stepper/__stories__/StepperVertical.stories.tsx @@ -552,6 +552,240 @@ export const CustomComponents = () => { ); }; +// ------------------------------------------------------------ +// Disable animate on mount +// ------------------------------------------------------------ +export const DisableAnimateOnMount = () => { + const steps: StepperValue[] = [ + { + id: 'first-step', + label: 'First step', + }, + { + id: 'second-step', + label: 'Second step', + }, + { + id: 'third-step', + label: 'Third step', + }, + { + id: 'final-step', + label: 'Final step', + }, + ]; + + const oneLevelSteps: StepperValue[] = [ + { + id: 'first-step', + label: 'First step', + }, + { + id: 'second-step', + label: 'Second step', + subSteps: [ + { id: 'second-step-substep-one', label: 'Substep one' }, + { id: 'second-step-substep-two', label: 'Substep two' }, + { id: 'second-step-substep-three', label: 'Substep three' }, + ], + }, + { + id: 'final-step', + label: 'Final step', + }, + ]; + + const twoLevelSteps: StepperValue[] = [ + { + id: 'first-step', + label: 'First step', + }, + { + id: 'second-step', + label: 'Second step', + subSteps: [ + { id: 'second-step-substep-one', label: 'Substep one' }, + { + id: 'second-step-substep-two', + label: 'Substep two', + subSteps: [ + { id: 'deeply-nested-step-1', label: 'Deeply nested step 1' }, + { id: 'deeply-nested-step-2', label: 'Deeply nested step 2' }, + ], + }, + { id: 'second-step-substep-three', label: 'Substep three' }, + ], + }, + { + id: 'final-step', + label: 'Final step', + }, + ]; + + return ( + + + + + + + + + + + ); +}; + +// ------------------------------------------------------------ +// Animate false +// ------------------------------------------------------------ +export const AnimateFalse = () => { + const steps: StepperValue[] = [ + { id: 'first-step', label: 'First step' }, + { id: 'second-step', label: 'Second step' }, + { id: 'third-step', label: 'Third step' }, + { id: 'final-step', label: 'Final step' }, + ]; + + const oneLevelSteps: StepperValue[] = [ + { id: 'first-step', label: 'First step' }, + { + id: 'second-step', + label: 'Second step', + subSteps: [ + { id: 'second-step-substep-one', label: 'Substep one' }, + { id: 'second-step-substep-two', label: 'Substep two' }, + { id: 'second-step-substep-three', label: 'Substep three' }, + ], + }, + { id: 'final-step', label: 'Final step' }, + ]; + + const twoLevelSteps: StepperValue[] = [ + { id: 'first-step', label: 'First step' }, + { + id: 'second-step', + label: 'Second step', + subSteps: [ + { id: 'second-step-substep-one', label: 'Substep one' }, + { + id: 'second-step-substep-two', + label: 'Substep two', + subSteps: [ + { id: 'deeply-nested-step-1', label: 'Deeply nested step 1' }, + { id: 'deeply-nested-step-2', label: 'Deeply nested step 2' }, + ], + }, + { id: 'second-step-substep-three', label: 'Substep three' }, + ], + }, + { id: 'final-step', label: 'Final step' }, + ]; + + return ( + + + + + + + + + + ); +}; + // ------------------------------------------------------------ // Null Components // ------------------------------------------------------------ diff --git a/packages/web/src/styles/styleProps.ts b/packages/web/src/styles/styleProps.ts index 54457e7559..67da997ffe 100644 --- a/packages/web/src/styles/styleProps.ts +++ b/packages/web/src/styles/styleProps.ts @@ -150,7 +150,9 @@ export const dynamicPixelProps = { flexBasis: 1, } as const satisfies Partial>; -export type ResponsiveProp = T | { base?: T; phone?: T; tablet?: T; desktop?: T }; +export type ResponsiveValue = { base?: T; phone?: T; tablet?: T; desktop?: T }; + +export type ResponsiveProp = T | ResponsiveValue; export type ResponsiveProps = { [key in keyof T]?: ResponsiveProp; @@ -158,6 +160,12 @@ export type ResponsiveProps = { export type StyleProps = ResponsiveProps & ResponsiveProps; +/** Position-related style props using CSS types. Use this instead of common's PositionStyles for web. */ +export type PositionStyles = Pick< + StyleProps, + 'position' | 'top' | 'bottom' | 'left' | 'right' | 'zIndex' +>; + export const getStyles = (styleProps: StyleProps, inlineStyle?: React.CSSProperties) => { const style: Record = {}; let className = ''; diff --git a/packages/web/src/system/Interactable.tsx b/packages/web/src/system/Interactable.tsx index 3876bfbb85..34c227b195 100644 --- a/packages/web/src/system/Interactable.tsx +++ b/packages/web/src/system/Interactable.tsx @@ -12,6 +12,7 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import type { Theme } from '../core/theme'; import { cx } from '../cx'; +import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxBaseProps } from '../layout/Box'; @@ -163,8 +164,6 @@ export type InteractableBaseProps = Polymorphic.ExtendableProps< background?: ThemeVars.Color; /** Set element to block and expand to 100% width. */ block?: boolean; - /** Border color of the element. */ - borderColor?: ThemeVars.Color; /** Is the element currently disabled. */ disabled?: boolean; /** @@ -223,6 +222,7 @@ export const Interactable: InteractableComponent = forwardRef< ) => { const Component = (as ?? interactableDefaultElement) satisfies React.ElementType; const theme = useTheme(); + const resolvedBorderColor = useResolveResponsiveProp(borderColor); const interactableStyle = useMemo( () => ({ @@ -230,11 +230,11 @@ export const Interactable: InteractableComponent = forwardRef< theme, background, blendStyles, - borderColor, + borderColor: resolvedBorderColor, }), ...style, }), - [style, background, theme, blendStyles, borderColor], + [style, background, theme, blendStyles, resolvedBorderColor], ); return ( diff --git a/packages/web/src/system/PressableOpacity.tsx b/packages/web/src/system/PressableOpacity.tsx index 2ae9192ac1..8643692718 100644 --- a/packages/web/src/system/PressableOpacity.tsx +++ b/packages/web/src/system/PressableOpacity.tsx @@ -47,7 +47,7 @@ export const PressableOpacity: PressableOpacityComponent = forwardRef< ) => { const Component = (as ?? pressableOpacityDefaultElement) satisfies React.ElementType; return ( - + {children} ); diff --git a/packages/web/src/system/ThemeProvider.tsx b/packages/web/src/system/ThemeProvider.tsx index 6d2582e0be..5cf9205f77 100644 --- a/packages/web/src/system/ThemeProvider.tsx +++ b/packages/web/src/system/ThemeProvider.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, memo, useContext, useMemo } from 'react'; import type { ColorScheme } from '@coinbase/cds-common/core/theme'; import { createThemeCssVars } from '../core/createThemeCssVars'; @@ -23,15 +23,16 @@ type ThemeManagerProps = { className?: string; style?: React.CSSProperties; children?: React.ReactNode; - theme: Theme; + theme: Partial; }; -export const useThemeProviderStyles = (theme: Theme) => { +export const useThemeProviderStyles = (theme: Partial) => { const style = useMemo(() => createThemeCssVars(theme), [theme]); return style; }; -const ThemeManager = ({ display, className, style, children, theme }: ThemeManagerProps) => { +/** Injects theme CSS variables into the DOM by calling `createThemeCssVars` via `useThemeProviderStyles`. */ +const ThemeManager = memo(({ display, className, style, children, theme }: ThemeManagerProps) => { const themeStyles = useThemeProviderStyles(theme); const styles = useMemo( () => ({ ...themeStyles, display, ...style }), @@ -42,99 +43,141 @@ const ThemeManager = ({ display, className, style, children, theme }: ThemeManag {children}
); +}); + +/** + * Diff two themes and return a new partial theme with only the differences. + */ +export const diffThemes = (theme: Theme, parentTheme?: Theme) => { + if (!parentTheme) return theme; + const themeDiff = { + id: theme.id, + activeColorScheme: theme.activeColorScheme, + } as Record; + (Object.keys(theme) as (keyof Theme)[]).forEach((key) => { + if (key === 'id' || key === 'activeColorScheme') return; + themeDiff[key] = {}; + Object.keys(theme[key] ?? {}).forEach((value) => { + if ((theme[key] as any)?.[value] !== (parentTheme[key] as any)?.[value]) { + themeDiff[key][value] = (theme[key] as any)[value]; + } + }); + }); + return themeDiff as Partial; }; -export type ThemeProviderProps = Pick & - Pick & { - theme: ThemeConfig; - activeColorScheme: ColorScheme; - children?: React.ReactNode; - }; - -export const ThemeProvider = ({ - theme, - activeColorScheme, - children, - className, - display, - style, - motionFeatures, -}: ThemeProviderProps) => { - const themeApi = useMemo(() => { - const activeSpectrumKey = activeColorScheme === 'dark' ? 'darkSpectrum' : 'lightSpectrum'; - const activeColorKey = activeColorScheme === 'dark' ? 'darkColor' : 'lightColor'; - const inverseSpectrumKey = activeColorScheme === 'dark' ? 'lightSpectrum' : 'darkSpectrum'; - const inverseColorKey = activeColorScheme === 'dark' ? 'lightColor' : 'darkColor'; - - // TO DO: Link to color / theme docs in these error messages - if (!theme[activeColorKey]) - throw Error( - `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeColorScheme} colors are defined for the theme`, - ); - - if (!theme[activeSpectrumKey]) - throw Error( - `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeSpectrumKey} values are defined for the theme`, - ); - - if (theme[inverseSpectrumKey] && !theme[inverseColorKey]) - throw Error( - `ThemeProvider theme has ${inverseSpectrumKey} values defined but no ${inverseColorKey} colors are defined for the theme`, - ); - - if (theme[inverseColorKey] && !theme[inverseSpectrumKey]) - throw Error( - `ThemeProvider theme has ${inverseColorKey} colors defined but no ${inverseSpectrumKey} values are defined for the theme`, - ); - - return { - ...theme, - activeColorScheme: activeColorScheme, - spectrum: theme[activeSpectrumKey], - color: theme[activeColorKey], - }; - }, [theme, activeColorScheme]); - - return ( - - - - {children} - - - - ); +export type ThemeProviderProps = Pick & { + theme: ThemeConfig; + activeColorScheme: ColorScheme; + children?: React.ReactNode; + /** + * Use the `isolated` prop to indicate when ThemeProvider will render outside its parent's DOM tree, such as in a portal. + * An isolated ThemeProvider always inserts all theme CSS variables into the DOM, regardless of what overlaps with parent ThemeProvider instances. + * @default false + */ + isolated?: boolean; + /** + * A motion "feature" bundle, used to selectively bundle specific framer-motion features. You likely won't need to use this this prop. + * @default domAnimation + * @see https://motion.dev/docs/react-reduce-bundle-size + */ + motionFeatures?: FramerMotionProviderProps['motionFeatures']; }; +export const ThemeProvider = memo( + ({ + theme, + activeColorScheme, + children, + className, + display, + style, + motionFeatures, + isolated, + }: ThemeProviderProps) => { + const themeApi = useMemo(() => { + const activeSpectrumKey = activeColorScheme === 'dark' ? 'darkSpectrum' : 'lightSpectrum'; + const activeColorKey = activeColorScheme === 'dark' ? 'darkColor' : 'lightColor'; + const inverseSpectrumKey = activeColorScheme === 'dark' ? 'lightSpectrum' : 'darkSpectrum'; + const inverseColorKey = activeColorScheme === 'dark' ? 'lightColor' : 'darkColor'; + + if (!theme[activeColorKey]) + throw Error( + `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeColorScheme} colors are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, + ); + + if (!theme[activeSpectrumKey]) + throw Error( + `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeSpectrumKey} values are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, + ); + + if (theme[inverseSpectrumKey] && !theme[inverseColorKey]) + throw Error( + `ThemeProvider theme has ${inverseSpectrumKey} values defined but no ${inverseColorKey} colors are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, + ); + + if (theme[inverseColorKey] && !theme[inverseSpectrumKey]) + throw Error( + `ThemeProvider theme has ${inverseColorKey} colors defined but no ${inverseSpectrumKey} values are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, + ); + + return { + ...theme, + activeColorScheme: activeColorScheme, + spectrum: theme[activeSpectrumKey], + color: theme[activeColorKey], + }; + }, [theme, activeColorScheme]); + + const parentTheme = useContext(ThemeContext); + + const partialTheme = useMemo( + () => (isolated ? themeApi : diffThemes(themeApi, parentTheme)), + [themeApi, parentTheme, isolated], + ); + + return ( + + + + {children} + + + + ); + }, +); + export type InvertedThemeProviderProps = Pick< ThemeManagerProps, 'display' | 'className' | 'style' -> & { - children?: React.ReactNode; -}; +> & + Pick & { + children?: React.ReactNode; + }; /** Falls back to the currently active colorScheme if the inverse colors are not defined in the theme. */ -export const InvertedThemeProvider = ({ - children, - display, - className, - style, -}: InvertedThemeProviderProps) => { - const context = useContext(ThemeContext); - if (!context) throw Error('InvertedThemeProvider must be used within a ThemeProvider'); - const inverseColorScheme = context.activeColorScheme === 'dark' ? 'light' : 'dark'; - const inverseColorKey = context.activeColorScheme === 'dark' ? 'lightColor' : 'darkColor'; - const newColorScheme = context[inverseColorKey] ? inverseColorScheme : context.activeColorScheme; - - return ( - - {children} - - ); -}; +export const InvertedThemeProvider = memo( + ({ children, display, className, style, isolated }: InvertedThemeProviderProps) => { + const context = useContext(ThemeContext); + if (!context) throw Error('InvertedThemeProvider must be used within a ThemeProvider'); + const inverseColorScheme = context.activeColorScheme === 'dark' ? 'light' : 'dark'; + const inverseColorKey = context.activeColorScheme === 'dark' ? 'lightColor' : 'darkColor'; + const newColorScheme = context[inverseColorKey] + ? inverseColorScheme + : context.activeColorScheme; + + return ( + + {children} + + ); + }, +); diff --git a/packages/web/src/system/__stories__/Pressable.stories.tsx b/packages/web/src/system/__stories__/Pressable.stories.tsx index bcdca15c4a..9a5a02b831 100644 --- a/packages/web/src/system/__stories__/Pressable.stories.tsx +++ b/packages/web/src/system/__stories__/Pressable.stories.tsx @@ -178,7 +178,7 @@ export const ThemeColors = () => { borderColor="bgLine" borderWidth={100} > - + {color} @@ -213,7 +213,7 @@ export const ThemeColorsWithDisabled = () => { borderColor="bgLine" borderWidth={100} > - + {color} diff --git a/packages/web/src/system/__tests__/Interactable.test.tsx b/packages/web/src/system/__tests__/Interactable.test.tsx new file mode 100644 index 0000000000..09644321df --- /dev/null +++ b/packages/web/src/system/__tests__/Interactable.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; + +import { media } from '../../styles/media'; +import { defaultTheme } from '../../themes/defaultTheme'; +import { Interactable } from '../Interactable'; +import { MediaQueryContext } from '../MediaQueryProvider'; +import { ThemeProvider } from '../ThemeProvider'; + +const responsiveBorderColor = { + base: 'bgLine', + phone: 'bgNegative', + tablet: 'bgPositive', + desktop: 'bgPrimary', +} as const; + +const renderWithWidth = (width?: number) => { + const mediaContextValue = width + ? { + subscribe: () => () => undefined, + getServerSnapshot: () => false, + getSnapshot: (query: string) => { + if (query === media.phone) return width <= 767; + if (query === media.tablet) return width >= 768 && width <= 1279; + if (query === media.desktop) return width >= 1280; + return false; + }, + } + : null; + + const content = ( + + + + ); + + return render( + mediaContextValue ? ( + {content} + ) : ( + content + ), + ); +}; + +describe('Interactable', () => { + it.each([ + ['phone', 500, 'bgNegative'], + ['tablet', 900, 'bgPositive'], + ['desktop', 1400, 'bgPrimary'], + ])('resolves %s borderColor from responsive prop', (_, width, expectedToken) => { + renderWithWidth(width); + const element = screen.getByTestId('interactable'); + expect(element.style.getPropertyValue('--inter-borderColor')).toBe( + `var(--color-${expectedToken})`, + ); + }); + + it('falls back to base value without MediaQueryProvider', () => { + renderWithWidth(); + + const element = screen.getByTestId('interactable'); + expect(element.style.getPropertyValue('--inter-borderColor')).toBe( + `var(--color-${responsiveBorderColor.base})`, + ); + }); +}); diff --git a/packages/web/src/system/__tests__/MediaQueryProvider.test.tsx b/packages/web/src/system/__tests__/MediaQueryProvider.test.tsx index 1c6210e733..78cc7b36f6 100644 --- a/packages/web/src/system/__tests__/MediaQueryProvider.test.tsx +++ b/packages/web/src/system/__tests__/MediaQueryProvider.test.tsx @@ -1,6 +1,5 @@ import React, { useContext, useEffect, useState } from 'react'; -import { act, render, screen } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, render, renderHook, screen } from '@testing-library/react'; // Mock the listener utilities import * as MediaQueryListenerUtils from '../../utils/mediaQueryListener'; @@ -141,9 +140,7 @@ describe('createMediaQueryStore client-side logic', () => { useEffect(() => { const callback = () => { - act(() => { - setMatches(store.getSnapshot(query)); - }); + setMatches(store.getSnapshot(query)); }; const unsubscribe = store.subscribe(query, callback); callback(); diff --git a/packages/web/src/system/interactableCSSProperties.ts b/packages/web/src/system/interactableCSSProperties.ts index 7ab949cb51..334c870275 100644 --- a/packages/web/src/system/interactableCSSProperties.ts +++ b/packages/web/src/system/interactableCSSProperties.ts @@ -1,14 +1,14 @@ -export const interactableBorderRadius = '--interactable-border-radius'; -export const interactableBackground = '--interactable-background'; -export const interactableBorderColor = '--interactable-border-color'; +export const interactableBorderRadius = '--inter-borderRadius'; +export const interactableBackground = '--inter-bg'; +export const interactableBorderColor = '--inter-borderColor'; // Pressed: -export const interactablePressedBackground = '--interactable-pressed-background'; -export const interactablePressedBorderColor = '--interactable-pressed-border-color'; -export const interactablePressedOpacity = '--interactable-pressed-opacity'; +export const interactablePressedBackground = '--inter-press-bg'; +export const interactablePressedBorderColor = '--inter-press-borderColor'; +export const interactablePressedOpacity = '--inter-press-opacity'; // Hovered: -export const interactableHoveredBackground = '--interactable-hovered-background'; -export const interactableHoveredBorderColor = '--interactable-hovered-border-color'; -export const interactableHoveredOpacity = '--interactable-hovered-opacity'; +export const interactableHoveredBackground = '--inter-hover-bg'; +export const interactableHoveredBorderColor = '--inter-hover-borderColor'; +export const interactableHoveredOpacity = '--inter-hover-opacity'; // Disabled: -export const interactableDisabledBackground = '--interactable-disabled-background'; -export const interactableDisabledBorderColor = '--interactable-disabled-border-color'; +export const interactableDisabledBackground = '--inter-disable-bg'; +export const interactableDisabledBorderColor = '--inter-disable-borderColor'; diff --git a/packages/web/src/tables/Table.tsx b/packages/web/src/tables/Table.tsx index 44979b43be..afb70c93d3 100644 --- a/packages/web/src/tables/Table.tsx +++ b/packages/web/src/tables/Table.tsx @@ -1,5 +1,4 @@ import React, { forwardRef, memo, useMemo } from 'react'; -import type { DimensionValue } from '@coinbase/cds-common/types/DimensionStyles'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; import { css, type LinariaClassName } from '@linaria/core'; @@ -50,9 +49,9 @@ export type TableProps = SharedProps & /** Use compact cell spacing. If set, cellSpacing will override these defaults */ compact?: boolean; /** Set a fixed height. */ - height?: DimensionValue; + height?: React.CSSProperties['height']; /** Set a maximum height. */ - maxHeight?: DimensionValue; + maxHeight?: React.CSSProperties['maxHeight']; /** * @danger This is an escape hatch. It is not intended to be used normally. */ diff --git a/packages/web/src/tables/TableCell.tsx b/packages/web/src/tables/TableCell.tsx index 4f34bcab6f..35b558ef4e 100644 --- a/packages/web/src/tables/TableCell.tsx +++ b/packages/web/src/tables/TableCell.tsx @@ -6,6 +6,7 @@ import { isDevelopment } from '@coinbase/cds-utils'; import { css } from '@linaria/core'; import { Cell, type CellBaseProps } from '../cells/Cell'; +import type { CellAccessoryProps } from '../cells/CellAccessory'; import { cx } from '../cx'; import { Box } from '../layout/Box'; import { Text, type TextBaseProps } from '../typography/Text'; @@ -33,7 +34,7 @@ type TableCellBaseProps = TableCellSharedProps & { * Element (icon, asset, image, etc) to display at the end of the cell * @default undefined */ - end?: React.ReactElement; + end?: React.ReactElement; /** * The color for all text components rendered inside the TableCell. * Use titleColor and subtitleColor if you need to be more specific diff --git a/packages/web/src/tables/__stories__/TableCell.stories.tsx b/packages/web/src/tables/__stories__/TableCell.stories.tsx index 48dcba7ff9..b5ed2c820a 100644 --- a/packages/web/src/tables/__stories__/TableCell.stories.tsx +++ b/packages/web/src/tables/__stories__/TableCell.stories.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { css } from '@linaria/core'; -import type { Meta, Story } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Accordion, AccordionItem } from '../../accordion'; import { LoremIpsum } from '../../layout/__stories__/LoremIpsum'; @@ -11,10 +11,13 @@ import { defaultTheme } from '../../themes/defaultTheme'; import { Text } from '../../typography/Text'; import { Table, TableBody, TableCell, TableFooter, TableHeader, TableRow } from '..'; -export default { +const meta: Meta = { title: 'Components/Table/TableCell', component: TableCell, -} as Meta; +}; + +export default meta; +type Story = StoryObj; const handleClick = console.log; @@ -28,8 +31,8 @@ const spacingConfig = { huge: { padding: 7 }, } as const; -export const CellSpacing: Story = () => { - return ( +export const CellSpacing: Story = { + render: () => ( @@ -42,15 +45,15 @@ export const CellSpacing: Story = () => {
- ); + ), }; const flexCss = css` display: flex; `; -export const VerticallyAlignedTableCell: Story = () => { - return ( +export const VerticallyAlignedTableCell: Story = { + render: () => ( @@ -72,11 +75,11 @@ export const VerticallyAlignedTableCell: Story = () => {
- ); + ), }; -export const ComplexSpacingOverride: Story = () => { - return ( +export const ComplexSpacingOverride: Story = { + render: () => ( This story is complex on purpose - it is intended to provide visgreg testing to ensure crazy @@ -157,11 +160,11 @@ export const ComplexSpacingOverride: Story = () => { - ); + ), }; -export const SampleCells: Story = () => { - return ( +export const SampleCells: Story = { + render: () => ( @@ -209,11 +212,11 @@ export const SampleCells: Story = () => {
- ); + ), }; -export const SampleFixedLayout: Story = () => { - return ( +export const SampleFixedLayout: Story = { + render: () => ( @@ -261,5 +264,5 @@ export const SampleFixedLayout: Story = () => {
- ); + ), }; diff --git a/packages/web/src/tables/__stories__/TableCellFallback.stories.tsx b/packages/web/src/tables/__stories__/TableCellFallback.stories.tsx index f286523e01..0aad5928f5 100644 --- a/packages/web/src/tables/__stories__/TableCellFallback.stories.tsx +++ b/packages/web/src/tables/__stories__/TableCellFallback.stories.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useToggler } from '@coinbase/cds-common/hooks/useToggler'; -import type { Meta, Story } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { CellMedia, type CellMediaType } from '../../cells/CellMedia'; import { Switch } from '../../controls/Switch'; @@ -11,14 +11,18 @@ import { Text } from '../../typography/Text'; import { assetHubMock } from '../__mocks__'; import { Table, TableBody, TableCell, TableCellFallback, TableHeader, TableRow } from '..'; -export default { +const meta: Meta = { title: 'Components/Table/TableCellFallback', component: TableCellFallback, -} as Meta; +}; + +export default meta; +type Story = StoryObj; const LABELS = ['name', 'ticker', 'appStatus']; const mediaTypes: CellMediaType[] = ['asset', 'avatar', 'icon', 'image', 'pictogram']; -export const TableCellFallbackExample: Story = () => { + +const TableCellFallbackExampleRender = () => { const [loading, { toggle }] = useToggler(); const data = assetHubMock.slice(0, 20); @@ -100,3 +104,7 @@ export const TableCellFallbackExample: Story = () => { ); }; + +export const TableCellFallbackExample: Story = { + render: () => , +}; diff --git a/packages/web/src/tables/__stories__/TableRow.stories.tsx b/packages/web/src/tables/__stories__/TableRow.stories.tsx index 319b4a655c..3e19627320 100644 --- a/packages/web/src/tables/__stories__/TableRow.stories.tsx +++ b/packages/web/src/tables/__stories__/TableRow.stories.tsx @@ -1,19 +1,22 @@ import React from 'react'; -import type { Meta, Story } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../../buttons/Button'; import { Text } from '../../typography/Text'; import { Table, TableBody, TableCell, TableRow } from '..'; -export default { +const meta: Meta = { title: 'Components/Table/TableRow', component: TableRow, -} as Meta; +}; + +export default meta; +type Story = StoryObj; const handleClick = console.log; -export const TableRowExample: Story = () => { - return ( +export const TableRowExample: Story = { + render: () => ( @@ -49,5 +52,5 @@ export const TableRowExample: Story = () => {
- ); + ), }; diff --git a/packages/web/src/tables/__stories__/TableSection.stories.tsx b/packages/web/src/tables/__stories__/TableSection.stories.tsx index e1be5c562c..c8c0001360 100644 --- a/packages/web/src/tables/__stories__/TableSection.stories.tsx +++ b/packages/web/src/tables/__stories__/TableSection.stories.tsx @@ -1,18 +1,21 @@ import React from 'react'; -import type { Meta, Story } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { VStack } from '../../layout/VStack'; import { Spinner } from '../../loaders/Spinner'; import { Text } from '../../typography/Text'; import { Table, TableBody, TableCell, TableFooter, TableHeader, TableRow } from '..'; -export default { +const meta: Meta = { title: 'Components/Table/TableSection', component: TableBody, -} as Meta; +}; + +export default meta; +type Story = StoryObj; -export const SampleTableSection: Story = () => { - return ( +export const SampleTableSection: Story = { + render: () => ( @@ -30,11 +33,11 @@ export const SampleTableSection: Story = () => {
- ); + ), }; -export const SectionFlowControl: Story = () => { - return ( +export const SectionFlowControl: Story = { + render: () => ( @@ -52,11 +55,11 @@ export const SectionFlowControl: Story = () => {
- ); + ), }; -export const LoadingStateExample: Story = () => { - return ( +export const LoadingStateExample: Story = { + render: () => ( @@ -72,5 +75,5 @@ export const LoadingStateExample: Story = () => {
- ); + ), }; diff --git a/packages/web/src/tables/hooks/__tests__/useSortableCell.test.tsx b/packages/web/src/tables/hooks/__tests__/useSortableCell.test.tsx index e7679cea23..334204b018 100644 --- a/packages/web/src/tables/hooks/__tests__/useSortableCell.test.tsx +++ b/packages/web/src/tables/hooks/__tests__/useSortableCell.test.tsx @@ -1,5 +1,5 @@ import { noop } from '@coinbase/cds-utils'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { TableCellSortIcon } from '../../TableCellSortIcon'; import { useSortableCell } from '../useSortableCell'; diff --git a/packages/web/src/tables/hooks/__tests__/useTable.test.tsx b/packages/web/src/tables/hooks/__tests__/useTable.test.tsx index b12ebc51e8..bb08fc123b 100644 --- a/packages/web/src/tables/hooks/__tests__/useTable.test.tsx +++ b/packages/web/src/tables/hooks/__tests__/useTable.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { Table } from '../../Table'; import { TableBody } from '../../TableBody'; @@ -128,8 +128,11 @@ describe('useTableTag', () => { }); it('Get cell spacing can skip as validation', () => { - const { result } = renderHook(() => useTableCellSpacing({ skipAsValidation: true })); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + renderHook(() => useTableCellSpacing({ skipAsValidation: true })); - expect(result.error).toBeUndefined(); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); }); }); diff --git a/packages/web/src/tables/hooks/__tests__/useTableRowListener.test.ts b/packages/web/src/tables/hooks/__tests__/useTableRowListener.test.ts index 704031270c..00d618ecc1 100644 --- a/packages/web/src/tables/hooks/__tests__/useTableRowListener.test.ts +++ b/packages/web/src/tables/hooks/__tests__/useTableRowListener.test.ts @@ -1,5 +1,5 @@ import { noop } from '@coinbase/cds-utils'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { TableRowRef } from '../../TableRow'; import { useTableRowListener } from '../useTableRowListener'; diff --git a/packages/web/src/tables/hooks/__tests__/useTableVariant.test.tsx b/packages/web/src/tables/hooks/__tests__/useTableVariant.test.tsx index 3bd228b48f..a2f7a10dfd 100644 --- a/packages/web/src/tables/hooks/__tests__/useTableVariant.test.tsx +++ b/packages/web/src/tables/hooks/__tests__/useTableVariant.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { Table, TableBody } from '../../index'; import { useTableVariant } from '../useTableVariant'; diff --git a/packages/web/src/tabs/Paddle.tsx b/packages/web/src/tabs/Paddle.tsx index e865f26c68..504b2acb27 100644 --- a/packages/web/src/tabs/Paddle.tsx +++ b/packages/web/src/tabs/Paddle.tsx @@ -124,7 +124,13 @@ export const Paddle = ({ color={background} data-testid={`${testID}--container`} > - + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} + )} + > + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} )} />
)} diff --git a/packages/web/src/tabs/TabLabel.tsx b/packages/web/src/tabs/TabLabel.tsx index 97de8c742e..7495c9c0c3 100644 --- a/packages/web/src/tabs/TabLabel.tsx +++ b/packages/web/src/tabs/TabLabel.tsx @@ -106,13 +106,13 @@ export const TabLabel = memo( {/* This element is used to ensure the element width doesn't change when we change font-weight */}