diff --git a/.agents/skills/mpx2rn/references/rn-template-reference.md b/.agents/skills/mpx2rn/references/rn-template-reference.md index 55e6cd3708..c11c296da4 100644 --- a/.agents/skills/mpx2rn/references/rn-template-reference.md +++ b/.agents/skills/mpx2rn/references/rn-template-reference.md @@ -1111,6 +1111,8 @@ movable-view 的可移动区域。 从底部弹起的滚动选择器。 +`picker` 节点上的文本样式(如 `color`、`font-size` 等)会透传给触发区域的子节点,用于对齐小程序中父节点文本样式影响子内容的表现。 + #### 属性 | 属性名 | 类型 | 默认值 | 说明 | diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md index 537e495088..dd64fc09f7 100644 --- a/docs-vitepress/guide/rn/component.md +++ b/docs-vitepress/guide/rn/component.md @@ -503,6 +503,8 @@ movable-view的可移动区域。 从底部弹起的滚动选择器。 +`picker` 节点上的文本样式(如 `color`、`font-size` 等)会透传给触发区域的子节点,用于对齐小程序中父节点文本样式影响子内容的表现。 + 属性 | 属性名 | 类型 | 默认值 | 说明 | diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/picker.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/picker.js index ad2f1e349c..4902ccbf42 100644 --- a/packages/webpack-plugin/lib/platform/template/wx/component-config/picker.js +++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/picker.js @@ -8,9 +8,6 @@ module.exports = function ({ print }) { const ttPropLog = print({ platform: 'bytedance', tag: TAG_NAME, isError: false }) const baiduPropLog = print({ platform: 'baidu', tag: TAG_NAME, isError: false }) const qaPropLog = print({ platform: 'qa', tag: TAG_NAME, isError: false }) - const iosPropLog = print({ platform: 'ios', tag: TAG_NAME, isError: false }) - const androidPropLog = print({ platform: 'android', tag: TAG_NAME, isError: false }) - const harmonyPropLog = print({ platform: 'harmony', tag: TAG_NAME, isError: false }) const ksPropLog = print({ platform: 'ks', tag: TAG_NAME, isError: false }) return { @@ -48,9 +45,6 @@ module.exports = function ({ print }) { ali: aliPropLog, jd: jdPropLog, qa: qaPropLog, - ios: iosPropLog, - android: androidPropLog, - harmony: harmonyPropLog, ks: ksPropLog } ], diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx index 820ec34213..64738361bf 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/index.tsx @@ -6,10 +6,11 @@ import PickerMultiSelector from './multiSelector' import PickerTime from './time' import PickerDate from './date' import PickerRegion from './region' +import Portal from '../mpx-portal' import { FormContext, FormFieldValue, RouteContext } from '../context' import useNodesRef, { HandlerRef } from '../useNodesRef' import useInnerProps, { getCustomEvent } from '../getInnerListeners' -import { extendObject, useLayout } from '../utils' +import { extendObject, splitProps, splitStyle, useLayout, useTextPassThrough, useTransformStyle, wrapChildren } from '../utils' import { createPopupManager } from '../mpx-popup' import { EventType, LanguageCode, PickerMode, PickerProps } from './type' @@ -78,13 +79,13 @@ const styles = StyleSheet.create({ } }) -const pickerModalMap: Record> = { +const pickerModalMap = { [PickerMode.SELECTOR]: PickerSelector, [PickerMode.MULTI_SELECTOR]: PickerMultiSelector, [PickerMode.TIME]: PickerTime, [PickerMode.DATE]: PickerDate, [PickerMode.REGION]: PickerRegion -} +} satisfies Record> const getDefaultValue = (mode: PickerMode) => { switch (mode) { @@ -111,16 +112,23 @@ const buttonTextMap: Record = } const Picker = forwardRef, PickerProps>( - (props: PickerProps, ref): React.JSX.Element => { + (pickerProps: PickerProps, ref): React.JSX.Element => { + const { textProps, innerProps: splitInnerProps } = splitProps(pickerProps) + const props = (splitInnerProps || {}) as PickerProps const { mode, value, - range = null, + range = [], children, disabled, bindcancel, bindchange, - 'header-text': headerText = '' + 'header-text': headerText = '', + style, + 'enable-var': enableVar, + 'enable-text-pass-through': enableTextPassThrough, + 'parent-width': parentWidth, + 'parent-height': parentHeight } = props const { pageId } = useContext(RouteContext) || {} @@ -129,12 +137,31 @@ const Picker = forwardRef, PickerProps>( pickerValue.current = Array.isArray(value) ? value.slice() : value const nodeRef = useRef(null) const pickerRef = useRef(null) - const { open, show, hide, remove } = useRef(createPopupManager()).current + const { open, show, hide, remove, update } = useRef(createPopupManager()).current + const { + normalStyle, + hasVarDec, + varContextRef, + hasSelfPercent, + hasPositionFixed, + setWidth, + setHeight + } = useTransformStyle(style, { enableVar, parentWidth, parentHeight }) + const { textStyle, backgroundStyle, innerStyle = {} } = splitStyle(normalStyle) + const textPassThrough = useTextPassThrough(textStyle, textProps, { enableTextPassThrough }) - useNodesRef(props, ref, nodeRef) - const { layoutRef, layoutProps } = useLayout({ + if (backgroundStyle) { + warn('Picker does not support background image-related styles!') + } + + useNodesRef(props, ref, nodeRef, { + style: normalStyle + }) + const { layoutRef, layoutProps, layoutStyle } = useLayout({ props, - hasSelfPercent: false, + hasSelfPercent, + setWidth, + setHeight, nodeRef }) @@ -143,7 +170,8 @@ const Picker = forwardRef, PickerProps>( {}, props, { - ref: nodeRef + ref: nodeRef, + style: extendObject({}, innerStyle, layoutStyle) }, layoutProps ), @@ -228,15 +256,9 @@ const Picker = forwardRef, PickerProps>( hide() } - const specificProps = extendObject({}, innerProps, { - mode, - children, - bindchange: onChange, - bindcolumnchange: onColumnChange, - getRange: () => range - }) + const getContentHeight = () => headerText ? 350 : 310 - const renderPickerContent = () => { + const getPickerContent = () => { if (disabled) { return null } @@ -244,9 +266,22 @@ const Picker = forwardRef, PickerProps>( if (!(_mode in pickerModalMap)) { return warn(`[Mpx runtime warn]: Unsupported mode: ${mode}`) } + const modalProps = props as any + const specificProps = { + mode: _mode, + range, + 'range-key': modalProps['range-key'], + start: modalProps.start, + end: modalProps.end, + fields: modalProps.fields, + level: modalProps.level, + 'custom-item': modalProps['custom-item'], + bindchange: onChange, + bindcolumnchange: onColumnChange + } as PickerProps const _value: any = value - const PickerModal = pickerModalMap[_mode] - const renderPickerModal = ( + const PickerModal = pickerModalMap[_mode] as React.ComponentType + return ( <> {headerText && ( @@ -270,22 +305,51 @@ const Picker = forwardRef, PickerProps>( ) - const contentHeight = headerText ? 350 : 310 - open(renderPickerModal, pageId, { contentHeight }) + } + + const openPickerContent = () => { + const renderPickerModal = getPickerContent() + if (!renderPickerModal) { + return + } + open(renderPickerModal, pageId, { contentHeight: getContentHeight() }) } useEffect(() => { - renderPickerContent() + openPickerContent() return () => { remove() } }, []) - return createElement( + const showPicker = () => { + const renderPickerModal = getPickerContent() + if (!renderPickerModal) { + return + } + update(renderPickerModal, { contentHeight: getContentHeight() }) + show() + } + + const finalComponent = createElement( TouchableWithoutFeedback, - { onPress: show }, - createElement(View, innerProps, children) + { onPress: showPicker }, + createElement( + View, + innerProps, + wrapChildren(children, { + hasVarDec, + varContext: varContextRef.current, + textPassThrough + }) + ) ) + + if (hasPositionFixed) { + return createElement(Portal, null, finalComponent) + } + + return finalComponent } ) diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/multiSelector.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/multiSelector.tsx index 8661d45a95..7503625edf 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/multiSelector.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/multiSelector.tsx @@ -23,7 +23,7 @@ const styles = StyleSheet.create({ }) const formatRangeFun = (range: RangeItem[], rangeKey = '') => - rangeKey ? range.map((item: Obj) => item[rangeKey]) : range + rangeKey ? range.map((item) => (item as Obj)[rangeKey]) : range const formatValueFn = (value: number | number[]) => { return Array.isArray(value) ? value : [value] diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/selector.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/selector.tsx index 6d66dba341..d6e91f1289 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/selector.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/selector.tsx @@ -24,7 +24,7 @@ const styles = StyleSheet.create({ }) const formatRangeFun = (range: RangeItem[], rangeKey = '') => - rangeKey ? range.map((item: Obj) => item[rangeKey]) : range + rangeKey ? range.map((item) => (item as Obj)[rangeKey]) : range const formatValueFn = (value: number | number[] = 0) => { const _value = Array.isArray(value) ? value[0] : value diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/type.ts b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/type.ts index dea29c665c..063a8d3272 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/type.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-picker/type.ts @@ -28,6 +28,10 @@ export interface BasePickerProps { /** 作为表单组件时的名称 */ name?: string style?: Record + 'enable-var'?: boolean + 'enable-text-pass-through'?: boolean + 'parent-width'?: number + 'parent-height'?: number children?: React.ReactNode range?: RangeItem[] ref?: any diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/index.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/index.tsx index 1b05de197e..2162babde2 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/index.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/index.tsx @@ -11,6 +11,10 @@ export interface IUsePopupOptions { type?: PopupType } +type PopupOptions = { + contentHeight?: number +} + /** * 根据 type 返回对应的弹窗壳子组件 */ @@ -44,7 +48,7 @@ const createPopupManager = (options: IUsePopupOptions = {}) => { const open = ( childComponent: React.ReactNode, pageId: number | undefined, - options?: { contentHeight?: number } + options?: PopupOptions ) => { if (!isOpen && pageId != null) { isOpen = true @@ -57,9 +61,9 @@ const createPopupManager = (options: IUsePopupOptions = {}) => { } } - const update = (updatedChild: ReactElement | null) => { + const update = (updatedChild: ReactElement | null, options?: PopupOptions) => { if (popupKey !== null && child !== null && updatedChild !== null) { - child = cloneElement(child, { children: updatedChild }) + child = cloneElement(child, Object.assign({ children: updatedChild }, options)) Portal.update(popupKey, child) } } diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/popupBase.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/popupBase.tsx index 441c2426dc..efc65d7e39 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/popupBase.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-popup/popupBase.tsx @@ -59,8 +59,9 @@ const PopupBase = (props: PopupBaseProps = {}) => { contentHeight = MOVEOUT_HEIGHT, visible = false } = props + const moveoutHeight = contentHeight + bottom const fade = useSharedValue(MASK_OFF) - const slide = useSharedValue(contentHeight) + const slide = useSharedValue(moveoutHeight) const animatedStylesMask = useAnimatedStyle(() => ({ opacity: fade.value @@ -87,7 +88,7 @@ const PopupBase = (props: PopupBaseProps = {}) => { duration: 300 }) slide.value = withTiming( - contentHeight, + moveoutHeight, { easing: Easing.inOut(Easing.poly(3)), duration: 300 diff --git a/packages/webpack-plugin/test/platform/wx/template/picker.spec.js b/packages/webpack-plugin/test/platform/wx/template/picker.spec.js new file mode 100644 index 0000000000..93c0705dbd --- /dev/null +++ b/packages/webpack-plugin/test/platform/wx/template/picker.spec.js @@ -0,0 +1,22 @@ +const { compileTemplate, warnFn, errorFn } = require('../../util') + +describe('picker template transform', function () { + afterEach(() => { + warnFn.mockClear() + errorFn.mockClear() + }) + + it('should support header-text in react native modes', function () { + const input = '' + const modes = ['ios', 'android', 'harmony'] + + modes.forEach((mode) => { + compileTemplate(input, { srcMode: 'wx', mode }) + }) + + expect(warnFn.mock.calls.map(args => args[0])).not.toEqual(expect.arrayContaining([ + expect.stringContaining(' does not support [header-text] property') + ])) + expect(errorFn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/mpx-picker.spec.ts b/packages/webpack-plugin/test/runtime/react-native/mpx-picker.spec.ts new file mode 100644 index 0000000000..c2e26bd265 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/mpx-picker.spec.ts @@ -0,0 +1,247 @@ +/// + +const mockOpen = jest.fn() +const mockShow = jest.fn() +const mockHide = jest.fn() +const mockRemove = jest.fn() +const mockUpdate = jest.fn() +const mockWarn = jest.fn() +const mockEmitter = { + addListener: jest.fn(), + emit: jest.fn(), + removeListener: jest.fn() +} + +jest.mock('react-native', () => ({ + StyleSheet: { + hairlineWidth: 1 / 3, + create: (style: any) => style + }, + Text: 'Text', + TouchableWithoutFeedback: 'TouchableWithoutFeedback', + View: 'View', + DeviceEventEmitter: mockEmitter, + NativeEventEmitter: jest.fn(() => mockEmitter) +}), { virtual: false }) + +jest.mock('react-native-gesture-handler', () => ({ + Gesture: { Tap: () => ({}), Pan: () => ({}), LongPress: () => ({}) } +}), { virtual: false }) + +jest.mock('react-native-safe-area-context', () => ({ + initialWindowMetrics: { insets: { top: 0, right: 0, bottom: 0, left: 0 } } +}), { virtual: false }) + +jest.mock('react', () => { + const actual = jest.requireActual('react') + return Object.assign({}, actual, { + forwardRef: (render: any) => render, + createElement: jest.fn((type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children })), + useCallback: (fn: any) => fn, + useContext: jest.fn(() => null), + useEffect: (effect: any) => effect(), + useImperativeHandle: () => undefined, + useMemo: (factory: any) => factory(), + useRef: (init: any) => ({ current: init }), + useState: (init: any) => [typeof init === 'function' ? init() : init, () => undefined] + }) +}) + +jest.mock('../../../lib/runtime/components/react/mpx-popup', () => ({ + createPopupManager: () => ({ + open: mockOpen, + show: mockShow, + hide: mockHide, + update: mockUpdate, + remove: mockRemove + }) +})) + +jest.mock('../../../lib/runtime/components/react/mpx-picker/selector', () => ({ + __esModule: true, + default: 'PickerSelector' +})) + +jest.mock('../../../lib/runtime/components/react/mpx-picker/multiSelector', () => ({ + __esModule: true, + default: 'PickerMultiSelector' +})) + +jest.mock('../../../lib/runtime/components/react/mpx-picker/time', () => ({ + __esModule: true, + default: 'PickerTime' +})) + +jest.mock('../../../lib/runtime/components/react/mpx-picker/date', () => ({ + __esModule: true, + default: 'PickerDate' +})) + +jest.mock('../../../lib/runtime/components/react/mpx-picker/region', () => ({ + __esModule: true, + default: 'PickerRegion' +})) + +// eslint-disable-next-line import/first +import Picker from '../../../lib/runtime/components/react/mpx-picker' +// eslint-disable-next-line import/first +import { PickerMode } from '../../../lib/runtime/components/react/mpx-picker/type' +// eslint-disable-next-line import/first +import { TextPassThroughContext } from '../../../lib/runtime/components/react/context' + +const findElementByType = (element: any, type: any): any => { + if (!element) return null + if (Array.isArray(element)) { + let matched = null + element.some((item) => { + matched = findElementByType(item, type) + return !!matched + }) + return matched + } + if (element.type === type) return element + return findElementByType(element.props?.children || element.children, type) +} + +describe('MpxPicker RN runtime', () => { + beforeEach(() => { + mockOpen.mockClear() + mockShow.mockClear() + mockHide.mockClear() + mockRemove.mockClear() + mockUpdate.mockClear() + mockWarn.mockClear() + const mpxConfig = (global as any).__mpx.config + mpxConfig.ignoreWarning = true + delete mpxConfig.warnHandler + }) + + test('passes selector props to popup content after filtering root props', () => { + const range = [{ name: 'Beijing' }, { name: 'Shanghai' }, { name: 'Guangzhou' }] + const result = (Picker as any)({ + mode: PickerMode.SELECTOR, + value: 1, + range, + 'range-key': 'name', + style: { width: 100 }, + children: 'Select' + }, null) + + const popupContent = mockOpen.mock.calls[0][0] + const selector = findElementByType(popupContent, 'PickerSelector') + const triggerView = findElementByType(result, 'View') + + expect(selector.props.range).toBe(range) + expect(selector.props['range-key']).toBe('name') + expect(selector.props.style).toBeUndefined() + expect(selector.props.children).toBeUndefined() + expect(triggerView.props.range).toBeUndefined() + }) + + test('defaults omitted selector range to empty array', () => { + const result = (Picker as any)({ + mode: PickerMode.SELECTOR, + value: 0, + children: 'Select' + }, null) + + const popupContent = mockOpen.mock.calls[0][0] + const selector = findElementByType(popupContent, 'PickerSelector') + const triggerView = findElementByType(result, 'View') + + expect(selector.props.range).toEqual([]) + expect(triggerView.props.range).toBeUndefined() + }) + + test('updates selector popup content before showing when range changes', () => { + const range = ['Beijing', 'Shanghai'] + ;(Picker as any)({ + mode: PickerMode.SELECTOR, + value: 0, + range: [], + children: 'Select' + }, null) + const result = (Picker as any)({ + mode: PickerMode.SELECTOR, + value: 0, + range, + children: 'Select' + }, null) + + expect(mockUpdate).not.toHaveBeenCalled() + const openCallCount = mockOpen.mock.calls.length + result.props.onPress() + + const popupContent = mockUpdate.mock.calls[mockUpdate.mock.calls.length - 1][0] + const selector = findElementByType(popupContent, 'PickerSelector') + + expect(mockOpen.mock.calls.length).toBe(openCallCount) + expect(mockShow).toHaveBeenCalledTimes(1) + expect(selector.props.range).toBe(range) + }) + + test('updates popup content height before showing when header text changes', () => { + (Picker as any)({ + mode: PickerMode.SELECTOR, + value: 0, + range: ['Beijing'], + children: 'Select' + }, null) + const result = (Picker as any)({ + mode: PickerMode.SELECTOR, + value: 0, + range: ['Beijing'], + 'header-text': 'City', + children: 'Select' + }, null) + + expect(mockOpen.mock.calls[0][2]).toEqual({ contentHeight: 310 }) + result.props.onPress() + + const updateOptions = mockUpdate.mock.calls[mockUpdate.mock.calls.length - 1][1] + + expect(updateOptions).toEqual({ contentHeight: 350 }) + }) + + test('passes text style through to trigger children', () => { + const result = (Picker as any)({ + mode: PickerMode.SELECTOR, + value: 0, + range: ['Beijing'], + style: { + width: 100, + color: 'red', + fontSize: 20 + }, + children: 'Select' + }, null) + + const triggerView = findElementByType(result, 'View') + const textProvider = findElementByType(triggerView, TextPassThroughContext.Provider) + + expect(triggerView.props.style).toEqual({ width: 100 }) + expect(textProvider.props.value.textStyle).toEqual({ + color: 'red', + fontSize: 20 + }) + }) + + test('warns when background image-related styles are used', () => { + const mpxConfig = (global as any).__mpx.config + mpxConfig.ignoreWarning = false + mpxConfig.warnHandler = mockWarn + + const result = (Picker as any)({ + mode: PickerMode.SELECTOR, + value: 0, + range: ['Beijing'], + style: { + backgroundImage: 'url(test.png)' + }, + children: 'Select' + }, null) + + expect(result).toBeTruthy() + expect(mockWarn.mock.calls[0][0]).toBe('Picker does not support background image-related styles!') + }) +})