diff --git a/apps/storybook-react-native/.storybook/storybook.requires.js b/apps/storybook-react-native/.storybook/storybook.requires.js index 599589f3e..a17029502 100644 --- a/apps/storybook-react-native/.storybook/storybook.requires.js +++ b/apps/storybook-react-native/.storybook/storybook.requires.js @@ -100,6 +100,7 @@ const getStories = () => { "./../../packages/design-system-react-native/src/components/Label/Label.stories.tsx": require("../../../packages/design-system-react-native/src/components/Label/Label.stories.tsx"), "./../../packages/design-system-react-native/src/components/ListItem/ListItem.stories.tsx": require("../../../packages/design-system-react-native/src/components/ListItem/ListItem.stories.tsx"), "./../../packages/design-system-react-native/src/components/MainActionButton/MainActionButton.stories.tsx": require("../../../packages/design-system-react-native/src/components/MainActionButton/MainActionButton.stories.tsx"), + "./../../packages/design-system-react-native/src/components/PickerBase/PickerBase.stories.tsx": require("../../../packages/design-system-react-native/src/components/PickerBase/PickerBase.stories.tsx"), "./../../packages/design-system-react-native/src/components/RadioButton/RadioButton.stories.tsx": require("../../../packages/design-system-react-native/src/components/RadioButton/RadioButton.stories.tsx"), "./../../packages/design-system-react-native/src/components/SensitiveText/SensitiveText.stories.tsx": require("../../../packages/design-system-react-native/src/components/SensitiveText/SensitiveText.stories.tsx"), "./../../packages/design-system-react-native/src/components/Skeleton/Skeleton.stories.tsx": require("../../../packages/design-system-react-native/src/components/Skeleton/Skeleton.stories.tsx"), diff --git a/packages/design-system-react-native/src/components/PickerBase/PickerBase.constants.ts b/packages/design-system-react-native/src/components/PickerBase/PickerBase.constants.ts new file mode 100644 index 000000000..4a7bc3bb0 --- /dev/null +++ b/packages/design-system-react-native/src/components/PickerBase/PickerBase.constants.ts @@ -0,0 +1,13 @@ +import { PickerBaseEndArrow } from '@metamask/design-system-shared'; + +import { IconName } from '../../types'; + +export const MAP_PICKERBASE_END_ARROW_TO_ICON_NAME: Record< + (typeof PickerBaseEndArrow)[keyof typeof PickerBaseEndArrow], + IconName +> = { + [PickerBaseEndArrow.Up]: IconName.ArrowUp, + [PickerBaseEndArrow.Down]: IconName.ArrowDown, + [PickerBaseEndArrow.Left]: IconName.ArrowLeft, + [PickerBaseEndArrow.Right]: IconName.ArrowRight, +}; diff --git a/packages/design-system-react-native/src/components/PickerBase/PickerBase.stories.tsx b/packages/design-system-react-native/src/components/PickerBase/PickerBase.stories.tsx new file mode 100644 index 000000000..bfc277253 --- /dev/null +++ b/packages/design-system-react-native/src/components/PickerBase/PickerBase.stories.tsx @@ -0,0 +1,168 @@ +import { + PickerBaseEndArrow, + TextColor, + TextVariant, +} from '@metamask/design-system-shared'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { Meta, StoryObj } from '@storybook/react-native'; +import type { ViewProps } from 'react-native'; +import { View } from 'react-native'; + +import { Icon, IconName, IconSize } from '../Icon'; + +import { PickerBase } from './PickerBase'; +import type { PickerBaseProps } from './PickerBase.types'; + +const noopPress = () => undefined; + +const meta: Meta = { + title: 'Components/PickerBase', + component: PickerBase, + argTypes: { + endArrow: { + control: 'select', + options: [...Object.values(PickerBaseEndArrow), undefined], + }, + isDisabled: { + control: 'boolean', + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default meta; + +const PickerBaseStoryWrapper: React.FC = ({ + children, + ...props +}) => { + const tw = useTailwind(); + return ( + + {children} + + ); +}; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Select an option', + endArrow: PickerBaseEndArrow.Down, + isDisabled: false, + twClassName: '', + onPress: noopPress, + }, + render: (args) => ( + + + + ), +}; + +export const StartAccessory: Story = { + render: () => ( + + } + > + With start accessory + + + ), +}; + +export const EndArrow: Story = { + render: () => ( + + {( + Object.entries(PickerBaseEndArrow) as [ + keyof typeof PickerBaseEndArrow, + (typeof PickerBaseEndArrow)[keyof typeof PickerBaseEndArrow], + ][] + ).map(([key, value]) => ( + + {`End arrow: ${key}`} + + ))} + + ), +}; + +export const TextProps: Story = { + render: () => ( + + + Custom text variant and color + + + ), +}; + +export const EndArrowIconProps: Story = { + render: () => ( + + + Small arrow + + + Large arrow + + + ), +}; + +export const IsDisabled: Story = { + render: () => ( + + + Enabled + + + Disabled + + + ), +}; + +export const EndAccessory: Story = { + render: () => ( + + } + > + Custom trailing content + + + ), +}; diff --git a/packages/design-system-react-native/src/components/PickerBase/PickerBase.test.tsx b/packages/design-system-react-native/src/components/PickerBase/PickerBase.test.tsx new file mode 100644 index 000000000..ec76d83eb --- /dev/null +++ b/packages/design-system-react-native/src/components/PickerBase/PickerBase.test.tsx @@ -0,0 +1,306 @@ +import { PickerBaseEndArrow } from '@metamask/design-system-shared'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { renderHook } from '@testing-library/react-hooks'; +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { View } from 'react-native'; + +import { IconColor, IconName, IconSize } from '../../types'; +import { TWCLASSMAP_ICON_SIZE_DIMENSION } from '../Icon/Icon.constants'; + +import { PickerBase } from './PickerBase'; +import { MAP_PICKERBASE_END_ARROW_TO_ICON_NAME } from './PickerBase.constants'; + +const ROOT_TEST_ID = 'picker-base'; + +const noopPress = () => undefined; + +describe('PickerBase', () => { + let tw: ReturnType; + + beforeAll(() => { + tw = renderHook(() => useTailwind()).result.current; + }); + + describe('label slot', () => { + it('renders string children', () => { + const { getByText } = render( + + Select + , + ); + + expect(getByText('Select')).toHaveTextContent('Select'); + }); + + it('exposes testID on the root Pressable', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId('custom-picker')).toBeOnTheScreen(); + }); + + it('renders startAccessory before the label', () => { + const { getByTestId } = render( + } + > + With accessory + , + ); + + expect(getByTestId('start-accessory')).toBeOnTheScreen(); + }); + }); + + describe('when endArrow is set', () => { + const cases: { + endArrow: (typeof PickerBaseEndArrow)[keyof typeof PickerBaseEndArrow]; + iconName: IconName; + }[] = [ + { endArrow: PickerBaseEndArrow.Up, iconName: IconName.ArrowUp }, + { endArrow: PickerBaseEndArrow.Down, iconName: IconName.ArrowDown }, + { endArrow: PickerBaseEndArrow.Left, iconName: IconName.ArrowLeft }, + { endArrow: PickerBaseEndArrow.Right, iconName: IconName.ArrowRight }, + ]; + + it.each(cases)( + 'maps endArrow $endArrow to trailing icon $iconName', + ({ endArrow, iconName }) => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId('end-arrow')).toHaveProp('name', iconName); + }, + ); + }); + + describe('when endArrow is omitted', () => { + it('does not render a trailing icon', () => { + const { queryByTestId } = render( + + Label + , + ); + + expect(queryByTestId('end-arrow')).toBeNull(); + }); + }); + + describe('when endAccessory is used', () => { + it('renders when endArrow is omitted', () => { + const { getByTestId } = render( + } + > + With end accessory + , + ); + + expect(getByTestId('end-accessory')).toBeOnTheScreen(); + }); + + it('is ignored when endArrow is set', () => { + const { getByTestId, queryByTestId } = render( + } + endArrowIconProps={{ testID: 'end-arrow' }} + > + Label + , + ); + + expect(getByTestId('end-arrow')).toHaveProp( + 'name', + MAP_PICKERBASE_END_ARROW_TO_ICON_NAME[PickerBaseEndArrow.Down], + ); + expect(queryByTestId('end-accessory')).toBeNull(); + }); + }); + + describe('root Pressable styles', () => { + it('applies default row layout', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toHaveStyle( + tw`flex-row items-center gap-1`, + ); + }); + + it('applies disabled opacity when isDisabled is true', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`opacity-50`); + }); + + it('does not apply disabled opacity when isDisabled is false', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId(ROOT_TEST_ID)).not.toHaveStyle(tw`opacity-50`); + }); + + it('merges twClassName onto the root', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`mt-4`); + }); + + it('merges the style prop after tailwind styles', () => { + const customStyle = { marginBottom: 20 }; + + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toHaveStyle({ marginBottom: 20 }); + }); + }); + + describe('when pressed', () => { + it('invokes onPress', () => { + const onPress = jest.fn(); + + const { getByTestId } = render( + + Label + , + ); + + fireEvent.press(getByTestId(ROOT_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not throw when onPress is omitted', () => { + const { getByTestId } = render( + Label, + ); + + expect(() => { + fireEvent.press(getByTestId(ROOT_TEST_ID)); + }).not.toThrow(); + }); + + it('does not throw when isDisabled is true and onPress is omitted', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(() => { + fireEvent.press(getByTestId(ROOT_TEST_ID)); + }).not.toThrow(); + }); + + it('does not invoke onPress when isDisabled is true', () => { + const onPress = jest.fn(); + + const { getByTestId } = render( + + Label + , + ); + + fireEvent.press(getByTestId(ROOT_TEST_ID)); + + expect(onPress).not.toHaveBeenCalled(); + }); + }); + + describe('when isDisabled is true', () => { + it('disables the root Pressable', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toBeDisabled(); + }); + }); + + describe('Pressable prop forwarding', () => { + it('forwards hitSlop to the root', () => { + const hitSlop = { top: 4, bottom: 4, left: 4, right: 4 }; + + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toHaveProp('hitSlop', hitSlop); + }); + }); + + describe('when endArrowIconProps is provided', () => { + it('applies size to the trailing icon', () => { + const { getByTestId } = render( + + Label + , + ); + + expect(getByTestId('end-arrow')).toHaveStyle( + tw.style( + IconColor.IconDefault, + TWCLASSMAP_ICON_SIZE_DIMENSION[IconSize.Sm], + ), + ); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/PickerBase/PickerBase.tsx b/packages/design-system-react-native/src/components/PickerBase/PickerBase.tsx new file mode 100644 index 000000000..2b8a3f41a --- /dev/null +++ b/packages/design-system-react-native/src/components/PickerBase/PickerBase.tsx @@ -0,0 +1,77 @@ +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import React, { forwardRef, useCallback } from 'react'; +import type { GestureResponderEvent } from 'react-native'; +import { Pressable } from 'react-native'; + +import { Icon, IconSize } from '../Icon'; +import { TextOrChildren } from '../temp-components/TextOrChildren'; + +import { MAP_PICKERBASE_END_ARROW_TO_ICON_NAME } from './PickerBase.constants'; +import type { PickerBaseProps } from './PickerBase.types'; + +export const PickerBase = forwardRef< + React.ComponentRef, + PickerBaseProps +>( + ( + { + children, + textProps, + startAccessory, + endArrow, + endAccessory, + isDisabled = false, + endArrowIconProps, + twClassName, + style, + testID, + onPress, + ...pressableRest + }, + ref, + ) => { + const tw = useTailwind(); + + const handlePress = useCallback( + (event: GestureResponderEvent) => { + if (!isDisabled && onPress) { + onPress(event); + } + }, + [isDisabled, onPress], + ); + + return ( + + {startAccessory} + {children} + {endArrow && ( + + )} + {!endArrow && endAccessory} + + ); + }, +); + +PickerBase.displayName = 'PickerBase'; diff --git a/packages/design-system-react-native/src/components/PickerBase/PickerBase.types.ts b/packages/design-system-react-native/src/components/PickerBase/PickerBase.types.ts new file mode 100644 index 000000000..3ebd56642 --- /dev/null +++ b/packages/design-system-react-native/src/components/PickerBase/PickerBase.types.ts @@ -0,0 +1,28 @@ +import type { PickerBasePropsShared } from '@metamask/design-system-shared'; +import type { PressableProps, StyleProp, ViewStyle } from 'react-native'; + +import type { IconProps } from '../Icon/Icon.types'; +import type { TextProps } from '../Text'; + +/** + * PickerBase component props. + */ +export type PickerBaseProps = Omit & + PickerBasePropsShared & { + /** + * Optional props passed to `Text` when `children` is a string. + */ + textProps?: Omit, 'children'>; + /** + * Optional props passed to the trailing arrow `Icon` when `endArrow` is set (excluding `name`, which is derived from `endArrow`). + */ + endArrowIconProps?: Partial>; + /** + * Optional twrnc class names merged onto the root row. + */ + twClassName?: string; + /** + * Optional style for the root `Pressable`. + */ + style?: StyleProp; + }; diff --git a/packages/design-system-react-native/src/components/PickerBase/README.md b/packages/design-system-react-native/src/components/PickerBase/README.md new file mode 100644 index 000000000..6972b78d9 --- /dev/null +++ b/packages/design-system-react-native/src/components/PickerBase/README.md @@ -0,0 +1,236 @@ +# PickerBase + +PickerBase is a presentational row used as the tap target for picker-style controls. It supports an optional `startAccessory`, a label (`Text` when `children` is a string), and an end slot: pass `endArrow` to show a mapped arrow icon, or omit `endArrow` and pass `endAccessory` for custom trailing content. The root is a `Pressable` with `accessibilityRole="button"` and `accessibilityState.disabled` when `isDisabled` is true—use `accessibilityLabel` or `accessibilityLabelledBy` on the root when the visible label is not enough for assistive technologies. + +```tsx +import { + PickerBase, + PickerBaseEndArrow, +} from '@metamask/design-system-react-native'; + + {}}> + Select an option +; +``` + +## Props + +Shared props live in `@metamask/design-system-shared` as `PickerBasePropsShared`. The React Native component extends the root `Pressable` (excluding `children`, which is the label slot). Other `Pressable` props—such as `hitSlop` or `accessibilityLabel`—are forwarded to that root. + +### `children` + +The label content: a string (styled with `textProps`) or any `ReactNode`. + +| TYPE | REQUIRED | DEFAULT | +| --------------------- | -------- | ------- | +| `ReactNode \| string` | Yes | N/A | + +```tsx +import { + PickerBase, + PickerBaseEndArrow, +} from '@metamask/design-system-react-native'; + + {}}> + Network +; +``` + +### `endArrow` + +When set, shows the trailing arrow icon. Maps to `IconName.ArrowUp`, `ArrowDown`, `ArrowLeft`, or `ArrowRight`. When `endArrow` is omitted, no arrow is rendered; use `endAccessory` for a custom trailing node instead. If both `endArrow` and `endAccessory` are passed, `endArrow` wins and `endAccessory` is ignored. + +Available values: + +- `PickerBaseEndArrow.Up` +- `PickerBaseEndArrow.Down` +- `PickerBaseEndArrow.Left` +- `PickerBaseEndArrow.Right` + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `PickerBaseEndArrow` | No | `undefined` | + +```tsx +import { + PickerBase, + PickerBaseEndArrow, +} from '@metamask/design-system-react-native'; + + {}}> + Navigate +; +``` + +### `endAccessory` + +Optional node at the end of the row when `endArrow` is omitted (for example a custom icon or badge). Not rendered when `endArrow` is set. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + Icon, + IconName, + IconSize, + PickerBase, +} from '@metamask/design-system-react-native'; + + {}} + endAccessory={} +> + With custom end +; +``` + +### `startAccessory` + +Optional node rendered before the label (for example an icon). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + Icon, + IconName, + IconSize, + PickerBase, + PickerBaseEndArrow, +} from '@metamask/design-system-react-native'; + + {}} + startAccessory={} +> + Search +; +``` + +### `textProps` + +Optional props passed to `Text` when `children` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------------------------- | -------- | ----------- | +| `Omit, 'children'>` | No | `undefined` | + +```tsx +import { + PickerBase, + PickerBaseEndArrow, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; + + {}} + textProps={{ variant: TextVariant.BodySm, color: TextColor.TextAlternative }} +> + Styled label +; +``` + +### `endArrowIconProps` + +Optional props forwarded to the trailing `Icon` when `endArrow` is set, except `name` (always derived from `endArrow`). + +| TYPE | REQUIRED | DEFAULT | +| ---------------------------------- | -------- | ----------- | +| `Partial>` | No | `undefined` | + +```tsx +import { + IconSize, + PickerBase, + PickerBaseEndArrow, +} from '@metamask/design-system-react-native'; + + {}} + endArrowIconProps={{ size: IconSize.Sm }} +> + Compact arrow +; +``` + +### `isDisabled` + +When true, disables the `Pressable` and applies reduced opacity. + +| TYPE | REQUIRED | DEFAULT | +| --------- | -------- | ------- | +| `boolean` | No | `false` | + +```tsx +import { + PickerBase, + PickerBaseEndArrow, +} from '@metamask/design-system-react-native'; + + {}} isDisabled> + Disabled +; +``` + +### `twClassName` + +Use the `twClassName` prop to add Tailwind CSS classes to the root `Pressable`. These classes will be merged with the component's default classes using `twMerge`, allowing you to: + +- Add new styles that don't exist in the default component +- Override the component's default styles when needed + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +```tsx +import { PickerBase } from '@metamask/design-system-react-native'; + +// Add additional styles + {}} twClassName="mt-4"> + With margin + + +// Override default styles + {}} twClassName="px-6"> + Wider horizontal padding + +``` + +### `style` + +Use the `style` prop to customize the root `Pressable` with React Native styles. For consistent styling, prefer using `twClassName` with Tailwind classes when possible. Use `style` with `tw.style()` for conditionals or dynamic values. + +| TYPE | REQUIRED | DEFAULT | +| ---------------------- | -------- | ----------- | +| `StyleProp` | No | `undefined` | + +```tsx +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { PickerBase } from '@metamask/design-system-react-native'; + +export const ConditionalExample = ({ isActive }: { isActive: boolean }) => { + const tw = useTailwind(); + + return ( + {}} + style={tw.style('bg-transparent', isActive && 'bg-muted')} + > + Conditional styling + + ); +}; +``` + +## 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/PickerBase/index.ts b/packages/design-system-react-native/src/components/PickerBase/index.ts new file mode 100644 index 000000000..061e3a5a9 --- /dev/null +++ b/packages/design-system-react-native/src/components/PickerBase/index.ts @@ -0,0 +1,3 @@ +export { PickerBaseEndArrow } from '@metamask/design-system-shared'; +export { PickerBase } from './PickerBase'; +export type { PickerBaseProps } from './PickerBase.types'; diff --git a/packages/design-system-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index e057985d2..6d104aee4 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -173,6 +173,9 @@ export type { MaskiconProps } from './temp-components/Maskicon'; export { MainActionButton } from './MainActionButton'; export type { MainActionButtonProps } from './MainActionButton'; +export { PickerBase, PickerBaseEndArrow } from './PickerBase'; +export type { PickerBaseProps } from './PickerBase'; + export { Skeleton } from './Skeleton'; export type { SkeletonProps } from './Skeleton'; diff --git a/packages/design-system-shared/src/index.ts b/packages/design-system-shared/src/index.ts index 3868af23e..0597bf763 100644 --- a/packages/design-system-shared/src/index.ts +++ b/packages/design-system-shared/src/index.ts @@ -58,6 +58,12 @@ export { type KeyValueRowPropsShared, } from './types/KeyValueRow'; +// PickerBase types (ADR-0003 + ADR-0004) +export { + PickerBaseEndArrow, + type PickerBasePropsShared, +} from './types/PickerBase'; + // ButtonFilter types (ADR-0004) export { type ButtonFilterPropsShared } from './types/ButtonFilter'; diff --git a/packages/design-system-shared/src/types/PickerBase/PickerBase.types.ts b/packages/design-system-shared/src/types/PickerBase/PickerBase.types.ts new file mode 100644 index 000000000..03cff059e --- /dev/null +++ b/packages/design-system-shared/src/types/PickerBase/PickerBase.types.ts @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; + +import type { TextOrChildrenPropsShared } from '../TextOrChildren'; + +/** + * PickerBase — trailing arrow direction (maps to platform arrow icons). + * Convert from enum to const object (ADR-0003). + */ +export const PickerBaseEndArrow = { + Up: 'up', + Down: 'down', + Left: 'left', + Right: 'right', +} as const; +export type PickerBaseEndArrow = + (typeof PickerBaseEndArrow)[keyof typeof PickerBaseEndArrow]; + +/** + * PickerBase component shared props (ADR-0004). + */ +export type PickerBasePropsShared = TextOrChildrenPropsShared & { + /** + * Optional node rendered before the label (for example an icon). + */ + startAccessory?: ReactNode; + /** + * When set, the mapped trailing arrow icon is shown at the end of the row. + * When omitted, use `endAccessory` for a custom trailing node instead. + */ + endArrow?: PickerBaseEndArrow; + /** + * Optional node at the end of the row when `endArrow` is omitted (for example a custom icon or badge). + */ + endAccessory?: ReactNode; + /** + * When true, disables the root control and applies disabled presentation. + * + * @default false + */ + isDisabled?: boolean; +}; diff --git a/packages/design-system-shared/src/types/PickerBase/index.ts b/packages/design-system-shared/src/types/PickerBase/index.ts new file mode 100644 index 000000000..d6e6a36fa --- /dev/null +++ b/packages/design-system-shared/src/types/PickerBase/index.ts @@ -0,0 +1,4 @@ +export { + PickerBaseEndArrow, + type PickerBasePropsShared, +} from './PickerBase.types';