@oxyhq/bloom 0.51.1 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js +7 -4
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/commonjs/connection-dots/ConnectionDots.web.js.map +1 -1
- package/lib/commonjs/design-tokens/shadows.js +4 -0
- package/lib/commonjs/design-tokens/shadows.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.web.js +26 -10
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/frosted-icon-button/shared.js +10 -0
- package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
- package/lib/commonjs/index.js +66 -66
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +90 -90
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/loading/Loading.web.js.map +1 -1
- package/lib/commonjs/loading/SpinnerIcon.web.js +10 -6
- package/lib/commonjs/loading/SpinnerIcon.web.js.map +1 -1
- package/lib/commonjs/motion/index.js +1 -1
- package/lib/commonjs/motion/index.web.js +32 -0
- package/lib/commonjs/motion/index.web.js.map +1 -0
- package/lib/commonjs/motion/motion.js +10 -2
- package/lib/commonjs/motion/motion.js.map +1 -1
- package/lib/commonjs/motion/motion.web.js +137 -0
- package/lib/commonjs/motion/motion.web.js.map +1 -0
- package/lib/commonjs/progressive-blur/index.web.js.map +1 -1
- package/lib/commonjs/styles/web-view-style.js +33 -0
- package/lib/commonjs/styles/web-view-style.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +46 -7
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/glyph.js +9 -4
- package/lib/commonjs/tab-bar/glyph.js.map +1 -1
- package/lib/commonjs/tab-bar/glyph.native.js +7 -2
- package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
- package/lib/commonjs/tab-bar/index.js +7 -0
- package/lib/commonjs/tab-bar/index.js.map +1 -1
- package/lib/commonjs/tab-bar/index.web.js +7 -0
- package/lib/commonjs/tab-bar/index.web.js.map +1 -1
- package/lib/commonjs/tab-bar/shared.js +32 -1
- package/lib/commonjs/tab-bar/shared.js.map +1 -1
- package/lib/commonjs/tab-bar/surface.web.js.map +1 -1
- package/lib/commonjs/tab-bar/use-footprint.js +56 -0
- package/lib/commonjs/tab-bar/use-footprint.js.map +1 -0
- package/lib/commonjs/toast/ToastRow.js +7 -3
- package/lib/commonjs/toast/ToastRow.js.map +1 -1
- package/lib/commonjs/toast/ToastSwipeHandler.js +29 -13
- package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
- package/lib/commonjs/toast/constants.js +29 -1
- package/lib/commonjs/toast/constants.js.map +1 -1
- package/lib/commonjs/toast/position-utils.js +9 -4
- package/lib/commonjs/toast/position-utils.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +12 -9
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/module/bottom-sheet/BottomSheetBase.js +7 -4
- package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -1
- package/lib/module/design-tokens/shadows.js +4 -0
- package/lib/module/design-tokens/shadows.js.map +1 -1
- package/lib/module/dialog/Dialog.js.map +1 -1
- package/lib/module/dialog/Dialog.web.js +26 -10
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/frosted-icon-button/shared.js +10 -0
- package/lib/module/frosted-icon-button/shared.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/loading/Loading.web.js.map +1 -1
- package/lib/module/loading/SpinnerIcon.web.js +10 -6
- package/lib/module/loading/SpinnerIcon.web.js.map +1 -1
- package/lib/module/motion/index.js +1 -1
- package/lib/module/motion/index.js.map +1 -1
- package/lib/module/motion/index.web.js +10 -0
- package/lib/module/motion/index.web.js.map +1 -0
- package/lib/module/motion/motion.js +10 -2
- package/lib/module/motion/motion.js.map +1 -1
- package/lib/module/motion/motion.web.js +133 -0
- package/lib/module/motion/motion.web.js.map +1 -0
- package/lib/module/progressive-blur/index.web.js.map +1 -1
- package/lib/module/styles/web-view-style.js +33 -0
- package/lib/module/styles/web-view-style.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +47 -8
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/glyph.js +9 -4
- package/lib/module/tab-bar/glyph.js.map +1 -1
- package/lib/module/tab-bar/glyph.native.js +7 -2
- package/lib/module/tab-bar/glyph.native.js.map +1 -1
- package/lib/module/tab-bar/index.js +1 -0
- package/lib/module/tab-bar/index.js.map +1 -1
- package/lib/module/tab-bar/index.web.js +1 -0
- package/lib/module/tab-bar/index.web.js.map +1 -1
- package/lib/module/tab-bar/shared.js +30 -0
- package/lib/module/tab-bar/shared.js.map +1 -1
- package/lib/module/tab-bar/surface.web.js.map +1 -1
- package/lib/module/tab-bar/use-footprint.js +52 -0
- package/lib/module/tab-bar/use-footprint.js.map +1 -0
- package/lib/module/toast/ToastRow.js +8 -4
- package/lib/module/toast/ToastRow.js.map +1 -1
- package/lib/module/toast/ToastSwipeHandler.js +29 -13
- package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
- package/lib/module/toast/constants.js +28 -1
- package/lib/module/toast/constants.js.map +1 -1
- package/lib/module/toast/position-utils.js +9 -4
- package/lib/module/toast/position-utils.js.map +1 -1
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +12 -9
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/design-tokens/shadows.d.ts +4 -0
- package/lib/typescript/commonjs/design-tokens/shadows.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +10 -0
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -2
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/loading/Loading.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/loading/SpinnerIcon.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/motion/index.web.d.ts +9 -0
- package/lib/typescript/commonjs/motion/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/motion/motion.d.ts +14 -6
- package/lib/typescript/commonjs/motion/motion.d.ts.map +1 -1
- package/lib/typescript/commonjs/motion/motion.web.d.ts +79 -0
- package/lib/typescript/commonjs/motion/motion.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/progressive-blur/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/styles/web-view-style.d.ts +49 -0
- package/lib/typescript/commonjs/styles/web-view-style.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
- package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
- package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
- package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/surface.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +37 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts +31 -0
- package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts.map +1 -0
- package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +12 -6
- package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/constants.d.ts +26 -8
- package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/position-utils.d.ts +8 -3
- package/lib/typescript/commonjs/toast/position-utils.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/shadows.d.ts +4 -0
- package/lib/typescript/module/design-tokens/shadows.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/frosted-icon-button/shared.d.ts +10 -0
- package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -2
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/loading/Loading.web.d.ts.map +1 -1
- package/lib/typescript/module/loading/SpinnerIcon.web.d.ts.map +1 -1
- package/lib/typescript/module/motion/index.web.d.ts +9 -0
- package/lib/typescript/module/motion/index.web.d.ts.map +1 -0
- package/lib/typescript/module/motion/motion.d.ts +14 -6
- package/lib/typescript/module/motion/motion.d.ts.map +1 -1
- package/lib/typescript/module/motion/motion.web.d.ts +79 -0
- package/lib/typescript/module/motion/motion.web.d.ts.map +1 -0
- package/lib/typescript/module/progressive-blur/index.web.d.ts.map +1 -1
- package/lib/typescript/module/styles/web-view-style.d.ts +49 -0
- package/lib/typescript/module/styles/web-view-style.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
- package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
- package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/index.d.ts +1 -0
- package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
- package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
- package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/surface.web.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +37 -1
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/use-footprint.d.ts +31 -0
- package/lib/typescript/module/tab-bar/use-footprint.d.ts.map +1 -0
- package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
- package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +12 -6
- package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
- package/lib/typescript/module/toast/constants.d.ts +26 -8
- package/lib/typescript/module/toast/constants.d.ts.map +1 -1
- package/lib/typescript/module/toast/position-utils.d.ts +8 -3
- package/lib/typescript/module/toast/position-utils.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/__tests__/Motion.test.tsx +31 -3
- package/src/__tests__/MotionWebFork.test.ts +140 -0
- package/src/__tests__/TabBar.test.tsx +82 -2
- package/src/__tests__/TabBarAnimatedShorthand.test.ts +135 -0
- package/src/__tests__/TabBarFootprint.test.tsx +172 -0
- package/src/__tests__/TabBarLongPress.test.tsx +258 -0
- package/src/__tests__/TabBarWebFork.test.ts +1 -0
- package/src/__tests__/ToastHostWebFork.test.ts +6 -2
- package/src/__tests__/Toaster.test.tsx +62 -1
- package/src/__tests__/toast-position-utils.test.ts +23 -7
- package/src/__tests__/web-css-style.test.ts +139 -0
- package/src/bottom-sheet/BottomSheetBase.tsx +11 -7
- package/src/connection-dots/ConnectionDots.web.tsx +4 -3
- package/src/design-tokens/shadows.ts +5 -1
- package/src/dialog/Dialog.tsx +1 -1
- package/src/dialog/Dialog.web.tsx +41 -26
- package/src/frosted-icon-button/shared.ts +10 -0
- package/src/index.web.ts +2 -2
- package/src/loading/Loading.web.tsx +7 -5
- package/src/loading/SpinnerIcon.web.tsx +12 -9
- package/src/motion/index.web.ts +8 -0
- package/src/motion/motion.ts +14 -6
- package/src/motion/motion.web.ts +99 -0
- package/src/progressive-blur/index.web.tsx +4 -3
- package/src/styles/web-view-style.ts +50 -0
- package/src/tab-bar/TabBarBase.tsx +54 -15
- package/src/tab-bar/glyph.native.tsx +5 -2
- package/src/tab-bar/glyph.tsx +8 -4
- package/src/tab-bar/index.ts +1 -0
- package/src/tab-bar/index.web.tsx +1 -0
- package/src/tab-bar/shared.ts +38 -0
- package/src/tab-bar/surface.web.tsx +4 -3
- package/src/tab-bar/types.ts +37 -1
- package/src/tab-bar/use-footprint.ts +50 -0
- package/src/toast/ToastRow.tsx +8 -3
- package/src/toast/ToastSwipeHandler.tsx +30 -12
- package/src/toast/constants.ts +28 -0
- package/src/toast/position-utils.ts +10 -5
- package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +9 -8
|
@@ -5,16 +5,21 @@ export declare const getOrderedToastIds: (toasts: ToastProps[], position: ToastP
|
|
|
5
5
|
*
|
|
6
6
|
* Scale rather than width: changing the real width would rewrap the text. Returns
|
|
7
7
|
* 1 (no squeeze) for a single row, an expanded stack, stacking turned off, or
|
|
8
|
-
* before the
|
|
8
|
+
* before the row width is known.
|
|
9
|
+
*
|
|
10
|
+
* `rowWidth` is the row's OWN width, not the window's. Upstream can use the
|
|
11
|
+
* screen width because its row is always full-bleed; Bloom's is capped at
|
|
12
|
+
* `TOAST_MAX_ROW_WIDTH`, and dividing a fixed `stackGap` inset by a 1280px window
|
|
13
|
+
* would shrink a buried row by 4 visible pixels instead of the intended 16.
|
|
9
14
|
*/
|
|
10
|
-
export declare const calculateStackScaleX: ({ index, numberOfToasts, enableStacking, position, isExpanded, stackGap,
|
|
15
|
+
export declare const calculateStackScaleX: ({ index, numberOfToasts, enableStacking, position, isExpanded, stackGap, rowWidth, }: {
|
|
11
16
|
index: number;
|
|
12
17
|
numberOfToasts: number;
|
|
13
18
|
enableStacking: boolean;
|
|
14
19
|
position: ToastPosition;
|
|
15
20
|
isExpanded: boolean;
|
|
16
21
|
stackGap: number;
|
|
17
|
-
|
|
22
|
+
rowWidth: number;
|
|
18
23
|
}) => number;
|
|
19
24
|
export declare const calculateToastPosition: ({ index, numberOfToasts, enableStacking, position, allToastHeights, gap, orderedToastIds, isExpanded, stackGap, }: {
|
|
20
25
|
index: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"position-utils.d.ts","sourceRoot":"","sources":["../../../../src/toast/position-utils.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAEzD,eAAO,MAAM,kBAAkB,GAC7B,QAAQ,UAAU,EAAE,EACpB,UAAU,aAAa,EACvB,gBAAgB,OAAO,KACtB,KAAK,CAAC,MAAM,GAAG,MAAM,CAQvB,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"position-utils.d.ts","sourceRoot":"","sources":["../../../../src/toast/position-utils.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAEzD,eAAO,MAAM,kBAAkB,GAC7B,QAAQ,UAAU,EAAE,EACpB,UAAU,aAAa,EACvB,gBAAgB,OAAO,KACtB,KAAK,CAAC,MAAM,GAAG,MAAM,CAQvB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,GAAI,sFAQlC;IACD,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,OAAO,CAAC;IACxB,QAAQ,EAAE,aAAa,CAAC;IACxB,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,KAAG,MASH,CAAC;AAaF,eAAO,MAAM,sBAAsB,GAAI,mHAUpC;IACD,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,OAAO,CAAC;IACxB,QAAQ,EAAE,aAAa,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC,MAAM,GAAG,MAAM,EAAE,MAAM,CAAC,CAAC;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;IACxC,UAAU,EAAE,OAAO,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB,KAAG,MAoDH,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ZoomableImageGallery.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/ZoomableImageGallery.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AAkEjF,OAAO,KAAK,EAGV,0BAA0B,EAC1B,yBAAyB,EAC1B,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"ZoomableImageGallery.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/ZoomableImageGallery.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AAkEjF,OAAO,KAAK,EAGV,0BAA0B,EAC1B,yBAAyB,EAC1B,MAAM,SAAS,CAAC;AA64BjB,eAAO,MAAM,oBAAoB,8GAA4B,CAAC;AAyK9D,eAAe,oBAAoB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/bloom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.1",
|
|
4
4
|
"description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -477,6 +477,11 @@
|
|
|
477
477
|
},
|
|
478
478
|
"./motion": {
|
|
479
479
|
"react-native": "./src/motion/index.ts",
|
|
480
|
+
"browser": {
|
|
481
|
+
"types": "./lib/typescript/module/motion/index.web.d.ts",
|
|
482
|
+
"import": "./lib/module/motion/index.web.js",
|
|
483
|
+
"require": "./lib/commonjs/motion/index.web.js"
|
|
484
|
+
},
|
|
480
485
|
"import": {
|
|
481
486
|
"types": "./lib/typescript/module/motion/index.d.ts",
|
|
482
487
|
"default": "./lib/module/motion/index.js"
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Text } from 'react-native';
|
|
3
3
|
import { render } from '@testing-library/react-native';
|
|
4
|
+
import type {
|
|
5
|
+
EntryOrExitLayoutType,
|
|
6
|
+
LayoutAnimation,
|
|
7
|
+
} from 'react-native-reanimated';
|
|
4
8
|
|
|
5
9
|
import {
|
|
6
10
|
ScaleAndFadeIn,
|
|
@@ -12,21 +16,45 @@ import {
|
|
|
12
16
|
// The reanimated mock resolves `withTiming(v)` -> v, `withDelay(d, v)` -> v and
|
|
13
17
|
// `withSequence(...v)` -> last, so the builder output is deterministic.
|
|
14
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The presets are typed as reanimated's `entering`/`exiting` union so the two
|
|
21
|
+
* platform files present ONE contract and nothing can depend on the mechanism.
|
|
22
|
+
* These tests are about the native mechanism specifically, so they narrow back
|
|
23
|
+
* down through a runtime check rather than a cast: a predefined builder is a
|
|
24
|
+
* class (it carries the `createInstance` static), a custom worklet builder is a
|
|
25
|
+
* plain function.
|
|
26
|
+
*/
|
|
27
|
+
const isWorkletBuilder = (
|
|
28
|
+
preset: EntryOrExitLayoutType,
|
|
29
|
+
): preset is () => LayoutAnimation =>
|
|
30
|
+
// A predefined builder is a class and carries the `createInstance` static; a
|
|
31
|
+
// custom worklet builder is a plain nullary function.
|
|
32
|
+
typeof preset === 'function' &&
|
|
33
|
+
!('createInstance' in preset) &&
|
|
34
|
+
preset.length === 0;
|
|
35
|
+
|
|
36
|
+
const runNativeBuilder = (preset: EntryOrExitLayoutType): LayoutAnimation => {
|
|
37
|
+
if (!isWorkletBuilder(preset)) {
|
|
38
|
+
throw new Error('expected the native preset to be a custom worklet builder');
|
|
39
|
+
}
|
|
40
|
+
return preset();
|
|
41
|
+
};
|
|
42
|
+
|
|
15
43
|
describe('motion presets', () => {
|
|
16
44
|
it('ScaleAndFadeIn enters from 70% scale and transparent', () => {
|
|
17
|
-
const { initialValues, animations } = ScaleAndFadeIn
|
|
45
|
+
const { initialValues, animations } = runNativeBuilder(ScaleAndFadeIn);
|
|
18
46
|
expect(initialValues).toEqual({ opacity: 0, transform: [{ scale: 0.7 }] });
|
|
19
47
|
expect(animations).toEqual({ opacity: 1, transform: [{ scale: 1 }] });
|
|
20
48
|
});
|
|
21
49
|
|
|
22
50
|
it('ScaleAndFadeOut exits to 70% scale and transparent', () => {
|
|
23
|
-
const { initialValues, animations } = ScaleAndFadeOut
|
|
51
|
+
const { initialValues, animations } = runNativeBuilder(ScaleAndFadeOut);
|
|
24
52
|
expect(initialValues).toEqual({ opacity: 1, transform: [{ scale: 1 }] });
|
|
25
53
|
expect(animations).toEqual({ opacity: 0, transform: [{ scale: 0.7 }] });
|
|
26
54
|
});
|
|
27
55
|
|
|
28
56
|
it('ShrinkAndPop overshoots to 110% before leaving', () => {
|
|
29
|
-
const { initialValues, animations } = ShrinkAndPop
|
|
57
|
+
const { initialValues, animations } = runNativeBuilder(ShrinkAndPop);
|
|
30
58
|
expect(initialValues).toEqual({ opacity: 1, transform: [{ scale: 1 }] });
|
|
31
59
|
// withSequence resolves to its final keyframe (the 1.1 overshoot) in the mock.
|
|
32
60
|
expect(animations.transform).toEqual([{ scale: 1.1 }]);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for the `motion` web fork.
|
|
3
|
+
*
|
|
4
|
+
* The bug: `motion.ts`'s custom worklet builders are INERT on web. Reanimated's
|
|
5
|
+
* web layout-animation manager resolves an animation by preset name, a custom
|
|
6
|
+
* builder has none, and `startWebLayoutAnimation` falls through to
|
|
7
|
+
* `makeElementVisible` — the element appears at its final frame with one
|
|
8
|
+
* `[Reanimated] Couldn't load entering/exiting animation` warning apiece.
|
|
9
|
+
*
|
|
10
|
+
* The trap: rebuilding all three as `Keyframe`s fixes the exits and makes the
|
|
11
|
+
* ENTER worse. Reanimated's `entering` path calls `setElementAnimation` with
|
|
12
|
+
* `shouldSavePosition` TRUE, and any animation name absent from its built-in
|
|
13
|
+
* `Animations` map — which every custom `Keyframe` is, since they mint a fresh
|
|
14
|
+
* `REA-ENTERING-n` rule — also gets a `scheduleAnimationCleanup` that pins the
|
|
15
|
+
* element with `position: absolute` and a frozen box at `duration x 5`. That is
|
|
16
|
+
* failure mode (C) in `~/Oxy/AGENTS.md`.
|
|
17
|
+
*
|
|
18
|
+
* So the invariant is asymmetric, and BOTH halves are load-bearing:
|
|
19
|
+
* entering -> a PREDEFINED builder (its `presetName` is in `Animations`, so no
|
|
20
|
+
* cleanup is ever scheduled), never a `Keyframe`.
|
|
21
|
+
* exiting -> a `Keyframe` is safe (the cleanup only reaps the throwaway clone)
|
|
22
|
+
* and is the only mechanism that can express the full shape.
|
|
23
|
+
*
|
|
24
|
+
* The actual motion is verified in a real browser — jest cannot run reanimated's
|
|
25
|
+
* web layout-animation manager.
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
import { Keyframe } from 'react-native-reanimated';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
ScaleAndFadeIn,
|
|
34
|
+
ScaleAndFadeOut,
|
|
35
|
+
ShrinkAndPop,
|
|
36
|
+
} from '../motion/motion.web';
|
|
37
|
+
|
|
38
|
+
const SRC = join(__dirname, '..');
|
|
39
|
+
const read = (rel: string) => readFileSync(join(SRC, rel), 'utf8');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Upstream's `ReanimatedKeyframe` type hides the instance state that the web
|
|
43
|
+
* layout manager actually reads (`createCustomKeyFrameAnimation` takes
|
|
44
|
+
* `config.definitions` raw, `getDurationFromConfig` takes `config.durationV`), so
|
|
45
|
+
* the test narrows to it with a runtime-checked predicate rather than a cast.
|
|
46
|
+
*/
|
|
47
|
+
type KeyframeState = {
|
|
48
|
+
definitions: Record<string, Record<string, unknown>>;
|
|
49
|
+
durationV: number | undefined;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isKeyframeState = (value: unknown): value is KeyframeState =>
|
|
53
|
+
value instanceof Keyframe && 'definitions' in value;
|
|
54
|
+
|
|
55
|
+
const stateOf = (preset: unknown): KeyframeState => {
|
|
56
|
+
if (!isKeyframeState(preset)) {
|
|
57
|
+
throw new Error('expected a Keyframe');
|
|
58
|
+
}
|
|
59
|
+
return preset;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('motion web fork', () => {
|
|
63
|
+
it('never hands a Keyframe to `entering`', () => {
|
|
64
|
+
// The whole reason the enter and the exits use different mechanisms.
|
|
65
|
+
expect(ScaleAndFadeIn instanceof Keyframe).toBe(false);
|
|
66
|
+
expect(typeof ScaleAndFadeIn).toBe('object');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('uses a predefined reanimated builder for the enter', () => {
|
|
70
|
+
const web = read('motion/motion.web.ts');
|
|
71
|
+
expect(web).toMatch(/export const ScaleAndFadeIn[^=]*=\s*FadeIn;/);
|
|
72
|
+
expect(web).toMatch(/import \{[\s\S]*\bFadeIn\b[\s\S]*\} from 'react-native-reanimated'/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('uses a Keyframe for both exits', () => {
|
|
76
|
+
expect(ScaleAndFadeOut instanceof Keyframe).toBe(true);
|
|
77
|
+
expect(ShrinkAndPop instanceof Keyframe).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps the full scale + opacity shape on ScaleAndFadeOut', () => {
|
|
81
|
+
expect(stateOf(ScaleAndFadeOut).definitions).toEqual({
|
|
82
|
+
from: { opacity: 1, transform: [{ scale: 1 }] },
|
|
83
|
+
to: { opacity: 0, transform: [{ scale: 0.7 }] },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every stop declares every animated property, so the CSS the web parser emits
|
|
89
|
+
* does not depend on cross-stop interpolation, and the intermediate values are
|
|
90
|
+
* the native timeline sampled at those instants (250ms total: scale 1 -> 0.7 by
|
|
91
|
+
* 75ms, -> 1.1 by 225ms; opacity held to 125ms, then to 0 by 250ms).
|
|
92
|
+
*/
|
|
93
|
+
it('samples the native ShrinkAndPop timeline at every stop', () => {
|
|
94
|
+
const stops = stateOf(ShrinkAndPop).definitions;
|
|
95
|
+
expect(Object.keys(stops)).toEqual(['0', '30', '50', '90', '100']);
|
|
96
|
+
for (const [percent, style] of Object.entries(stops)) {
|
|
97
|
+
expect({ percent, keys: Object.keys(style).sort() }).toEqual({
|
|
98
|
+
percent,
|
|
99
|
+
keys: ['opacity', 'transform'],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
expect(stops['0']).toEqual({ opacity: 1, transform: [{ scale: 1 }] });
|
|
103
|
+
expect(stops['30']).toEqual({ opacity: 1, transform: [{ scale: 0.7 }] });
|
|
104
|
+
expect(stops['90']).toEqual({ opacity: 0.2, transform: [{ scale: 1.1 }] });
|
|
105
|
+
expect(stops['100']).toEqual({ opacity: 0, transform: [{ scale: 1.1 }] });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('matches the native durations', () => {
|
|
109
|
+
// `withTiming`'s default for the pair the native file animates, and
|
|
110
|
+
// 75 + 150 for the pop's two sequenced scale legs.
|
|
111
|
+
expect(stateOf(ScaleAndFadeOut).durationV).toBe(300);
|
|
112
|
+
expect(stateOf(ShrinkAndPop).durationV).toBe(250);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('leaves the native file on its custom worklet builders', () => {
|
|
116
|
+
const native = read('motion/motion.ts');
|
|
117
|
+
expect(native).toMatch(/'worklet';/);
|
|
118
|
+
expect(native).not.toMatch(/\bKeyframe\b/);
|
|
119
|
+
expect(native).not.toMatch(/\bFadeIn\b/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('is wired as a web-forked subpath', () => {
|
|
123
|
+
const script = readFileSync(
|
|
124
|
+
join(SRC, '..', 'scripts', 'generate-platform-exports.mjs'),
|
|
125
|
+
'utf8',
|
|
126
|
+
);
|
|
127
|
+
// Between the WEB_FORKED_SUBPATHS opener and its closing bracket.
|
|
128
|
+
const forked = script.slice(
|
|
129
|
+
script.indexOf('const WEB_FORKED_SUBPATHS'),
|
|
130
|
+
script.indexOf(']);', script.indexOf('const WEB_FORKED_SUBPATHS')),
|
|
131
|
+
);
|
|
132
|
+
expect(forked).toContain("'./motion'");
|
|
133
|
+
|
|
134
|
+
const pkg: { exports: Record<string, { browser?: { import?: string } }> } =
|
|
135
|
+
JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8'));
|
|
136
|
+
expect(pkg.exports['./motion']?.browser?.import).toBe(
|
|
137
|
+
'./lib/module/motion/index.web.js',
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -46,6 +46,17 @@ const ITEMS: TabBarItem[] = [
|
|
|
46
46
|
{ name: 'you', label: 'You', icon: <BareIcon id="you-icon" /> },
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* An item whose selected state is a different SHAPE, not a different tint —
|
|
51
|
+
* the icon-set convention (outline vs filled) that `activeIcon` exists for.
|
|
52
|
+
*/
|
|
53
|
+
const SHAPE_ITEM: TabBarItem = {
|
|
54
|
+
name: 'home',
|
|
55
|
+
label: 'Home',
|
|
56
|
+
icon: <BareIcon id="outline-icon" />,
|
|
57
|
+
activeIcon: <BareIcon id="filled-icon" />,
|
|
58
|
+
};
|
|
59
|
+
|
|
49
60
|
/** Captures the live `ThemeColors` so assertions compare against real tokens. */
|
|
50
61
|
function ThemeProbe({ onColors }: { onColors: (colors: ThemeColors) => void }) {
|
|
51
62
|
onColors(useTheme().colors);
|
|
@@ -259,6 +270,75 @@ describe('TabBar', () => {
|
|
|
259
270
|
expect(flattenStyle(labelFor(UNSAFE_root, 'Home').props.style).color).toBe(theme.inactiveTint);
|
|
260
271
|
});
|
|
261
272
|
|
|
273
|
+
describe('activeIcon', () => {
|
|
274
|
+
function ShapeBar() {
|
|
275
|
+
return (
|
|
276
|
+
<TabBar activeIndex={0}>
|
|
277
|
+
<TabBarButton item={SHAPE_ITEM} index={0} />
|
|
278
|
+
</TabBar>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
it('renders the item icon underneath and activeIcon on the crossfade layer', () => {
|
|
283
|
+
// A tint crossfade cannot express an outline-to-filled swap: both layers
|
|
284
|
+
// would draw the same path in two colors. The layers must render two
|
|
285
|
+
// different NODES.
|
|
286
|
+
const { getAllByTestId } = renderWithTheme(<ShapeBar />);
|
|
287
|
+
expect(getAllByTestId('outline-icon')).toHaveLength(1);
|
|
288
|
+
expect(getAllByTestId('filled-icon')).toHaveLength(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('puts activeIcon on the ACTIVE layer, not the inactive one', () => {
|
|
292
|
+
// The tint each node was cloned with is what identifies its layer — the
|
|
293
|
+
// crossfading layer is the one painted with `activeTint`.
|
|
294
|
+
const theme = readTheme();
|
|
295
|
+
const { getAllByTestId } = renderWithTheme(<ShapeBar />);
|
|
296
|
+
expect(getAllByTestId('filled-icon')[0]?.props.fill).toBe(theme.activeTint);
|
|
297
|
+
expect(getAllByTestId('outline-icon')[0]?.props.fill).toBe(theme.inactiveTint);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('falls back to icon on both layers when the item has no activeIcon', () => {
|
|
301
|
+
// Full backward compatibility: an item that only carries `icon` behaves
|
|
302
|
+
// exactly as it did before `activeIcon` existed.
|
|
303
|
+
const theme = readTheme();
|
|
304
|
+
const { getAllByTestId } = renderWithTheme(<Bar activeIndex={0} />);
|
|
305
|
+
const fills = getAllByTestId('home-icon').map((node) => node.props.fill);
|
|
306
|
+
expect(fills).toHaveLength(2);
|
|
307
|
+
expect(fills).toEqual(expect.arrayContaining([theme.inactiveTint, theme.activeTint]));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('is honoured by the native glyph too, per crossfade layer', () => {
|
|
311
|
+
const theme = readTheme();
|
|
312
|
+
const active = renderWithTheme(
|
|
313
|
+
<NativeTabBarGlyph item={SHAPE_ITEM} tint={theme.activeTint} size={21} active />,
|
|
314
|
+
);
|
|
315
|
+
expect(active.queryByTestId('filled-icon')).toBeTruthy();
|
|
316
|
+
expect(active.queryByTestId('outline-icon')).toBeNull();
|
|
317
|
+
|
|
318
|
+
const inactive = renderWithTheme(
|
|
319
|
+
<NativeTabBarGlyph item={SHAPE_ITEM} tint={theme.inactiveTint} size={21} active={false} />,
|
|
320
|
+
);
|
|
321
|
+
expect(inactive.queryByTestId('outline-icon')).toBeTruthy();
|
|
322
|
+
expect(inactive.queryByTestId('filled-icon')).toBeNull();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('keeps the SF Symbol path on iOS, which is tinted natively', () => {
|
|
326
|
+
// A symbol IS tinted per layer by the system, so the tint crossfade is
|
|
327
|
+
// still the right expression there and `activeIcon` stays unused.
|
|
328
|
+
const theme = readTheme();
|
|
329
|
+
const { UNSAFE_root, queryByTestId } = renderWithTheme(
|
|
330
|
+
<NativeTabBarGlyph
|
|
331
|
+
item={{ ...SHAPE_ITEM, sfSymbol: 'house.fill' }}
|
|
332
|
+
tint={theme.activeTint}
|
|
333
|
+
size={21}
|
|
334
|
+
active
|
|
335
|
+
/>,
|
|
336
|
+
);
|
|
337
|
+
expect(hosts(UNSAFE_root, 'SymbolView')).toHaveLength(1);
|
|
338
|
+
expect(queryByTestId('filled-icon')).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
262
342
|
describe('theming', () => {
|
|
263
343
|
it('resolves its defaults from Bloom tokens, never a hardcoded palette', () => {
|
|
264
344
|
const theme = readTheme();
|
|
@@ -322,7 +402,7 @@ describe('TabBar', () => {
|
|
|
322
402
|
const theme = readTheme();
|
|
323
403
|
const item: TabBarItem = { ...(ITEMS[0] as TabBarItem), sfSymbol: 'house.fill' };
|
|
324
404
|
const { UNSAFE_root } = renderWithTheme(
|
|
325
|
-
<NativeTabBarGlyph item={item} tint={theme.activeTint} size={21} />,
|
|
405
|
+
<NativeTabBarGlyph item={item} tint={theme.activeTint} size={21} active />,
|
|
326
406
|
);
|
|
327
407
|
const symbol = hosts(UNSAFE_root, 'SymbolView')[0];
|
|
328
408
|
expect(symbol?.props.name).toBe('house.fill');
|
|
@@ -333,7 +413,7 @@ describe('TabBar', () => {
|
|
|
333
413
|
it('falls back to the item icon when no SF Symbol is given', () => {
|
|
334
414
|
const theme = readTheme();
|
|
335
415
|
const { queryByTestId, UNSAFE_root } = renderWithTheme(
|
|
336
|
-
<NativeTabBarGlyph item={ITEMS[0] as TabBarItem} tint={theme.activeTint} size={21} />,
|
|
416
|
+
<NativeTabBarGlyph item={ITEMS[0] as TabBarItem} tint={theme.activeTint} size={21} active />,
|
|
337
417
|
);
|
|
338
418
|
expect(hosts(UNSAFE_root, 'SymbolView')).toHaveLength(0);
|
|
339
419
|
expect(queryByTestId('home-icon')).toBeTruthy();
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* No reanimated mapper may return a React Native style SHORTHAND that
|
|
6
|
+
* react-native-web does not expand.
|
|
7
|
+
*
|
|
8
|
+
* Reanimated's web path hands a mapper's result to RNW's `createReactDOMStyle`
|
|
9
|
+
* and then assigns each surviving key straight onto the DOM node's `style`. That
|
|
10
|
+
* function expands a fixed set of shorthands (`marginInline`, `paddingInline`,
|
|
11
|
+
* `borderWidth`, `borderRadius`, `insetInline`, …) but NOT React Native's own
|
|
12
|
+
* `marginHorizontal` / `marginVertical` / `paddingHorizontal` / `paddingVertical`
|
|
13
|
+
* — verified absent from the entire file in RNW 0.21.2. An unexpanded shorthand
|
|
14
|
+
* becomes `element.style.marginHorizontal = '34px'`, which is not a CSS property,
|
|
15
|
+
* so the browser drops it WITHOUT any error.
|
|
16
|
+
*
|
|
17
|
+
* This shipped once: `barStyle` animated `marginHorizontal` (copied verbatim from
|
|
18
|
+
* upstream expo-glass-tabs, which only ever ran on native). On web the bar kept
|
|
19
|
+
* its full width while `highlightStyle` still subtracted `MINIMIZED_INSET` from
|
|
20
|
+
* `barWidth`, so the sliding highlight sat ~13.6px narrower than the tab beneath
|
|
21
|
+
* it at five tabs — visible only once the bar minimized.
|
|
22
|
+
*
|
|
23
|
+
* STATIC styles are fine and are deliberately not scanned: `StyleSheet.create`
|
|
24
|
+
* takes RNW's own StyleSheet path, which does handle the shorthand. The trap is
|
|
25
|
+
* specific to values produced inside a mapper.
|
|
26
|
+
*
|
|
27
|
+
* Neither tsc nor jest can see this: the shorthand is a valid RN style key, so it
|
|
28
|
+
* type-checks, and the reanimated mock never touches the DOM. Source analysis is
|
|
29
|
+
* the only automatable guard.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const SRC = join(__dirname, '..');
|
|
33
|
+
const ROOTS = ['tab-bar', 'progressive-blur'] as const;
|
|
34
|
+
const MAPPERS = ['useAnimatedStyle', 'useDerivedValue', 'useAnimatedProps'] as const;
|
|
35
|
+
|
|
36
|
+
/** RN shorthands with no entry in RNW's `STYLE_SHORT_FORM_EXPANSIONS`. */
|
|
37
|
+
const UNEXPANDED_SHORTHANDS = [
|
|
38
|
+
'marginHorizontal',
|
|
39
|
+
'marginVertical',
|
|
40
|
+
'paddingHorizontal',
|
|
41
|
+
'paddingVertical',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
function sourceFiles(root: string): string[] {
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
const walk = (dir: string) => {
|
|
47
|
+
for (const entry of readdirSync(dir)) {
|
|
48
|
+
const full = join(dir, entry);
|
|
49
|
+
if (statSync(full).isDirectory()) {
|
|
50
|
+
walk(full);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!/\.tsx?$/.test(entry)) continue;
|
|
54
|
+
if (/\.(test|spec|stories)\.tsx?$/.test(entry)) continue;
|
|
55
|
+
out.push(full);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
walk(join(SRC, root));
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The body of every mapper call in `source`, matched by brace depth from the
|
|
64
|
+
* mapper's opening paren so nested objects and callbacks are included whole.
|
|
65
|
+
*/
|
|
66
|
+
function mapperBodies(source: string): string[] {
|
|
67
|
+
const bodies: string[] = [];
|
|
68
|
+
for (const mapper of MAPPERS) {
|
|
69
|
+
let from = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const start = source.indexOf(`${mapper}(`, from);
|
|
72
|
+
if (start === -1) break;
|
|
73
|
+
let depth = 0;
|
|
74
|
+
let index = source.indexOf('(', start);
|
|
75
|
+
const open = index;
|
|
76
|
+
for (; index < source.length; index += 1) {
|
|
77
|
+
const char = source[index];
|
|
78
|
+
if (char === '(') depth += 1;
|
|
79
|
+
else if (char === ')') {
|
|
80
|
+
depth -= 1;
|
|
81
|
+
if (depth === 0) break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
bodies.push(source.slice(open, index));
|
|
85
|
+
from = index === -1 ? start + mapper.length : index;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return bodies;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('tab-bar animated styles', () => {
|
|
92
|
+
const files = ROOTS.flatMap(sourceFiles);
|
|
93
|
+
|
|
94
|
+
// Self-check: a broken walk must not pass vacuously.
|
|
95
|
+
it('walks the real sources', () => {
|
|
96
|
+
expect(files.length).toBeGreaterThanOrEqual(10);
|
|
97
|
+
const withMappers = files.filter((file) => mapperBodies(readFileSync(file, 'utf8')).length > 0);
|
|
98
|
+
expect(withMappers.length).toBeGreaterThanOrEqual(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('extracts a mapper body that really contains its returned keys', () => {
|
|
102
|
+
const base = files.find((file) => file.endsWith('TabBarBase.tsx'));
|
|
103
|
+
expect(base).toBeDefined();
|
|
104
|
+
const bodies = mapperBodies(readFileSync(base as string, 'utf8'));
|
|
105
|
+
expect(bodies.length).toBeGreaterThanOrEqual(6);
|
|
106
|
+
// `highlightStyle` is the mapper that computes the capsule geometry.
|
|
107
|
+
expect(bodies.some((body) => body.includes('borderRadius'))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it.each(UNEXPANDED_SHORTHANDS)('never returns %s from a mapper', (shorthand) => {
|
|
111
|
+
const offenders: string[] = [];
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
for (const body of mapperBodies(readFileSync(file, 'utf8'))) {
|
|
114
|
+
// Strip comments so the explanatory notes naming the shorthand do not
|
|
115
|
+
// trip the guard that exists because of them.
|
|
116
|
+
const code = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
|
117
|
+
if (new RegExp(`\\b${shorthand}\\s*:`).test(code)) {
|
|
118
|
+
offenders.push(relative(SRC, file));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
expect(offenders).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('animates the minimized inset as both long-form margins', () => {
|
|
126
|
+
const base = readFileSync(
|
|
127
|
+
files.find((file) => file.endsWith('TabBarBase.tsx')) as string,
|
|
128
|
+
'utf8',
|
|
129
|
+
);
|
|
130
|
+
const barStyle = mapperBodies(base).find((body) => body.includes('MINIMIZED_INSET'));
|
|
131
|
+
expect(barStyle).toBeDefined();
|
|
132
|
+
expect(barStyle).toMatch(/marginLeft:/);
|
|
133
|
+
expect(barStyle).toMatch(/marginRight:/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ReactTestInstance } from 'react-test-renderer';
|
|
3
|
+
import { render } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
6
|
+
import { TabBar, TabBarButton, useTabBarFootprint } from '../tab-bar';
|
|
7
|
+
import type { TabBarItem } from '../tab-bar/types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `useTabBarFootprint()` — the space the floating bar physically occupies above
|
|
11
|
+
* the bottom of the window, and nothing more.
|
|
12
|
+
*
|
|
13
|
+
* The bar is absolutely positioned, so nothing accounts for it automatically,
|
|
14
|
+
* and the geometry it is built from (expanded height, bottom gap) is private.
|
|
15
|
+
* Without the hook a consumer hardcodes both — which drifts silently the first
|
|
16
|
+
* time the bar changes by a pixel — and, worse, tends to add `insets.bottom` on
|
|
17
|
+
* top, because that is what laying anything out against the bottom edge
|
|
18
|
+
* normally requires. The bar already folds the inset into its own gap, so that
|
|
19
|
+
* double-counts the home indicator.
|
|
20
|
+
*
|
|
21
|
+
* It is the RAW footprint: no clearance is folded in, because clearance differs
|
|
22
|
+
* per call site (a list wants a gap above the pill, a FAB anchored by `bottom`
|
|
23
|
+
* supplies its own) and only the consumer knows which. That is asserted here
|
|
24
|
+
* rather than merely documented — a margin quietly added to the hook would move
|
|
25
|
+
* every consumer's FAB.
|
|
26
|
+
*
|
|
27
|
+
* The suite asserts four separate things: the absolute pixel values (so a change
|
|
28
|
+
* to the constants must be deliberate), that the number is the bar and only the
|
|
29
|
+
* bar, the inset relationship (so the double-count can never creep back in), and
|
|
30
|
+
* equality with the bar's own rendered geometry (so it cannot drift from where
|
|
31
|
+
* the bar actually sits).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// Mutated per test; must be `mock`-prefixed for the hoisted factory.
|
|
35
|
+
const mockInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
36
|
+
|
|
37
|
+
jest.mock('react-native-safe-area-context', () => ({
|
|
38
|
+
...jest.requireActual('react-native-safe-area-context'),
|
|
39
|
+
useSafeAreaInsets: () => mockInsets,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const ITEMS: TabBarItem[] = [
|
|
43
|
+
{ name: 'home', label: 'Home', icon: null },
|
|
44
|
+
{ name: 'search', label: 'Search', icon: null },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** A device with a home indicator (iPhone-class bottom inset). */
|
|
48
|
+
const HOME_INDICATOR_INSET = 34;
|
|
49
|
+
|
|
50
|
+
function FootprintProbe({ onValue }: { onValue: (space: number) => void }) {
|
|
51
|
+
onValue(useTabBarFootprint());
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function withTheme(ui: React.ReactElement) {
|
|
56
|
+
return (
|
|
57
|
+
<BloomThemeProvider mode="light" colorPreset="teal">
|
|
58
|
+
{ui}
|
|
59
|
+
</BloomThemeProvider>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The footprint the hook reports on a device whose bottom inset is `inset`. */
|
|
64
|
+
function footprintFor(inset: number): number {
|
|
65
|
+
mockInsets.bottom = inset;
|
|
66
|
+
let space: number | undefined;
|
|
67
|
+
render(withTheme(<FootprintProbe onValue={(value) => (space = value)} />));
|
|
68
|
+
if (space === undefined) throw new Error('FootprintProbe never ran');
|
|
69
|
+
return space;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function flattenStyle(style: unknown): Record<string, unknown> {
|
|
73
|
+
const out: Record<string, unknown> = {};
|
|
74
|
+
const visit = (value: unknown): void => {
|
|
75
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
76
|
+
else if (value && typeof value === 'object') Object.assign(out, value);
|
|
77
|
+
};
|
|
78
|
+
visit(style);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The flattened style of every styled node in the tree.
|
|
84
|
+
*
|
|
85
|
+
* Restricted to HOST nodes: the RN mock renders each component as a composite
|
|
86
|
+
* wrapping a host element of the same name, and both carry the same `style`, so
|
|
87
|
+
* counting all of them would double every match.
|
|
88
|
+
*/
|
|
89
|
+
function styledNodes(root: ReactTestInstance): Record<string, unknown>[] {
|
|
90
|
+
return root
|
|
91
|
+
.findAll((node) => typeof node.type === 'string' && node.props?.style !== undefined)
|
|
92
|
+
.map((node) => flattenStyle(node.props.style));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('useTabBarFootprint', () => {
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
mockInsets.bottom = 0;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('measures the bar plus its gap where there is no bottom inset', () => {
|
|
101
|
+
// 58pt bar + the 12pt floor the gap never goes below.
|
|
102
|
+
expect(footprintFor(0)).toBe(70);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('measures more on a device with a home indicator', () => {
|
|
106
|
+
expect(footprintFor(HOME_INDICATOR_INSET)).toBe(76);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('adds no clearance of its own — the number is the bar and only the bar', () => {
|
|
110
|
+
// Clearance belongs to the call site: a list wants a gap above the pill, a
|
|
111
|
+
// FAB anchored by `bottom` supplies its own. A margin folded in here would
|
|
112
|
+
// lift every consumer's FAB off its intended position, and would make
|
|
113
|
+
// "footprint" mean two different things in one file.
|
|
114
|
+
expect(footprintFor(0)).toBe(58 + 12);
|
|
115
|
+
expect(footprintFor(HOME_INDICATOR_INSET)).toBe(58 + (HOME_INDICATOR_INSET - 16));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('already includes the inset — adding insets.bottom would double-count it', () => {
|
|
119
|
+
// The bar absorbs 16pt of the inset into its own gap, so the footprint grows
|
|
120
|
+
// by the REMAINDER, never by the whole inset. A consumer writing
|
|
121
|
+
// `footprint + insets.bottom` would account 110pt for a 76pt bar.
|
|
122
|
+
const grown = footprintFor(HOME_INDICATOR_INSET) - footprintFor(0);
|
|
123
|
+
expect(grown).toBe(6);
|
|
124
|
+
expect(grown).toBeLessThan(HOME_INDICATOR_INSET);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('does not shrink below the floor for a small inset', () => {
|
|
128
|
+
// An inset under what the gap absorbs must not pull the bar down toward the
|
|
129
|
+
// window edge — the gap is clamped, and the footprint with it.
|
|
130
|
+
expect(footprintFor(8)).toBe(footprintFor(0));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('equals the space the rendered bar actually occupies', () => {
|
|
134
|
+
// The invariant that makes the hook worth exporting: its number is the bar's
|
|
135
|
+
// own geometry (the gap it holds off the window edge plus its expanded
|
|
136
|
+
// height), not a second copy that can drift.
|
|
137
|
+
mockInsets.bottom = HOME_INDICATOR_INSET;
|
|
138
|
+
let footprint: number | undefined;
|
|
139
|
+
const { UNSAFE_root } = render(
|
|
140
|
+
withTheme(
|
|
141
|
+
<>
|
|
142
|
+
<FootprintProbe onValue={(value) => (footprint = value)} />
|
|
143
|
+
<TabBar activeIndex={0}>
|
|
144
|
+
{ITEMS.map((item, index) => (
|
|
145
|
+
<TabBarButton key={item.name} item={item} index={index} />
|
|
146
|
+
))}
|
|
147
|
+
</TabBar>
|
|
148
|
+
</>,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const styles = styledNodes(UNSAFE_root);
|
|
153
|
+
// The wrapper holding the pill off the bottom edge — the only node with a
|
|
154
|
+
// bottom margin.
|
|
155
|
+
const gaps = styles.filter((style) => typeof style.marginBottom === 'number');
|
|
156
|
+
expect(gaps).toHaveLength(1);
|
|
157
|
+
// The pill itself: the animated capsule sizes BOTH its height and its
|
|
158
|
+
// horizontal inset, which nothing else in the bar does. The inset is written
|
|
159
|
+
// as the two long-form margins — `marginHorizontal` would be dropped
|
|
160
|
+
// silently on web, since RNW's `createReactDOMStyle` does not expand it (see
|
|
161
|
+
// `TabBarAnimatedShorthand.test.ts`).
|
|
162
|
+
const pills = styles.filter(
|
|
163
|
+
(style) =>
|
|
164
|
+
typeof style.height === 'number' &&
|
|
165
|
+
typeof style.marginLeft === 'number' &&
|
|
166
|
+
typeof style.marginRight === 'number',
|
|
167
|
+
);
|
|
168
|
+
expect(pills).toHaveLength(1);
|
|
169
|
+
|
|
170
|
+
expect(footprint).toBe(Number(gaps[0]?.marginBottom) + Number(pills[0]?.height));
|
|
171
|
+
});
|
|
172
|
+
});
|