diff --git a/packages/design-system-react-native/jest.config.js b/packages/design-system-react-native/jest.config.js index 05bd81de0..7e33e3880 100644 --- a/packages/design-system-react-native/jest.config.js +++ b/packages/design-system-react-native/jest.config.js @@ -22,6 +22,17 @@ module.exports = merge(baseConfig, { lines: 100, statements: 100, }, + // useAnimatedScrollHandler wraps onScroll in a Reanimated worklet; Jest uses the + // reanimated mock, which does not execute that worklet body. Scroll logic is covered + // via updateScrollYFromEvent unit tests, but the hook line that forwards scrollEvent + // into updateScrollYFromEvent stays uncovered here—so statements/lines/functions sit + // below 100% while branches remain fully exercised. + './src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts': { + branches: 100, + functions: 75, + lines: 87, + statements: 87, + }, }, // Add coverage ignore patterns diff --git a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.tsx b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.tsx index 437cc47b0..92d6cfed2 100644 --- a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.tsx +++ b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.tsx @@ -2,14 +2,10 @@ import React, { useMemo } from 'react'; // External dependencies. -import { BoxAlignItems } from '../Box'; -import { BoxColumn } from '../BoxColumn'; import type { ButtonIconProps } from '../ButtonIcon'; import { HeaderBase } from '../HeaderBase'; import { IconName } from '../Icon'; -import { TextOrChildren } from '../temp-components/TextOrChildren'; -import type { TextProps } from '../Text'; -import { FontWeight, TextColor, TextVariant } from '../Text'; +import { HeaderStandardCenterColumn } from '../temp-components/HeaderStandardCenterColumn'; // Internal dependencies. import type { HeaderStandardProps } from './HeaderStandard.types'; @@ -68,38 +64,13 @@ export const HeaderStandard: React.FC = ({ return children; } if (title) { - let subtitleTextProps: Omit, 'children'> | undefined; - if (subtitle && typeof subtitle === 'string') { - const { twClassName: subtitleTwClassName, ...subtitleTextRest } = - subtitleProps ?? {}; - subtitleTextProps = { - variant: TextVariant.BodySm, - color: TextColor.TextAlternative, - ...subtitleTextRest, - twClassName: ['-mt-0.5', subtitleTwClassName] - .filter(Boolean) - .join(' '), - }; - } - return ( - - {subtitle} - - ) : undefined - } - > - {title} - + ); } return null; diff --git a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.types.ts b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.types.ts index 9d3fb9c16..dc4ff61f2 100644 --- a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.types.ts +++ b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.types.ts @@ -1,58 +1,31 @@ -// Third party dependencies. -import React from 'react'; - // External dependencies. import type { ButtonIconProps } from '../ButtonIcon'; import type { HeaderBaseProps } from '../HeaderBase'; -import type { TextProps } from '../Text'; +import type { HeaderStandardCenterColumnFields } from '../temp-components/HeaderStandardCenterColumn'; /** * HeaderStandard component props. */ -export type HeaderStandardProps = HeaderBaseProps & { - /** - * Title to display in the header. Can be a string or a React node. - * Used as children if children prop is not provided. - * When string: rendered with TextVariant.BodyMd and FontWeight.Bold by default; titleProps apply. - * When node: rendered as-is; titleProps are not applied. - */ - title?: string | React.ReactNode; - /** - * Additional props to pass to the title Text component. - * Props are spread to the Text component and can override default values. - * Only applied when title is a string. - */ - titleProps?: Partial; - /** - * Subtitle to display below the title. Can be a string or a React node. - * When string: rendered with TextVariant.BodySm and TextColor.TextAlternative by default; subtitleProps apply. - * When node: rendered as-is; subtitleProps are not applied (add spacing on your root if needed, e.g. twClassName). - */ - subtitle?: string | React.ReactNode; - /** - * Additional props to pass to the subtitle Text component. - * Props are spread to the Text component and can override default values. - * Only applied when subtitle is a string. - */ - subtitleProps?: Partial; - /** - * Callback when the back button is pressed. - * If provided, a back button will be rendered as startButtonIconProps. - */ - onBack?: () => void; - /** - * Additional props to pass to the back ButtonIcon. - * If provided, a back button will be rendered as startButtonIconProps with these props spread. - */ - backButtonProps?: Omit; - /** - * Callback when the close button is pressed. - * If provided, a close button will be added to endButtonIconProps. - */ - onClose?: () => void; - /** - * Additional props to pass to the close ButtonIcon. - * If provided, a close button will be added to endButtonIconProps with these props spread. - */ - closeButtonProps?: Omit; -}; +export type HeaderStandardProps = HeaderBaseProps & + HeaderStandardCenterColumnFields & { + /** + * Callback when the back button is pressed. + * If provided, a back button will be rendered as startButtonIconProps. + */ + onBack?: () => void; + /** + * Additional props to pass to the back ButtonIcon. + * If provided, a back button will be rendered as startButtonIconProps with these props spread. + */ + backButtonProps?: Omit; + /** + * Callback when the close button is pressed. + * If provided, a close button will be added to endButtonIconProps. + */ + onClose?: () => void; + /** + * Additional props to pass to the close ButtonIcon. + * If provided, a close button will be added to endButtonIconProps with these props spread. + */ + closeButtonProps?: Omit; + }; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx new file mode 100644 index 000000000..975c5d683 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import type { ComponentType } from 'react'; +import React from 'react'; +import Animated from 'react-native-reanimated'; + +import { Box } from '../Box'; +import { IconName } from '../Icon'; +import { Text, TextColor, TextVariant } from '../Text'; +import { TitleStandard } from '../TitleStandard'; + +import { HeaderStandardAnimated } from './HeaderStandardAnimated'; +import type { HeaderStandardAnimatedProps } from './HeaderStandardAnimated.types'; +import { useHeaderStandardAnimated } from './useHeaderStandardAnimated'; + +type ScrollStoryArgs = Omit< + HeaderStandardAnimatedProps, + 'scrollY' | 'titleSectionHeight' | 'children' +>; + +const meta: Meta = { + title: 'Components/HeaderStandardAnimated', + component: + HeaderStandardAnimated as unknown as ComponentType, + parameters: { + docs: { + description: { + component: + 'Scroll-linked header: the center title animates with scroll position. Use `useHeaderStandardAnimated` for `scrollY`, `titleSectionHeight`, and `onScroll`, and attach `onScroll` to `Animated.ScrollView`. Use `HeaderStandard` when you do not need this behavior.', + }, + }, + }, + argTypes: { + title: { control: 'text' }, + subtitle: { control: 'text' }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const SampleContent = ({ itemCount = 20 }: { itemCount?: number }) => ( + <> + {Array.from({ length: itemCount }).map((_, index) => ( + + Item {index + 1} + + This is sample content to demonstrate scrolling behavior. + + + ))} + +); + +function ScrollDemo(args: ScrollStoryArgs) { + const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + return ( + + + + setTitleSectionHeight(e.nativeEvent.layout.height)} + > + + Perps + + } + title="ETH-PERP" + twClassName="px-4 pt-1 pb-3" + /> + + + + + ); +} + +export const Default: Story = { + args: { + title: 'Market', + }, + render: (args) => , +}; + +export const Subtitle: Story = { + args: { + title: 'Market', + subtitle: 'Perpetual futures', + onBack: () => undefined, + }, + render: (args) => , +}; + +export const OnBack: Story = { + args: { + title: 'Settings', + onBack: () => undefined, + }, + render: (args) => , +}; + +export const OnClose: Story = { + args: { + title: 'Modal Title', + onClose: () => undefined, + }, + render: (args) => , +}; + +export const BackAndClose: Story = { + args: { + title: 'Settings', + onBack: () => undefined, + onClose: () => undefined, + }, + render: (args) => , +}; + +export const EndButtonIconProps: Story = { + args: { + title: 'Search', + onBack: () => undefined, + onClose: () => undefined, + endButtonIconProps: [ + { + iconName: IconName.Search, + onPress: () => undefined, + }, + ], + }, + render: (args) => , +}; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx new file mode 100644 index 000000000..71dfdf114 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx @@ -0,0 +1,155 @@ +// Third party dependencies. +import { render } from '@testing-library/react-native'; +import React from 'react'; +import type { SharedValue } from 'react-native-reanimated'; + +// Internal dependencies. +import { HeaderStandardAnimated } from './HeaderStandardAnimated'; + +jest.mock('react-native-reanimated', () => { + const ReanimatedModule = jest.requireActual('react-native-reanimated/mock'); + const mockUseAnimatedStyle = jest.fn((fn: unknown) => (fn as () => object)()); + jest + .spyOn(ReanimatedModule, 'useSharedValue') + .mockImplementation((initial: unknown) => ({ + value: initial as number, + })); + return Object.assign(ReanimatedModule, { + useAnimatedStyle: mockUseAnimatedStyle, + }); +}); + +const getUseAnimatedStyleMock = () => + jest.requireMock( + 'react-native-reanimated', + ).useAnimatedStyle as jest.Mock; + +const CONTAINER_TEST_ID = 'header-standard-animated-container'; +const TITLE_TEST_ID = 'header-standard-animated-title'; + +const createMockSharedValue = (initial: number): SharedValue => { + const ref = { value: initial }; + return { + get value() { + return ref.value; + }, + set value(v: number) { + ref.value = v; + }, + get: () => ref.value, + set: (v: number | ((prev: number) => number)) => { + ref.value = typeof v === 'function' ? v(ref.value) : v; + }, + addListener: jest.fn(), + removeListener: jest.fn(), + modify: jest.fn(), + }; +}; + +const defaultProps = { + scrollY: createMockSharedValue(0), + titleSectionHeight: createMockSharedValue(100), +}; + +describe('HeaderStandardAnimated', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('smoke', () => { + it('renders title and optional subtitle', () => { + const { getByText } = render( + , + ); + + expect(getByText('Test Title')).toBeOnTheScreen(); + expect(getByText('Sub')).toBeOnTheScreen(); + }); + + it('forwards testID and titleProps.testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + expect(getByTestId(TITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('omits center title when title is not provided', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Test Title')).not.toBeOnTheScreen(); + }); + }); + + describe('scroll-linked center animation', () => { + it('registers useAnimatedStyle for the center block', () => { + render(); + + expect(getUseAnimatedStyleMock()).toHaveBeenCalled(); + }); + + it('sets full opacity when scrolled past measured title section', () => { + const scrollY = createMockSharedValue(150); + const titleSectionHeight = createMockSharedValue(100); + render( + , + ); + + const styleFn = getUseAnimatedStyleMock().mock.calls[0][0]; + const style = ( + styleFn as () => { + opacity: number; + transform: { translateY: number }[]; + } + )(); + + expect(style.opacity).toBe(1); + expect(style.transform).toStrictEqual([{ translateY: 0 }]); + }); + + it('hides center styles when scroll is within title section', () => { + const scrollY = createMockSharedValue(30); + const titleSectionHeight = createMockSharedValue(100); + render( + , + ); + + const styleFn = getUseAnimatedStyleMock().mock.calls[0][0]; + const style = ( + styleFn as () => { + opacity: number; + transform: { translateY: number }[]; + } + )(); + + expect(style.opacity).toBe(0); + expect(style.transform).toStrictEqual([{ translateY: 8 }]); + }); + }); + + describe('displayName', () => { + it('is set for debugging', () => { + expect(HeaderStandardAnimated.displayName).toBe('HeaderStandardAnimated'); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.tsx b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.tsx new file mode 100644 index 000000000..7c56db307 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.tsx @@ -0,0 +1,64 @@ +// Third party dependencies. +import { AnimationDuration } from '@metamask/design-tokens'; +import React from 'react'; +import Animated, { + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; + +// External dependencies. +import { HeaderStandard } from '../HeaderStandard'; +import { HeaderStandardCenterColumn } from '../temp-components/HeaderStandardCenterColumn'; + +// Internal dependencies. +import type { HeaderStandardAnimatedProps } from './HeaderStandardAnimated.types'; + +const COMPACT_TITLE_ENTER_OFFSET_PX = 8; + +export const HeaderStandardAnimated: React.FC = ({ + title, + titleProps, + subtitle, + subtitleProps, + scrollY, + titleSectionHeight, + twClassName = '', + ...headerStandardProps +}) => { + const compactTitleProgress = useDerivedValue(() => { + const hasMeasured = titleSectionHeight.value > 0; + const isFullyHidden = + hasMeasured && scrollY.value >= titleSectionHeight.value; + return withTiming(isFullyHidden ? 1 : 0, { + duration: AnimationDuration.Fast, + }); + }); + + const centerAnimatedStyle = useAnimatedStyle(() => { + const progress = compactTitleProgress.value; + return { + opacity: progress, + transform: [ + { translateY: (1 - progress) * COMPACT_TITLE_ENTER_OFFSET_PX }, + ], + }; + }); + + return ( + + + {title ? ( + + ) : null} + + + ); +}; + +HeaderStandardAnimated.displayName = 'HeaderStandardAnimated'; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.types.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.types.ts new file mode 100644 index 000000000..7fe1879bf --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.types.ts @@ -0,0 +1,41 @@ +// External dependencies. +import type { + ScrollHandlerProcessed, + SharedValue, +} from 'react-native-reanimated'; + +// Internal dependencies. +import type { HeaderStandardProps } from '../HeaderStandard/HeaderStandard.types'; + +/** + * HeaderStandardAnimated component props. + * Extends HeaderStandardProps with scroll-driven animation inputs. + * Title/subtitle fields come from HeaderStandardCenterColumnFields via HeaderStandardProps. + * Center content is driven by title/subtitle only; `children` is not supported. + */ +export type HeaderStandardAnimatedProps = Omit< + HeaderStandardProps, + 'children' +> & { + /** + * Shared value for scroll offset from the ScrollView. + * Used to drive the center-title animation when scroll passes the title section. + */ + scrollY: SharedValue; + /** + * Shared value for the height of the title section (first child of ScrollView). + * When scrollY >= titleSectionHeight, the compact center title is shown. + */ + titleSectionHeight: SharedValue; +}; + +/** + * Return type for useHeaderStandardAnimated hook. + * onScroll is an animated scroll handler; use with Animated.ScrollView for UI-thread updates. + */ +export type UseHeaderStandardAnimatedReturn = { + scrollY: SharedValue; + titleSectionHeightSv: SharedValue; + setTitleSectionHeight: (height: number) => void; + onScroll: ScrollHandlerProcessed; +}; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/README.md b/packages/design-system-react-native/src/components/HeaderStandardAnimated/README.md new file mode 100644 index 000000000..6496628e6 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/README.md @@ -0,0 +1,78 @@ +# HeaderStandardAnimated + +HeaderStandardAnimated is a scroll-linked variant of [HeaderStandard](../HeaderStandard/README.md). It uses the same center title and subtitle layout as `HeaderStandard`, but fades and shifts that center block based on scroll position so the compact header title area stays readable once the page title section has scrolled away. + +Use [HeaderStandard](../HeaderStandard/README.md) when you do not need scroll-driven center animation. Use `HeaderStandardAnimated` when the header sits above an `Animated.ScrollView` and you want the large title block to hide as the user scrolls past the first section. + +```tsx +import { + HeaderStandardAnimated, + useHeaderStandardAnimated, +} from '@metamask/design-system-react-native'; +import Animated from 'react-native-reanimated'; + +const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + + + + setTitleSectionHeight(e.nativeEvent.layout.height)}> + {/* First scroll section — height drives when the compact title appears */} + + {/* page body */} + +; +``` + +## Props + +Inherits [HeaderStandardProps](../HeaderStandard/README.md) except `children` is not supported; the center is always built from `title` and `subtitle` like `HeaderStandard`. Additional props: + +### `scrollY` + +Reanimated shared value for vertical scroll offset (`contentOffset.y`), typically from `useHeaderStandardAnimated`. + +| TYPE | REQUIRED | DEFAULT | +| --------------------- | -------- | ------- | +| `SharedValue` | Yes | — | + +### `titleSectionHeight` + +Reanimated shared value for the height (in pixels) of the first scroll section below the header. When `scrollY` is at or above this height, the animated center block is fully shown in its compact state. + +| TYPE | REQUIRED | DEFAULT | +| --------------------- | -------- | ------- | +| `SharedValue` | Yes | — | + +## `useHeaderStandardAnimated` + +Returns `scrollY`, `titleSectionHeightSv`, `setTitleSectionHeight`, and `onScroll`. Pass `scrollY` and `titleSectionHeightSv` into `HeaderStandardAnimated` as `scrollY` and `titleSectionHeight`. Wire `onScroll` to `Animated.ScrollView`. Call `setTitleSectionHeight` from `onLayout` on the wrapper whose height should match the “large title” region. + +## Usage + +```tsx +const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + navigation.goBack()} +/>; +``` + +## Accessibility + +Same guidance as [HeaderStandard](../HeaderStandard/README.md): `testID`, `titleProps`, `subtitleProps`, and `backButtonProps` / `closeButtonProps` for labels and test hooks. + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/index.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/index.ts new file mode 100644 index 000000000..007d54e94 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/index.ts @@ -0,0 +1,6 @@ +export { HeaderStandardAnimated } from './HeaderStandardAnimated'; +export { useHeaderStandardAnimated } from './useHeaderStandardAnimated'; +export type { + HeaderStandardAnimatedProps, + UseHeaderStandardAnimatedReturn, +} from './HeaderStandardAnimated.types'; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts new file mode 100644 index 000000000..bf5359d31 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts @@ -0,0 +1,78 @@ +// Third party dependencies. +import { renderHook, act } from '@testing-library/react-native'; +import type { SharedValue } from 'react-native-reanimated'; + +// Internal dependencies. +import { + updateScrollYFromEvent, + useHeaderStandardAnimated, +} from './useHeaderStandardAnimated'; + +jest.mock('react-native-reanimated', () => + jest.requireActual('react-native-reanimated/mock'), +); + +describe('useHeaderStandardAnimated', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('return value', () => { + it('returns scrollY, titleSectionHeightSv, setTitleSectionHeight, and onScroll', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current).toHaveProperty('scrollY'); + expect(result.current).toHaveProperty('titleSectionHeightSv'); + expect(result.current).toHaveProperty('setTitleSectionHeight'); + expect(result.current).toHaveProperty('onScroll'); + expect(typeof result.current.setTitleSectionHeight).toBe('function'); + expect(typeof result.current.onScroll).toBe('function'); + }); + + it('initializes scrollY with value 0', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current.scrollY.value).toBe(0); + }); + + it('initializes titleSectionHeightSv with value 0', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current.titleSectionHeightSv.value).toBe(0); + }); + }); + + describe('setTitleSectionHeight', () => { + it('updates titleSectionHeightSv.value when called', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + act(() => { + result.current.setTitleSectionHeight(120); + }); + + expect(result.current.titleSectionHeightSv.value).toBe(120); + }); + + it('updates titleSectionHeightSv.value on multiple calls', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + act(() => { + result.current.setTitleSectionHeight(50); + }); + expect(result.current.titleSectionHeightSv.value).toBe(50); + + act(() => { + result.current.setTitleSectionHeight(200); + }); + expect(result.current.titleSectionHeightSv.value).toBe(200); + }); + }); + + describe('updateScrollYFromEvent', () => { + it('writes contentOffset.y to the shared value', () => { + const scrollYValue = { value: 0 } as unknown as SharedValue; + updateScrollYFromEvent(scrollYValue, 82); + expect(scrollYValue.value).toBe(82); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts new file mode 100644 index 000000000..02645dc39 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts @@ -0,0 +1,75 @@ +// Third party dependencies. +import { useCallback } from 'react'; +import type { SharedValue } from 'react-native-reanimated'; +import { + useSharedValue, + useAnimatedScrollHandler, +} from 'react-native-reanimated'; + +// Internal dependencies. +import type { UseHeaderStandardAnimatedReturn } from './HeaderStandardAnimated.types'; + +/** + * Writes a vertical content offset into the scroll shared value. + * + * @param scrollYValue - Shared value for vertical scroll offset. + * @param contentOffsetY - `contentOffset.y` from the scroll event. + */ +export function updateScrollYFromEvent( + scrollYValue: SharedValue, + contentOffsetY: number, +) { + scrollYValue.value = contentOffsetY; +} + +/** + * Hook for managing HeaderStandardAnimated scroll-linked animations. + * Use with HeaderStandardAnimated placed outside the ScrollView as a sibling. + * Use the returned onScroll with Animated.ScrollView for UI-thread scroll updates (zero lag). + * + * @returns Object containing scrollY, titleSectionHeightSv, setTitleSectionHeight, and onScroll. + * + * @example + * ```tsx + * const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + * useHeaderStandardAnimated(); + * + * + * + * + * setTitleSectionHeight(e.nativeEvent.layout.height)}> + * ...first scroll section (layout height drives compact title timing) + * + * {/* page body *\/} + * + * + * ``` + */ +export function useHeaderStandardAnimated(): UseHeaderStandardAnimatedReturn { + const scrollYValue = useSharedValue(0); + const titleSectionHeightSv = useSharedValue(0); + + const setTitleSectionHeight = useCallback( + (height: number) => { + titleSectionHeightSv.value = height; + }, + [titleSectionHeightSv], + ); + + const onScroll = useAnimatedScrollHandler({ + onScroll: (scrollEvent) => + updateScrollYFromEvent(scrollYValue, scrollEvent.contentOffset.y), + }); + + return { + scrollY: scrollYValue, + titleSectionHeightSv, + setTitleSectionHeight, + onScroll, + }; +} diff --git a/packages/design-system-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index 0a0828663..289c727d0 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -140,6 +140,21 @@ export type { export { HeaderStandard } from './HeaderStandard'; export type { HeaderStandardProps } from './HeaderStandard'; +export { + HeaderStandardAnimated, + useHeaderStandardAnimated, +} from './HeaderStandardAnimated'; +export type { + HeaderStandardAnimatedProps, + UseHeaderStandardAnimatedReturn, +} from './HeaderStandardAnimated'; + +export { HeaderStandardCenterColumn } from './temp-components/HeaderStandardCenterColumn'; +export type { + HeaderStandardCenterColumnFields, + HeaderStandardCenterColumnProps, +} from './temp-components/HeaderStandardCenterColumn'; + export { Icon, IconColor, IconName, IconSize } from './Icon'; export type { IconProps } from './Icon'; diff --git a/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.stories.tsx b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.stories.tsx new file mode 100644 index 000000000..f417384a3 --- /dev/null +++ b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import React from 'react'; +import { View } from 'react-native'; + +import { HeaderStandardCenterColumn } from './HeaderStandardCenterColumn'; +import type { HeaderStandardCenterColumnProps } from './HeaderStandardCenterColumn.types'; + +const meta: Meta = { + title: 'Temp Components/HeaderStandardCenterColumn', + component: HeaderStandardCenterColumn, + argTypes: { + title: { control: 'text' }, + subtitle: { control: 'text' }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Title', + }, +}; + +export const Subtitle: Story = { + args: { + title: 'Title', + subtitle: 'Subtitle', + }, +}; diff --git a/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.test.tsx b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.test.tsx new file mode 100644 index 000000000..8da24f110 --- /dev/null +++ b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.test.tsx @@ -0,0 +1,34 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; + +import { Text, TextVariant } from '../../Text'; + +import { HeaderStandardCenterColumn } from './HeaderStandardCenterColumn'; + +describe('HeaderStandardCenterColumn', () => { + it('renders title', () => { + const { getByText } = render(); + + expect(getByText('Main')).toBeDefined(); + }); + + it('renders title and string subtitle', () => { + const { getByText } = render( + , + ); + + expect(getByText('Main')).toBeDefined(); + expect(getByText('Sub')).toBeDefined(); + }); + + it('renders subtitle as a React node', () => { + const { getByText } = render( + Node} + />, + ); + + expect(getByText('Node')).toBeDefined(); + }); +}); diff --git a/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.tsx b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.tsx new file mode 100644 index 000000000..baf447e5c --- /dev/null +++ b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.tsx @@ -0,0 +1,51 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import { BoxAlignItems } from '../../Box'; +import { BoxColumn } from '../../BoxColumn'; +import type { TextProps } from '../../Text'; +import { FontWeight, TextColor, TextVariant } from '../../Text'; +import { TextOrChildren } from '../TextOrChildren'; + +// Internal dependencies. +import type { HeaderStandardCenterColumnProps } from './HeaderStandardCenterColumn.types'; + +export function HeaderStandardCenterColumn({ + title, + titleProps, + subtitle, + subtitleProps, +}: HeaderStandardCenterColumnProps) { + let subtitleTextProps: Omit, 'children'> | undefined; + if (subtitle && typeof subtitle === 'string') { + const { twClassName: subtitleTwClassName, ...subtitleTextRest } = + subtitleProps ?? {}; + subtitleTextProps = { + variant: TextVariant.BodySm, + color: TextColor.TextAlternative, + ...subtitleTextRest, + twClassName: ['-mt-0.5', subtitleTwClassName].filter(Boolean).join(' '), + }; + } + + return ( + + {subtitle} + + ) : undefined + } + > + {title} + + ); +} diff --git a/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.types.ts b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.types.ts new file mode 100644 index 000000000..5c3bd3ea9 --- /dev/null +++ b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/HeaderStandardCenterColumn.types.ts @@ -0,0 +1,46 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import type { TextProps } from '../../Text'; + +/** + * Shared title/subtitle fields for header center content (see HeaderStandard, HeaderStandardAnimated). + */ +export type HeaderStandardCenterColumnFields = { + /** + * Title to display in the header. Can be a string or a React node. + * Used as children if children prop is not provided. + * When string: rendered with TextVariant.BodyMd and FontWeight.Bold by default; titleProps apply. + * When node: rendered as-is; titleProps are not applied. + */ + title?: string | React.ReactNode; + /** + * Additional props to pass to the title Text component. + * Props are spread to the Text component and can override default values. + * Only applied when title is a string. + */ + titleProps?: Partial; + /** + * Subtitle to display below the title. Can be a string or a React node. + * When string: rendered with TextVariant.BodySm and TextColor.TextAlternative by default; subtitleProps apply. + * When node: rendered as-is; subtitleProps are not applied (add spacing on your root if needed, e.g. twClassName). + */ + subtitle?: string | React.ReactNode; + /** + * Additional props to pass to the subtitle Text component. + * Props are spread to the Text component and can override default values. + * Only applied when subtitle is a string. + */ + subtitleProps?: Partial; +}; + +/** + * Props for the presentational center column (requires `title` when rendered). + */ +export type HeaderStandardCenterColumnProps = Omit< + HeaderStandardCenterColumnFields, + 'title' +> & { + title: React.ReactNode; +}; diff --git a/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/README.md b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/README.md new file mode 100644 index 000000000..660f6a2cc --- /dev/null +++ b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/README.md @@ -0,0 +1,82 @@ +# HeaderStandardCenterColumn + +HeaderStandardCenterColumn renders a centered header title and optional subtitle with shared typography defaults. [HeaderStandard](../../HeaderStandard/README.md) and [HeaderStandardAnimated](../../HeaderStandardAnimated/README.md) compose this component so title and subtitle styling stay aligned. + +For full header chrome (back, close, layout), use those headers rather than this helper alone. + +```tsx +import { HeaderStandardCenterColumn } from '@metamask/design-system-react-native'; + +; +``` + +## Props + +### `title` + +The main header label. When `title` is a string, it is rendered with `TextVariant.BodyMd` and `FontWeight.Bold`; `titleProps` can override. When `title` is a React node, it is rendered as provided and `titleProps` are not applied. + +| TYPE | REQUIRED | DEFAULT | +| ----------------- | -------- | ------- | +| `React.ReactNode` | Yes | — | + +```tsx +import { HeaderStandardCenterColumn } from '@metamask/design-system-react-native'; + + + +} /> +``` + +### `titleProps` + +Additional props for the title `Text` component. Only applied when `title` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx +import { HeaderStandardCenterColumn } from '@metamask/design-system-react-native'; + +; +``` + +### `subtitle` + +Optional label below the title. When `subtitle` is a string, it is rendered with `TextVariant.BodySm` and `TextColor.TextAlternative`; `subtitleProps` can override. When `subtitle` is a React node, it is rendered as provided and `subtitleProps` are not applied. + +| TYPE | REQUIRED | DEFAULT | +| --------------------------- | -------- | ----------- | +| `string \| React.ReactNode` | No | `undefined` | + +```tsx +import { HeaderStandardCenterColumn } from '@metamask/design-system-react-native'; + +; +``` + +### `subtitleProps` + +Additional props for the subtitle `Text` component. Only applied when `subtitle` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx +import { HeaderStandardCenterColumn } from '@metamask/design-system-react-native'; + +; +``` + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/index.ts b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/index.ts new file mode 100644 index 000000000..5098fff80 --- /dev/null +++ b/packages/design-system-react-native/src/components/temp-components/HeaderStandardCenterColumn/index.ts @@ -0,0 +1,5 @@ +export { HeaderStandardCenterColumn } from './HeaderStandardCenterColumn'; +export type { + HeaderStandardCenterColumnFields, + HeaderStandardCenterColumnProps, +} from './HeaderStandardCenterColumn.types';